322 lines
9.0 KiB
Ruby
322 lines
9.0 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 open_via_keyboard(event) if !open? && event.key?
|
|
return navigate(event) if open?
|
|
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
|
|
r = activate_current
|
|
$stderr.puts "[menu] navigate enter -> #{r.inspect}"
|
|
return r
|
|
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
|
|
if event.mouse? && event.mouse.kind == :press && event.mouse.button == :left
|
|
return handle_mouse_press(event)
|
|
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)
|
|
$stderr.puts "[menu] activate_current item=#{item.label.inspect} result=#{result.inspect}"
|
|
@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
|