Markdown.to_lines now detects GFM tables (header + delimiter row) and renders them as box-drawn, alignment-aware tables that shrink to fit the pane width, instead of mangling the rows into a single paragraph. ScrollView now handles mouse-wheel events, and Window routes wheel events to the widget under the cursor, so a detail/content pane scrolls even when keyboard focus is on an adjacent list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
3.1 KiB
Ruby
103 lines
3.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative 'container'
|
|
require_relative 'focus_manager'
|
|
|
|
module BBS
|
|
# A Window is a top-level Container with its own border, title, and focus
|
|
# manager. The Application stacks windows; the top window is the modal /
|
|
# active one and receives input first.
|
|
class Window < Container
|
|
attr_accessor :title, :modal, :shadow, :on_close, :close_key
|
|
|
|
def initialize(title: nil, modal: false, shadow: true, on_close: nil,
|
|
close_key: :escape, **kw)
|
|
super(**kw)
|
|
@title = title
|
|
@modal = modal
|
|
@shadow = shadow
|
|
@on_close = on_close
|
|
@close_key = close_key
|
|
@focus_mgr = nil
|
|
end
|
|
|
|
def focus_manager
|
|
@focus_mgr ||= FocusManager.new(self)
|
|
end
|
|
|
|
def reset_focus
|
|
@focus_mgr = FocusManager.new(self)
|
|
end
|
|
|
|
def render(frame)
|
|
if @shadow
|
|
frame.shadow(x: bounds.x, y: bounds.y, width: bounds.width,
|
|
height: bounds.height, sgr: style(:window_shadow))
|
|
end
|
|
show_close = @on_close != false && bounds.width >= 6
|
|
frame.box(
|
|
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
|
|
sgr: style(:window_border), fill_sgr: style(:window_bg),
|
|
style: :double, title: @title,
|
|
title_sgr: focused ? style(:window_title_focused) : style(:window_title),
|
|
title_indent: show_close ? 4 : 0
|
|
)
|
|
# Close gadget — Turbo Vision style "[■]"
|
|
if show_close
|
|
frame.move(bounds.x + 2, bounds.y)
|
|
frame.write('[■]', sgr: style(:window_title))
|
|
end
|
|
@children.each { |c| c.render(frame) if c.visible }
|
|
end
|
|
|
|
def hit_test(x, y)
|
|
# close gadget area
|
|
if @on_close != false && y == bounds.y &&
|
|
x >= bounds.x + 2 && x <= bounds.x + 4
|
|
return self
|
|
end
|
|
super
|
|
end
|
|
|
|
def handle_event(event)
|
|
if event.mouse? && event.mouse.kind == :press && event.mouse.button == :left
|
|
if event.y == bounds.y && event.x >= bounds.x + 2 && event.x <= bounds.x + 4
|
|
return close
|
|
end
|
|
if event.mouse.button == :left
|
|
hit = hit_test(event.x, event.y)
|
|
focus_manager.focus(hit) if hit&.focusable?
|
|
end
|
|
end
|
|
|
|
# Route wheel scrolling to whatever widget is under the cursor, so a
|
|
# scrollable pane responds even when keyboard focus is elsewhere.
|
|
if event.mouse? && event.mouse.kind == :wheel
|
|
hit = hit_test(event.x, event.y)
|
|
if hit && hit != focus_manager.focused
|
|
res = hit.handle_event(event)
|
|
return res if res
|
|
end
|
|
end
|
|
|
|
result = focus_manager.handle_focus_key(event) ? :handled : nil
|
|
result ||= focus_manager.dispatch(event)
|
|
return result if result
|
|
|
|
if event.key? && event.key == @close_key
|
|
return close
|
|
end
|
|
nil
|
|
end
|
|
|
|
def close
|
|
@on_close&.call(self)
|
|
:close_window
|
|
end
|
|
|
|
# Called once per Application idle tick (~1s). Override in subclasses that
|
|
# need to poll for asynchronous updates (e.g. BBS::Windows::Stream).
|
|
def tick(app); end
|
|
end
|
|
end
|