new window dsl

This commit is contained in:
2026-05-13 22:23:37 +02:00
parent 5bf754f38c
commit 18e77c14b5
2 changed files with 221 additions and 9 deletions

View File

@@ -10,6 +10,7 @@ require_relative 'widgets'
require_relative 'menu' require_relative 'menu'
require_relative 'statusbar' require_relative 'statusbar'
require_relative 'dialogs' require_relative 'dialogs'
require_relative 'window_spec'
module BBS module BBS
# The widget event loop. Acts as the root container: # The widget event loop. Acts as the root container:
@@ -23,7 +24,7 @@ module BBS
# and use add_window / menubar / statusbar. # and use add_window / menubar / statusbar.
class Application class Application
attr_reader :session, :session_id, :context, :root, :menubar, :statusbar, attr_reader :session, :session_id, :context, :root, :menubar, :statusbar,
:windows, :theme :windows, :theme, :services, :window_specs
def self.define(&block) def self.define(&block)
builder = Builder.new builder = Builder.new
@@ -32,7 +33,7 @@ module BBS
end end
def initialize(session:, session_id:, context: {}, theme: nil, on_idle: nil, def initialize(session:, session_id:, context: {}, theme: nil, on_idle: nil,
on_tick: nil, helpers: nil) on_tick: nil, helpers: nil, services: {}, window_specs: {})
@session = session @session = session
@session_id = session_id @session_id = session_id
@context = context @context = context
@@ -47,6 +48,8 @@ module BBS
@on_idle = on_idle @on_idle = on_idle
@on_tick = on_tick @on_tick = on_tick
@helpers = helpers @helpers = helpers
@services = services
@window_specs = window_specs
extend(@helpers) if @helpers extend(@helpers) if @helpers
end end
@@ -69,11 +72,20 @@ module BBS
child child
end end
def open_window(window) # Overloaded: pass a Symbol/String to open a registered WindowSpec (with
window.theme = @theme # optional extras forwarded into the callback ctx), or pass a Window
@windows << window # instance to push it directly onto the modal stack.
window.focused = true def open_window(target, **extras)
window if target.is_a?(Symbol) || target.is_a?(String)
spec = @window_specs[target.to_sym]
raise ArgumentError, "Unknown window: #{target.inspect}" unless spec
spec.open(self, **extras)
else
target.theme = @theme
@windows << target
target.focused = true
target
end
end end
def close_window(window = nil) def close_window(window = nil)
@@ -242,7 +254,7 @@ module BBS
# ── Builder DSL ─────────────────────────────────────────────────────────── # ── Builder DSL ───────────────────────────────────────────────────────────
class Builder class Builder
attr_reader :init_block, :theme, :menubar_block, :statusbar_hints, attr_reader :init_block, :theme, :menubar_block, :statusbar_hints,
:helper_module :helper_module, :window_specs
def initialize def initialize
@init_block = nil @init_block = nil
@@ -251,6 +263,20 @@ module BBS
@statusbar_hints = [] @statusbar_hints = []
@body_block = nil @body_block = nil
@helper_module = Module.new @helper_module = Module.new
@services = {}
@window_specs = {}
end
def services(hash = nil)
return @services if hash.nil?
@services = @services.merge(hash)
end
def window(name, type:, &block)
spec = WindowSpec.new(name, type: type)
block&.call(spec)
@window_specs[name] = spec
spec
end end
def theme(base = nil, &block) def theme(base = nil, &block)
@@ -285,7 +311,9 @@ module BBS
app = Application.new( app = Application.new(
session: session, session_id: session_id, context: context, session: session, session_id: session_id, context: context,
theme: @theme || Theme.synchronet, theme: @theme || Theme.synchronet,
helpers: @helper_module helpers: @helper_module,
services: @services,
window_specs: @window_specs
) )
if @menubar_block if @menubar_block
menubar_widget = Menubar.new menubar_widget = Menubar.new

184
lib/bbs/window_spec.rb Normal file
View File

