From 18e77c14b5e0d48599bc18a1f55cf9c72b64fe47 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 13 May 2026 22:23:37 +0200 Subject: [PATCH] new window dsl --- lib/bbs/application.rb | 46 +++++++++-- lib/bbs/window_spec.rb | 184 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 lib/bbs/window_spec.rb diff --git a/lib/bbs/application.rb b/lib/bbs/application.rb index 1ef0f61..dfd7ce3 100644 --- a/lib/bbs/application.rb +++ b/lib/bbs/application.rb @@ -10,6 +10,7 @@ require_relative 'widgets' require_relative 'menu' require_relative 'statusbar' require_relative 'dialogs' +require_relative 'window_spec' module BBS # The widget event loop. Acts as the root container: @@ -23,7 +24,7 @@ module BBS # and use add_window / menubar / statusbar. class Application attr_reader :session, :session_id, :context, :root, :menubar, :statusbar, - :windows, :theme + :windows, :theme, :services, :window_specs def self.define(&block) builder = Builder.new @@ -32,7 +33,7 @@ module BBS end 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_id = session_id @context = context @@ -47,6 +48,8 @@ module BBS @on_idle = on_idle @on_tick = on_tick @helpers = helpers + @services = services + @window_specs = window_specs extend(@helpers) if @helpers end @@ -69,11 +72,20 @@ module BBS child end - def open_window(window) - window.theme = @theme - @windows << window - window.focused = true - window + # Overloaded: pass a Symbol/String to open a registered WindowSpec (with + # optional extras forwarded into the callback ctx), or pass a Window + # instance to push it directly onto the modal stack. + def open_window(target, **extras) + 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 def close_window(window = nil) @@ -242,7 +254,7 @@ module BBS # ── Builder DSL ─────────────────────────────────────────────────────────── class Builder attr_reader :init_block, :theme, :menubar_block, :statusbar_hints, - :helper_module + :helper_module, :window_specs def initialize @init_block = nil @@ -251,6 +263,20 @@ module BBS @statusbar_hints = [] @body_block = nil @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 def theme(base = nil, &block) @@ -285,7 +311,9 @@ module BBS app = Application.new( session: session, session_id: session_id, context: context, theme: @theme || Theme.synchronet, - helpers: @helper_module + helpers: @helper_module, + services: @services, + window_specs: @window_specs ) if @menubar_block menubar_widget = Menubar.new diff --git a/lib/bbs/window_spec.rb b/lib/bbs/window_spec.rb new file mode 100644 index 0000000..6bf091c --- /dev/null +++ b/lib/bbs/window_spec.rb @@ -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