app/console/ui.rb
the library's own source, console 0.2.0 · back to Console::UI
# Console::UI -- immediate-mode widgets. # # Widgets are described as data and drawn through Console::Draw. There is no # retained widget tree: you ask for a widget, it draws itself and reports its # state. That keeps the API small and keeps a prototype's UI readable top to # bottom. # # b = ui.button x: 40, y: 300, w: 200, h: 44, text: 'START' # goto :play if b[:clicked] # # ui.panel x: 20, y: 20, w: 260, h: 120, title: 'STATS' # ui.bar x: 40, y: 60, w: 200, value: hp, max: max_hp # ui.label x: 40, y: 100, text: "HP: #{hp}" # # Every widget takes ONE options hash and returns a Hash describing what # happened (at least :rect, plus :hover/:clicked/:value/:index as relevant). # One hash per widget is not just style: mruby binds a trailing `key: value` # list to the FIRST optional parameter of a method, so multi-parameter widget # signatures silently swallow their geometry. One hash sidesteps that entirely # and matches the draw.* helpers. # # Widgets are keyboard/gamepad navigable by default: hold a pointer over a # button (or press direction keys on a menu) and :accept activates it. module Console class UI attr_reader :console def initialize(console) @console = console end def draw @console.draw end def input @console.input end def store @console.ui_store end # Resolve a widget's box from its options, honouring either `y:` (bottom-left # origin, DragonRuby's convention) or `top:` (distance from the top of the # screen, which is how UI is usually specified). Widgets use this so that # `top:` works everywhere consistently. def box(opts, w, h) x = opts[:x] || 0 if opts[:top] Geom.anchored_top x, opts[:top], w, h, @console.args.grid else { x: x, y: opts[:y] || 0, w: w, h: h } end end # --- text ------------------------------------------------------------- # A text label. Returns the measured rect. def label(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 text = opts.delete(:text) text = '' if text.nil? align = opts.delete(:align) if align == :center opts[:anchor_x] = 0.5 opts[:anchor_y] = 0.5 end draw.text text.to_s, opts.merge(x: x, y: y) end # A label with a solid plate behind it, for HUD counters over a busy # background. def chip(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 text = opts.delete(:text).to_s bg = opts.delete(:bg) || Style.get(:panel_d) pad = opts.delete(:pad) || 6 tr = draw.text text, opts.merge(x: x + pad, y: y + pad) box = { x: x, y: y, w: tr[:w] + (pad * 2), h: tr[:h] + (pad * 2) } draw.rect box.merge(color: bg) box end # --- containers ------------------------------------------------------- # A panel with an optional title bar. Returns the inner content rect. def panel(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 w = opts.delete(:w) || 0 h = opts.delete(:h) || 0 title = opts.delete(:title) title_h = opts.delete(:title_h) || 28 pad = opts.delete(:pad) || Style.get(:pad) color = opts.delete(:color) || Style.get(:panel) alpha = opts.delete(:alpha) border_color = opts.delete(:border_color) r = box(opts, w, h) x = r[:x] y = r[:y] draw.panel r.merge(color: color, alpha: alpha, border_color: border_color) top = y + h if title draw.rect x: x, y: top - title_h, w: w, h: title_h, color: opts[:title_color] || Style.get(:panel_d) draw.text title, x: x + pad, y: top - (title_h / 2.0), anchor_y: 0.5, color: Style.get(:accent) draw.line x, top - title_h, x + w, top - title_h, Style.get(:border) top -= title_h end inner = Geom.inset({ x: x, y: y, w: w, h: h }, pad) inner[:h] = (top - pad) - inner[:y] inner end # --- indicators ------------------------------------------------------- # A progress bar. Returns the rect, the fill width and a clamped fraction. def bar(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 w = opts.delete(:w) || 100 h = opts.delete(:h) || 18 value = opts.delete(:value) || 0 max = opts.delete(:max) max = value if max.nil? color = opts.delete(:color) || Style.get(:accent) bg = opts.delete(:bg) || Style.get(:panel_d) show_text = opts.delete(:text) show_counts = opts.delete(:show_counts) border = opts.delete(:border) r = box(opts, w, h) x = r[:x] y = r[:y] fill = draw.bar r, value, max, color: color, bg: bg, border: border if show_text caption = show_text.is_a?(Symbol) ? show_text.to_s : show_text caption = "#{value}/#{max} #{caption}" if show_counts draw.text caption, x: x + (w / 2.0), y: y + (h / 2.0), anchor_x: 0.5, anchor_y: 0.5, color: Style.get(:text) end { rect: r, fill: fill, perc: Geom.perc(value, max), max: max, value: value } end # Segmented meter: one cell per unit, filled up to `value`. def pips(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 w = opts.delete(:w) || 100 h = opts.delete(:h) || 14 gap = opts.delete(:gap) || 3 value = opts.delete(:value) || 0 max = opts.delete(:max) || value count = max.to_i count = 1 if count < 1 color = opts.delete(:color) || Style.get(:accent) off = opts.delete(:off_color) || Style.get(:panel_d) r = box(opts, w, h) x = r[:x] y = r[:y] cells = Geom.columns(r, count, gap) filled = value.to_i cells.each_with_index do |c, i| draw.rect c.merge(color: (i < filled ? color : off)) end { rect: r, cells: cells, value: value } end # A slider bound to console UI state. Click to focus, then use left/right # (or the dpad) to adjust. def slider(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 w = opts.delete(:w) || 160 h = opts.delete(:h) || 24 min = opts.delete(:min) || 0 max = opts.delete(:max) || 100 step = opts.delete(:step) || 5 key = opts.delete(:key) color = opts.delete(:color) || Style.get(:accent) r = box(opts, w, h) x = r[:x] y = r[:y] focus_key = key || "slider_#{x}_#{y}" focused = store[focus_key] ? true : false value = key ? (store[key] || min) : min value = value.clamp(min, max) track_rect = r if input.pointer_inside?(track_rect) && input.pointer_pressed? store[focus_key] = true focused = true elsif input.pressed?(:cancel) store.delete focus_key focused = false end changed = false if focused if input.pressed?(:right) value += step changed = true end if input.pressed?(:left) value -= step changed = true end value = value.clamp(min, max) store[key] = value if key end track = { x: x, y: y + ((h - 8) / 2.0), w: w, h: 8 } draw.rect track.merge(color: Style.get(:panel_d)) perc = Geom.perc(value - min, max - min) draw.rect x: track[:x], y: track[:y], w: track[:w] * perc, h: track[:h], color: color knob_w = 14.0 knob_x = x + ((w - knob_w) * perc) draw.rect x: knob_x, y: y, w: knob_w, h: h, color: (focused ? :white : color) draw.text value.to_s, x: x + w + 8, y: y + (h / 2.0), anchor_y: 0.5, color: Style.get(:muted) { rect: track_rect, value: value, min: min, max: max, focused: focused, perc: perc, changed: changed } end # --- interactive ------------------------------------------------------ # A button. `:clicked` is true on the frame it is activated, by pointer or # by :accept while focused. def button(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 w = opts.delete(:w) || 120 h = opts.delete(:h) || 36 text = opts.delete(:text) || '' id = opts.delete(:id) || text on_click = opts.delete(:on_click) color = opts.delete(:color) || Style.get(:panel) active_color = opts.delete(:active_color) || Style.get(:accent) text_color = opts.delete(:text_color) || Style.get(:text) disabled = opts.delete(:disabled) ? true : false r = box(opts, w, h) x = r[:x] y = r[:y] key = "btn_#{id}" hovered = !disabled && input.pointer_inside?(r) focused = store['ui_focus'] == key if hovered store[key] = true end if input.pointer_pressed? && hovered store['ui_focus'] = key focused = true end if input.pressed?(:cancel) store.delete key focused = false end if focused && input.pressed?(:accept) store['ui_focus'] = key end clicked = false if !disabled && hovered && input.pointer_pressed? clicked = true end if !disabled && focused && input.pressed?(:accept) clicked = true end on_click.call(id) if clicked && on_click bg = if disabled Style.get(:panel_d) elsif clicked || focused active_color else color end tc = disabled ? Style.get(:muted) : text_color draw.rect r.merge(color: bg) draw.border r.merge(color: Style.get(:border)), 1 draw.text text, x: x + (w / 2.0), y: y + (h / 2.0), anchor_x: 0.5, anchor_y: 0.5, color: tc { rect: r, hovered: hovered, focused: focused, clicked: clicked, disabled: disabled, id: id, text: text } end # A checkbox bound to console UI state. Returns the new value. def checkbox(options = {}) opts = options.dup x = opts[:x] || 0 y = opts[:y] || 0 key = opts.delete(:key) text = opts.delete(:text) || '' box = opts.delete(:box) || 22 r = box(opts, box, box) x = r[:x] y = r[:y] value = store[key] ? true : false clicked = input.pointer_inside?(r) && input.pointer_pressed? if clicked value = !value store[key] = value end draw.border r.merge(color: Style.get(:accent)), 2 draw.rect Geom.inset(r, 5).merge(color: Style.get(:accent)) if value draw.text text, x: x + box + 10, y: y + (box / 2.0), anchor_y: 0.5 { rect: r, value: value, changed: clicked } end # A vertical list with keyboard/gamepad navigation. # # m = ui.menu x: 640, y: 200, items: ['PLAY', 'OPTIONS'], key: 'main' # goto :play if m[:clicked] == 'PLAY' def menu(options = {}) opts = options.dup items = opts.delete(:items) || [] x = opts[:x] || 0 y = opts[:y] || 0 width = opts.delete(:w) || 260 row_h = opts.delete(:row_h) || Style.get(:row_h) gap = opts.delete(:gap) || Style.get(:row_gap) key = opts.delete(:key) || 'menu' color = opts.delete(:color) || Style.get(:panel) active_color = opts.delete(:active_color) || Style.get(:accent) on_select = opts.delete(:on_select) index = store["menu_index_#{key}"] || opts.delete(:selected) || 0 index = 0 if index < 0 index = items.size - 1 if items.size > 0 && index > items.size - 1 anchor = box(opts, width, row_h) x = anchor[:x] y = anchor[:y] rects = [] hovered_index = -1 items.each_with_index do |item, i| cell = { x: x, y: y + (i * (row_h + gap)), w: width, h: row_h } rects << cell hovered_index = i if input.pointer_inside?(cell) end clicked = nil if hovered_index >= 0 index = hovered_index if input.pointer_pressed? clicked = items[index] end elsif items.size > 0 # Keyboard / gamepad navigation when the pointer is not on the menu. if input.pressed?(:up) index -= 1 index = items.size - 1 if index < 0 end if input.pressed?(:down) index += 1 index = 0 if index > items.size - 1 end if input.pressed?(:accept) clicked = items[index] end end store["menu_index_#{key}"] = index on_select.call(items[index]) if clicked && on_select rects.each_with_index do |cell, i| selected = i == index draw.rect cell.merge(color: (selected ? active_color : color)) draw.border cell.merge(color: Style.get(:border)), 1 unless selected draw.text items[i].to_s, x: cell[:x] + (cell[:w] / 2.0), y: cell[:y] + (cell[:h] / 2.0), anchor_x: 0.5, anchor_y: 0.5, color: (selected ? :dark : Style.get(:text)) end total_h = (rects.size * (row_h + gap)) - gap { index: index, clicked: clicked, items: rects, labels: items, rect: { x: x, y: y, w: width, h: total_h }, hovered: hovered_index >= 0 } end # --- HUD -------------------------------------------------------------- # The debug/status overlay. Toggle with :debug_toggle (F1). def hud(_options = {}) return nil unless @console.show_hud grid = @console.args.grid lines = [] lines << "fps #{DR.current_framerate.round}" lines << "scene #{@console.scenes.current_name} depth #{@console.scenes.depth}" counts = @console.kind_counts lines << "entities #{@console.entities.size} " \ "#{counts.map { |k, v| "#{k}:#{v}" }.join(' ')}".strip lines << "tweens #{@console.tweens.tweens.size} " \ "timers #{@console.tweens.timers.size} " \ "tickers #{@console.tweens.tickers.size}" missing = Sprites.missing_names unless missing.size == 0 lines << "MISSING SPRITES #{missing.size}" missing.first(5).each { |m| lines << " #{m}" } end pad = 8 line_h = 16 box_w = 320 box_h = (lines.size * line_h) + (pad * 2) bx = grid.w - box_w - 10 by = grid.h - box_h - 10 draw.rect x: bx, y: by, w: box_w, h: box_h, color: :black, alpha: 180 lines.each_with_index do |line, i| draw.text line, x: bx + pad, y: by + pad + (i * line_h), size_enum: -3, color: Style.get(:text) end { rect: { x: bx, y: by, w: box_w, h: box_h }, lines: lines } end # A centred modal card for title / pause / game-over screens. def card(options = {}) opts = options.dup grid = @console.args.grid w = opts.delete(:w) || 420 h = opts.delete(:h) || 300 dim = opts.key?(:dim) ? opts.delete(:dim) : true color = opts.delete(:color) || Style.get(:panel) alpha = opts.delete(:alpha) if dim draw.rect x: 0, y: 0, w: grid.w, h: grid.h, color: :black, alpha: opts.delete(:dim_alpha) || 170 end r = Geom.place(Geom.screen_rect(grid), x: 0.5, y: 0.5, w: w, h: h, anchor_x: 0.5, anchor_y: 0.5) draw.panel r.merge(color: color, alpha: alpha) r end end end