Files
rubbs/lib/bbs/menu.rb
2026-05-13 18:26:35 +02:00

319 lines
8.8 KiB
Ruby

# frozen_string_literal: true
require_relative 'widget'
require_relative 'window'
module BBS
# Menubar — a horizontal strip at the top of the screen with named
# menus. Each menu has a label like "&File" (Alt-F opens it) and a list of
# MenuItem objects.
class Menubar < Widget
Menu = Struct.new(:label, :items, :x, keyword_init: true) do
def hotkey
idx = label.index('&')
idx && idx + 1 < label.length ? label[idx + 1].downcase : nil
end
def display = label.sub('&', '')
end
MenuItem = Struct.new(:label, :shortcut, :enabled, :on_select, :separator,
keyword_init: true) do
def hotkey
return nil if separator
idx = label.index('&')
idx && idx + 1 < label.length ? label[idx + 1].downcase : nil
end
def display = separator ? '─' : label.sub('&', '')
end
attr_reader :menus
attr_accessor :on_activate
def initialize(**kw)
super
@menus = []
@open_index = nil
@item_index = 0
@on_activate = nil
end
def add_menu(label, &block)
builder = MenuBuilder.new
builder.instance_eval(&block) if block
@menus << Menu.new(label: label, items: builder.items, x: nil)
self
end
def open? = !@open_index.nil?
def opened_menu = @open_index ? @menus[@open_index] : nil
def open_index = @open_index
def selected_item = opened_menu&.items&.[](@item_index)
def open(index)
idx = index.clamp(0, @menus.size - 1)
@open_index = idx
@item_index = first_selectable_index(@menus[idx].items) || 0
end
def close
@open_index = nil
@item_index = 0
end
def focusable? = false
def render(frame)
sgr = style(:menubar)
sel = style(:menubar_selected)
hotkey = style(:menubar_hotkey)
sel_hot = style(:menubar_sel_hot)
frame.move(bounds.x, bounds.y)
frame.write(' ' * bounds.width, sgr: sgr)
cx = bounds.x + 1
@menus.each_with_index do |menu, i|
menu.x = cx
is_open = (@open_index == i)
slabel = is_open ? sel : sgr
shotkey = is_open ? sel_hot : hotkey
frame.move(cx, bounds.y)
frame.write(' ', sgr: slabel)
render_hotkey_label(frame, menu.label, cx + 1, bounds.y, slabel, shotkey)
frame.move(cx + 1 + menu.display.length, bounds.y)
frame.write(' ', sgr: slabel)
cx += menu.display.length + 3
end
render_dropdown(frame) if open?
end
def handle_event(event)
return navigate(event) if open?
return open_via_keyboard(event) if event.key?
if event.mouse? && event.mouse.kind == :press && event.mouse.button == :left
return handle_mouse_press(event)
end
nil
end
def navigate(event)
if event.mouse?
if event.mouse.kind == :press && event.mouse.button == :left
return handle_mouse_press(event)
end
return :handled
end
menu = opened_menu
return nil unless menu
case event.key
when :left
@open_index = (@open_index - 1) % @menus.size
@item_index = first_selectable_index(opened_menu.items) || 0
when :right
@open_index = (@open_index + 1) % @menus.size
@item_index = first_selectable_index(opened_menu.items) || 0
when :up
@item_index = prev_selectable_index(menu.items, @item_index)
when :down
@item_index = next_selectable_index(menu.items, @item_index)
when :escape, :f10
close
when :enter
return activate_current
else
if event.char?
activated = activate_by_hotkey(menu.items, event.key)
return activated if activated
end
end
:handled
end
def open_via_keyboard(event)
if event.key == :f10
open(0)
return :handled
end
if event.alt? && event.alt_char
idx = @menus.find_index { |m| m.hotkey == event.alt_char.downcase }
if idx
open(idx)
return :handled
end
end
nil
end
private
def handle_mouse_press(event)
if event.y == bounds.y
# menubar click
idx = @menus.find_index do |m|
m.x && event.x >= m.x && event.x < m.x + m.display.length + 2
end
if idx
@open_index == idx ? close : open(idx)
return :handled
end
close
return :handled
end
if open?
menu = opened_menu
x0 = menu.x
y0 = bounds.y + 1
w = menu_width(menu)
h = menu.items.size + 2
if event.x >= x0 && event.x < x0 + w && event.y >= y0 && event.y < y0 + h
inner_y = event.y - y0 - 1
if inner_y >= 0 && inner_y < menu.items.size
item = menu.items[inner_y]
unless item.separator || item.enabled == false
@item_index = inner_y
return activate_current
end
end
return :handled
end
close
return :handled
end
nil
end
def activate_current
item = selected_item
return :handled unless item && !item.separator && item.enabled != false
close
result = item.on_select&.call(item)
@on_activate&.call(item)
result == :halt ? :halt : :handled
end
def activate_by_hotkey(items, ch)
down = ch.downcase
idx = items.find_index { |i| !i.separator && i.enabled != false && i.hotkey == down }
return nil unless idx
@item_index = idx
activate_current
end
def first_selectable_index(items)
items.find_index { |i| !i.separator && i.enabled != false }
end
def next_selectable_index(items, from)
n = items.size
i = from
n.times do
i = (i + 1) % n
return i if !items[i].separator && items[i].enabled != false
end
from
end
def prev_selectable_index(items, from)
n = items.size
i = from
n.times do
i = (i - 1) % n
return i if !items[i].separator && items[i].enabled != false
end
from
end
def menu_width(menu)
max_label = menu.items.reject(&:separator).map { |i| i.display.length }.max || 0
max_short = menu.items.reject(&:separator).map { |i| i.shortcut.to_s.length }.max || 0
max_label + max_short + 6
end
def render_dropdown(frame)
menu = opened_menu
return unless menu
x = menu.x
y = bounds.y + 1
w = menu_width(menu)
h = menu.items.size + 2
frame.shadow(x: x, y: y, width: w, height: h, sgr: style(:window_shadow))
frame.box(x: x, y: y, width: w, height: h,
sgr: style(:menu_border), fill_sgr: style(:menu_bg))
menu.items.each_with_index do |item, idx|
row_y = y + 1 + idx
if item.separator
frame.hline(x: x + 1, y: row_y, length: w - 2, char: '─', sgr: style(:menu_border))
next
end
selected = (idx == @item_index)
sgr = if !item.enabled.nil? && item.enabled == false
style(:menu_disabled)
elsif selected
style(:menu_selected)
else
style(:menu_text)
end
frame.move(x + 1, row_y)
frame.write(' ' * (w - 2), sgr: sgr)
# label with hotkey highlight
frame.move(x + 2, row_y)
label = item.label
idx_amp = label.index('&')
if idx_amp && item.enabled != false
frame.write(label[0, idx_amp], sgr: sgr)
frame.write(label[idx_amp + 1], sgr: selected ? sgr : style(:menu_hotkey))
rest = label[(idx_amp + 2)..]
frame.write(rest, sgr: sgr) if rest
else
frame.write(item.display, sgr: sgr)
end
if item.shortcut
short = item.shortcut.to_s
frame.move(x + w - 1 - short.length, row_y)
frame.write(short, sgr: sgr)
end
end
end
def render_hotkey_label(frame, label, x, y, sgr, hotkey_sgr)
idx = label.index('&')
if idx
frame.move(x, y)
frame.write(label[0, idx], sgr: sgr) if idx > 0
frame.write(label[idx + 1], sgr: hotkey_sgr)
rest = label[(idx + 2)..]
frame.write(rest, sgr: sgr) if rest && !rest.empty?
else
frame.move(x, y)
frame.write(label, sgr: sgr)
end
end
class MenuBuilder
attr_reader :items
def initialize
@items = []
end
def item(label, shortcut: nil, enabled: true, &block)
@items << MenuItem.new(label: label, shortcut: shortcut,
enabled: enabled, on_select: block, separator: false)
end
def separator
@items << MenuItem.new(label: '', shortcut: nil, enabled: false,
on_select: nil, separator: true)
end
end
end
end