Files
rubbs/lib/bbs/window.rb
Zsolt Tasnadi 6b320318ca Render GFM tables and enable mouse-wheel scrolling
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>
2026-06-05 19:20:39 +02:00

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