@@ -0,0 +1,184 @@
# frozen_string_literal: true
require_relative 'windows/info'
require_relative 'windows/form'
require_relative 'windows/master_detail'
require_relative 'windows/stream'
module BBS
# Declarative window definition used inside Application.define:
#
# window :compose, type: :form do |w|
# w.title { |ctx| " Post to #{ctx[:board].title} " }
# w.width 70
# w.field :body, type: :textarea, max_length: 1000
# w.on_submit do |values, form, ctx|
# case ctx[:boards].post(ctx[:board].id, ctx[:username], values[:body])
# when :empty then form.error('Message cannot be empty.'); :keep
# else :close
# end
# end
# end
#
# Open with `app.open_window(:compose, board: some_board)`. Block callbacks
# receive a `ctx` hash combining the app's services, app.context, and any
# extras passed to open_window.
class WindowSpec
SCALAR_ATTRS = %i[
title width height padding hint placeholder list_width buffer_size
scroll close_button close_label empty_message submit_label cancel_label
].freeze
BLOCK_ATTRS = %i[
body items initial_lines item_label render_detail bottom_meta
on_submit on_cancel on_poll prefill
].freeze
attr_reader :name, :type
def initialize(name, type:)
@name = name
@type = type
@attrs = {}
@fields = []
@actions = []
@buttons = nil
end
SCALAR_ATTRS.each do |key|
define_method(key) { |value = nil, &block| @attrs[key] = block || value }
alias_method "#{key}=", key
end
BLOCK_ATTRS.each do |key|
define_method(key) { |value = nil, &block| @attrs[key] = block || value }
end
def field(key, **opts)
@fields << opts.merge(key: key)
end
def action(**opts, &block)
opts = opts.merge(on_click: block) if block
@actions << opts
end
def button(**opts, &block)
@buttons ||= []
opts = opts.merge(on_click: block) if block
@buttons << opts
end
# Resolves attrs + wraps callbacks with the runtime `ctx`, then opens the
# underlying rubbs window. `extras` flow into `ctx` (e.g., `board:`).
def open(app, **extras)
ctx = build_ctx(app, extras)
send("open_#{@type}", app, ctx)
end
private
def build_ctx(app, extras)
services = app.respond_to?(:services) ? (app.services || {}) : {}
context = app.respond_to?(:context) ? (app.context || {}) : {}
{ app: app, **context, **services, **extras }
end
def resolve(key, ctx, *args)
val = @attrs[key]
val.is_a?(Proc) ? val.call(ctx, *args) : val
end
def open_info(app, ctx)
kw = {
title: resolve(:title, ctx),
body: resolve(:body, ctx),
width: resolve(:width, ctx),
height: resolve(:height, ctx),
scroll: resolve(:scroll, ctx),
padding: resolve(:padding, ctx)
}.compact
kw[:buttons] = build_buttons(ctx) if @buttons
BBS::Windows::Info.open(app, **kw)
end
def open_form(app, ctx)
on_submit = @attrs[:on_submit]
on_cancel = @attrs[:on_cancel]
kw = {
title: resolve(:title, ctx),
fields: resolve_fields(ctx),
width: resolve(:width, ctx),
height: resolve(:height, ctx),
hint: resolve(:hint, ctx),
submit_label: resolve(:submit_label, ctx),
cancel_label: resolve(:cancel_label, ctx)
}.compact
kw[:on_submit] = ->(values, form) { on_submit.call(values, form, ctx) } if on_submit
kw[:on_cancel] = ->(form) { on_cancel.call(form, ctx) } if on_cancel
BBS::Windows::Form.open(app, **kw)
end
def open_master_detail(app, ctx)
label_cb = @attrs[:item_label]
render_cb = @attrs[:render_detail]
meta_cb = @attrs[:bottom_meta]
kw = {
title: resolve(:title, ctx),
items: resolve(:items, ctx) || [],
item_label: label_cb ? ->(item) { label_cb.call(item, ctx) } : ->(x) { x.to_s },
render_detail: render_cb ? ->(item, width) { render_cb.call(item, width, ctx) } : ->(_i, _w) { [] },
list_width: resolve(:list_width, ctx),
empty_message: resolve(:empty_message, ctx),
close_button: resolve(:close_button, ctx),
close_label: resolve(:close_label, ctx),
width: resolve(:width, ctx),
height: resolve(:height, ctx)
}.compact
kw[:bottom_meta] = ->(item) { meta_cb.call(item, ctx) } if meta_cb
kw[:actions] = build_actions(ctx) unless @actions.empty?
BBS::Windows::MasterDetail.open(app, **kw)
end
def open_stream(app, ctx)
on_submit = @attrs[:on_submit]
on_poll = @attrs[:on_poll]
kw = {
title: resolve(:title, ctx),
initial_lines: resolve(:initial_lines, ctx) || [],
placeholder: resolve(:placeholder, ctx),
buffer_size: resolve(:buffer_size, ctx),
width: resolve(:width, ctx),
height: resolve(:height, ctx),
close_button: resolve(:close_button, ctx),
close_label: resolve(:close_label, ctx)
}.compact
kw[:on_submit] = ->(text, app_) { on_submit.call(text, app_, ctx) } if on_submit
kw[:on_poll] = ->(app_) { on_poll.call(app_, ctx) } if on_poll
BBS::Windows::Stream.open(app, **kw)
end
def resolve_fields(ctx)
prefilled = if @attrs[:prefill]
Hash(@attrs[:prefill].call(ctx)).transform_keys(&:to_sym)
else
{}
end
@fields.map do |f|
value = f[:value]
value = value.call(ctx) if value.is_a?(Proc)
value = prefilled[f[:key]] if value.nil? && prefilled.key?(f[:key])
f.merge(value: value)
end
end
def build_buttons(ctx) = @buttons.map { |b| wrap_clickable(b, ctx) }
def build_actions(ctx) = @actions.map { |a| wrap_clickable(a, ctx) }
def wrap_clickable(opts, ctx)
return opts unless opts[:on_click].is_a?(Proc)
cb = opts[:on_click]
opts.merge(on_click: ->(win) { cb.call(win, ctx) })
end
end
end