Files
rubbs/lib/bbs/windows/form.rb
2026-05-13 20:35:46 +02:00

217 lines
7.0 KiB
Ruby

# frozen_string_literal: true
require_relative '../window'
require_relative '../widgets'
require_relative 'button_bar'
module BBS
module Windows
# A form dialog with labeled input rows, optional textarea, and a status
# line for inline validation feedback.
#
# BBS::Windows::Form.open(app,
# title: " Profile — #{u} ", width: 64,
# fields: [
# { key: :signature, label: 'Signature', value: '...' },
# { key: :location, label: 'Location', value: '...' },
# { key: :notes, label: 'Notes', value: '...' },
# ],
# on_submit: ->(values, form) {
# service.update(values); :close
# }
# )
#
# Field descriptor:
# { key:, label:, value:, type:, max_length:, placeholder:, password:, rows: }
#
# Field types:
# :input (default) — single-line TextInput; label on left.
# :textarea — multi-line TextArea; full width, label ignored.
# :checkbox — Checkbox; label is part of the widget.
#
# on_submit receives `(values_hash, self)`. Return :keep to keep the
# window open (e.g., after `form.error('...')`); any other value closes.
#
# `hint:` shows an info line above the fields (no label/value, no input).
class Form < Window
DEFAULT_PADDING = { sides: 2, top: 1, bottom: 2 }.freeze
def self.open(app, **kw)
new(app: app, **kw).tap(&:attach)
end
attr_reader :status_label
def initialize(app:, title:, fields:, on_submit:,
hint: nil, width: 60, height: nil,
submit_label: '&Save', cancel_label: '&Cancel',
padding: nil, on_cancel: nil)
super(title: title, modal: true)
self.theme = app.theme
@app = app
@fields = fields
@hint = hint
@on_submit = on_submit
@on_cancel = on_cancel
@submit_label = submit_label
@cancel_label = cancel_label
@width = width
@height = height
@padding = DEFAULT_PADDING.merge(padding || {})
@widgets = {}
build
end
def attach
@app.open_window(self)
reset_focus
first = @fields.first
focus_manager.focus(@widgets[first[:key]]) if first && @widgets[first[:key]]
self
end
def values
@fields.each_with_object({}) { |f, h| h[f[:key]] = field_value(f) }
end
def error(text)
return unless @status_label
@status_label.text = text.to_s
@status_label.style_key = :error
end
def info(text)
return unless @status_label
@status_label.text = text.to_s
@status_label.style_key = :input_label
end
def clear_status
@status_label&.text = ''
end
private
def field_value(field)
widget = @widgets[field[:key]]
return nil unless widget
return widget.checked if field[:type] == :checkbox
widget.value
end
def build
w = [@width, @app.cols - 4].min
heights = @fields.map { |f| natural_field_height(f) }
hint_block = @hint ? 2 : 0 # hint + spacer
spacing = [@fields.length - 1, 0].max
status_block = 1
buttons_block = 2 # spacer + button row
h_auto = @padding[:top] + hint_block + heights.sum + spacing +
1 + status_block + buttons_block + @padding[:bottom]
h = @height || h_auto
h = [h, @app.rows - 4].min
# If explicit height + exactly one textarea without :rows, expand it.
if @height
ta_indices = @fields.each_index.select do |i|
@fields[i][:type] == :textarea && !@fields[i][:rows]
end
if ta_indices.length == 1
idx = ta_indices.first
fixed_rest = heights.each_with_index.reject { |_, i| i == idx }.sum { |row, _| row }
available = h - @padding[:top] - @padding[:bottom] - hint_block -
spacing - 1 - status_block - buttons_block
heights[idx] = [available - fixed_rest, 1].max
end
end
x = (@app.cols - w) / 2 + 1
y = (@app.rows - h) / 2 + 1
layout(x, y, w, h)
inner_x = bounds.x + @padding[:sides]
inner_w = w - @padding[:sides] * 2
row_y = bounds.y + @padding[:top]
if @hint
hint = BBS::Widgets::Label.new(text: @hint, style_key: :input_label)
hint.layout(inner_x, row_y, inner_w, 1)
add(hint)
row_y += 2
end
label_w = compute_label_width
@fields.each_with_index do |f, i|
place_field(f, heights[i], inner_x, inner_w, row_y, label_w)
row_y += heights[i] + 1
end
@status_label = BBS::Widgets::Label.new(text: '', style_key: :error)
@status_label.layout(inner_x, bounds.y + h - 3, inner_w, 1)
add(@status_label)
ButtonBar.attach(self, @app, [
{ key: :submit, label: @submit_label, primary: true,
on_click: ->(_w) { @on_submit ? @on_submit.call(values, self) : :close } },
{ key: :cancel, label: @cancel_label, cancel: true,
on_click: ->(_w) { @on_cancel&.call(self) } }
])
end
def natural_field_height(field)
case field[:type]
when :textarea then field[:rows] || 6
else 1
end
end
def compute_label_width
with_labels = @fields.select { |f| f[:label] && f[:type] != :textarea && f[:type] != :checkbox }
return 0 if with_labels.empty?
max = with_labels.map { |f| f[:label].to_s.length }.max
[max + 1, 16].min.clamp(8, 16)
end
def place_field(field, height, inner_x, inner_w, y, label_w)
case field[:type]
when :textarea
widget = BBS::Widgets::TextArea.new(
value: field[:value].to_s,
max_length: field[:max_length] || 8_000
)
widget.layout(inner_x, y, inner_w, height)
when :checkbox
widget = BBS::Widgets::Checkbox.new(
label: field[:label].to_s,
checked: field[:value] ? true : false
)
widget.layout(inner_x, y, inner_w, 1)
else
if field[:label]
label = BBS::Widgets::Label.new(text: field[:label].to_s, style_key: :input_label)
label.layout(inner_x, y, label_w, 1)
add(label)
input_x = inner_x + label_w + 2
input_w = inner_w - label_w - 2
else
input_x = inner_x
input_w = inner_w
end
widget = BBS::Widgets::TextInput.new(
value: field[:value].to_s,
placeholder: field[:placeholder].to_s,
max_length: field[:max_length] || 256,
password: field[:password] ? true : false
)
widget.layout(input_x, y, input_w, 1)
end
add(widget)
@widgets[field[:key]] = widget
end
end
end
end