Files
rubbs/lib/bbs/windows/master_detail.rb
Zsolt Tasnadi db8a97b365 Relayout open windows on terminal resize
NAWS mid-session updates now propagate to the window stack: Application
re-runs each open window's relayout on resize. Window#relayout re-centers
(clamped to the screen edge) by shifting all descendants; MasterDetail
recomputes its full geometry and re-renders the detail at the new width.
ButtonBar placement is extracted into ButtonBar.place for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:11:27 +02:00

150 lines
4.8 KiB
Ruby

# frozen_string_literal: true
require_relative '../window'
require_relative '../widgets'
require_relative 'button_bar'
module BBS
module Windows
# A two-pane browser: left ListBox of items, right ScrollView of details.
# Selecting an item in the list reloads the detail pane.
#
# BBS::Windows::MasterDetail.open(app,
# title: ' Game Catalog ',
# items: services[:games].all,
# item_label: ->(g) { g.title },
# list_width: 30,
# render_detail: ->(game, width) { GameCard.lines(game, width) },
# actions: [
# { label: '&New', on_click: ->(win) { ... } }
# ],
# bottom_meta: ->(item) { "...optional 1-line meta..." }
# )
#
# `render_detail` is called with `(item, width)` whenever the selection
# changes and on first paint. It must return an Array of strings (each one
# is one rendered line; ANSI is supported). Returning nil shows nothing.
#
# `bottom_meta` is optional; if given, it's a `->(item) { text }` lambda
# for a single info line below the detail pane.
#
# `actions:` are extra buttons (right-aligned by default). A `Close` button
# is always appended on the right unless the caller suppresses it via
# `close_button: false`.
class MasterDetail < Window
def self.open(app, **kw)
new(app: app, **kw).tap(&:attach)
end
attr_reader :list_widget, :detail_view, :meta_label
def initialize(app:, title:, items:, item_label:, render_detail:,
list_width: nil, bottom_meta: nil, actions: nil,
close_button: true, close_label: '&Close',
empty_message: ' (empty)',
width: nil, height: nil)
super(title: title, modal: true)
self.theme = app.theme
@app = app
@items = items
@item_label = item_label
@render_detail = render_detail
@bottom_meta = bottom_meta
@actions = (actions || []).dup
@actions << { label: close_label, cancel: true } if close_button
@list_width = list_width
@empty_message = empty_message
@width = width
@height = height
build
end
def attach
@app.open_window(self)
reset_focus
focus_manager.focus(@list_widget) if @list_widget
reload_detail
self
end
def reload_detail
item = @list_widget&.selected_item
if item.nil?
@detail_view.lines = [@empty_message]
@detail_view.scroll = 0
@meta_label.text = '' if @meta_label
else
lines = @render_detail.call(item, @detail_view.bounds.width - 1)
@detail_view.lines = Array(lines)
@detail_view.scroll = 0
@meta_label.text = @bottom_meta.call(item) if @meta_label && @bottom_meta
end
end
# Terminal resized: recompute the whole geometry against the new size
# and re-render the detail at the new pane width.
def relayout(_app = @app)
place_window
place_panes
ButtonBar.place(self, @bar[:widgets], @actions)
reload_detail
end
private
def build
place_window
@list_widget = BBS::Widgets::ListBox.new(
items: @items,
label: ->(item) { " #{@item_label.call(item)}".ljust(@list_w || 0) }
)
add(@list_widget)
@detail_view = BBS::Widgets::ScrollView.new(lines: [])
add(@detail_view)
unless @bottom_meta.nil?
@meta_label = BBS::Widgets::Label.new(text: '', style_key: :input_label)
add(@meta_label)
end
@list_widget.on_select = ->(_, _) { reload_detail }
@bar = ButtonBar.attach(self, @app, @actions)
place_panes
end
def place_window
w = @width || @app.cols - 4
h = @height || @app.rows - 6
x = (@app.cols - w) / 2 + 1
y = (@app.rows - h) / 2 + 1
layout(x, y, w, h)
end
def place_panes
h = bounds.height
has_buttons = !@actions.empty?
has_meta = !@meta_label.nil?
panel_h = h - 4 - (has_meta ? 1 : 0) - (has_buttons ? 1 : 0)
panel_h = [panel_h, 3].max
@list_w = @list_width || [(bounds.width / 3).clamp(20, 40), bounds.width - 10].min
list_x = bounds.x + 2
list_y = bounds.y + 2
@list_widget.layout(list_x, list_y, @list_w, panel_h)
detail_x = list_x + @list_w + 2
detail_w = bounds.width - @list_w - 5
@detail_view.layout(detail_x, list_y, detail_w, panel_h)
if @meta_label
meta_y = bounds.y + h - (has_buttons ? 3 : 2)
@meta_label.layout(bounds.x + 2, meta_y, bounds.width - 4, 1)
end
end
end
end
end