tuile 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
@@ -14,7 +14,7 @@ module Tuile
14
14
  # combo.value = some_user # selects it; field shows its label
15
15
  #
16
16
  # It's the assembly you'd otherwise wire by hand — a {TextField} plus a
17
- # non-modal {Popup} over a {List} — promoted to one component. Give it a
17
+ # an {Overlay} over a {List} — promoted to one component. Give it a
18
18
  # single-row {#rect}; it paints the field across that row with a `▾` in the
19
19
  # last column and floats the dropdown above or below.
20
20
  #
@@ -52,6 +52,9 @@ module Tuile
52
52
  self.content = field
53
53
 
54
54
  @overlay = ListDropdown.new
55
+ # Outside-click dismissal spans the owner chain, so a click on this
56
+ # combo's dropdown must not dismiss a dialog the combo sits in.
57
+ @overlay.owner = self
55
58
  @overlay.renderer = ->(item) { @item_label.call(item) }
56
59
  @overlay.on_item_chosen = ->(_index, item) { commit(item) }
57
60
  end
@@ -98,7 +101,6 @@ module Tuile
98
101
  def cursor_position = content.cursor_position
99
102
 
100
103
  # @return [String]
101
- def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
102
104
 
103
105
  # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
104
106
  # field via {#layout}.
@@ -145,6 +147,13 @@ module Tuile
145
147
  draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well))
146
148
  end
147
149
 
150
+ # The one row this combo paints — the full width, at the top of {#rect}.
151
+ # A single-slot container hands its content the whole inner rect, so a
152
+ # ComboBox is routinely assigned more height than it uses; the dropdown
153
+ # hangs under this rather than under the unused space below it.
154
+ # @return [Size]
155
+ def extent = Size.new(rect.width, 1)
156
+
148
157
  protected
149
158
 
150
159
  # Field spans the row bar the last column, which the `▾` occupies
@@ -260,7 +269,7 @@ module Tuile
260
269
  # labels, which ellipsize a column earlier once the list scrolls. That is
261
270
  # the trade a measuring driver ({Select}) makes the other way.
262
271
  # @return [void]
263
- def anchor = @overlay.anchor_to(rect, rows: @filtered.size)
272
+ def anchor = @overlay.anchor_to(extent_rect, rows: @filtered.size)
264
273
  end
265
274
  end
266
275
  end
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # The confirm dialog: a {Window} asking a question — a caption, a prose
6
+ # {#message}, a centered row of buttons — plus the one-button degenerate
7
+ # case, the alert. Three factories cover the common shapes and open the
8
+ # dialog as a centered, content-sized {Popup}:
9
+ #
10
+ # Component::ConfirmWindow.alert("Export failed", "Contact support@example.com.")
11
+ # Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
12
+ # confirm: "Delete") { delete! }
13
+ # Component::ConfirmWindow.yes_no("Overwrite file?", "target.txt already exists.") { overwrite! }
14
+ #
15
+ # The component itself is the builder — declare any button set through
16
+ # {#button}, then {#open}:
17
+ #
18
+ # dialog = Component::ConfirmWindow.new("Unsaved changes")
19
+ # dialog.message = "Save your changes before leaving?"
20
+ # dialog.button("Save") { save! }
21
+ # dialog.button("Discard") { discard! }
22
+ # dialog.button("Cancel") # no action: pressing it dismisses
23
+ # dialog.on_dismiss = -> { stay_put }
24
+ # dialog.open
25
+ #
26
+ # **Every button closes the dialog.** A button with a block then fires it; a
27
+ # button without one is a Cancel. ESC, `q`, an outside click and a Cancel
28
+ # button are all one outcome — {#on_dismiss}, fired exactly once, and only
29
+ # when no action button was chosen. There is deliberately no keep-open knob:
30
+ # a dialog that leads somewhere opens the next window from its callback.
31
+ #
32
+ # Keys: Left/Right (and Tab) move between the buttons, Enter/Space press the
33
+ # focused one, and each button answers to its underlined mnemonic letter
34
+ # (see {#button}). The message scrolls without taking focus —
35
+ # {BODY_SCROLL_KEYS} are handed to it from anywhere in the dialog. Focus
36
+ # opens on the first button; Shift+Tab reaches the message, a tab stop of
37
+ # its own.
38
+ #
39
+ # The popup sizes itself from what the dialog owns — caption, message,
40
+ # buttons — capped at half the screen ({#measured_size}), re-measured when
41
+ # any of them changes. Usable tiled too (add it to a {Layout}): buttons then
42
+ # fire their callbacks with nothing to close.
43
+ #
44
+ # There is deliberately no content slot: the body is prose ({#message=}
45
+ # takes a component for the rare rich body, but the dialog then cannot
46
+ # measure it). A dialog collecting *input* is not a confirm dialog — build a
47
+ # `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
48
+ # for the API rationale.
49
+ class ConfirmWindow < Window
50
+ # Keys handed to the message body from anywhere in the dialog, so it
51
+ # scrolls while a button keeps focus. The printables among them (`g`/`G`,
52
+ # the less/vi top/bottom idiom) are {RESERVED_MNEMONICS} in exchange.
53
+ #
54
+ # Deliberately *not* `Keys::UP_ARROWS`/`DOWN_ARROWS`: those include the vi
55
+ # aliases `j`/`k`, which stay available as mnemonics ("Keep") — the body
56
+ # still honors them when focused itself.
57
+ # @return [Array<String>]
58
+ BODY_SCROLL_KEYS = ([Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
59
+ Keys::CTRL_U, Keys::CTRL_D, "g", "G"] + Keys::HOMES + Keys::ENDS_).freeze
60
+
61
+ # Letters {#button} refuses as a mnemonic, compared downcased: `q` is
62
+ # unconditionally the dismiss key (a {Popup} claims it below this window),
63
+ # and `g`/`G` scroll the message ({BODY_SCROLL_KEYS}).
64
+ # @return [Array<String>]
65
+ RESERVED_MNEMONICS = %w[q g].freeze
66
+
67
+ # Border rows plus the body-to-buttons spacing plus the button row — what
68
+ # {#measured_size} adds to the wrapped message rows.
69
+ # @return [Integer]
70
+ HEIGHT_CHROME = 4
71
+ # Border columns plus the body padding — what {#measured_size} adds to the
72
+ # widest message line, and subtracts to find the wrap width.
73
+ # @return [Integer]
74
+ WIDTH_CHROME = 4
75
+ private_constant :HEIGHT_CHROME, :WIDTH_CHROME
76
+
77
+ # @param caption [String, StyledString, nil] the border title, coerced the
78
+ # same way {HasCaption#caption=} coerces it.
79
+ def initialize(caption = nil)
80
+ @popup = nil
81
+ @chosen = false
82
+ @message = nil
83
+ @on_dismiss = nil
84
+ # Insertion-ordered; identity-keyed so equal captions stay two buttons.
85
+ @actions = {}.compare_by_identity
86
+ @mnemonics = {}
87
+ super(caption)
88
+ @body_slot = Slot.new
89
+ @button_row = Layout::Horizontal.new(spacing: 2)
90
+ @box = Layout::Vertical.new(spacing: 1, padding: Layout::Insets[left: 1, right: 1])
91
+ @box.add(@body_slot, Layout::Expand[1])
92
+ @box.add(@button_row, Layout::Fixed[1], cross: Layout::Fixed[0], align: :center)
93
+ self.content = @box
94
+ end
95
+
96
+ # Callback taking no arguments, fired when the dialog is dismissed — ESC,
97
+ # `q`, an outside click, or a {#button} declared without a block. Fires
98
+ # exactly once per {#open}, and never when an action button was chosen.
99
+ # @return [Proc, nil]
100
+ attr_accessor :on_dismiss
101
+
102
+ # @return [String, StyledString, Component, nil] whatever {#message=} was
103
+ # given — set a `String`, read that `String` back. The component
104
+ # rendering it is derived, never returned.
105
+ attr_reader :message
106
+
107
+ # Also re-measures the popup: the caption participates in the width.
108
+ # @param new_caption [String, StyledString, nil]
109
+ # @return [void]
110
+ def caption=(new_caption)
111
+ super
112
+ resize_popup
113
+ end
114
+
115
+ # Sets the dialog body. Text (`String` / {StyledString}) is rendered by a
116
+ # word-wrapping, scrollable {TextView} the dialog owns and measures; a
117
+ # {Component} is mounted as-is, and the dialog — which may measure only
118
+ # content it owns — then takes the full half-screen box. `nil` clears.
119
+ # @param value [String, StyledString, Component, nil]
120
+ # @raise [TypeError] on any other type.
121
+ # @return [void]
122
+ def message=(value)
123
+ occupant =
124
+ case value
125
+ when nil then nil
126
+ when Component then value
127
+ when String, StyledString then TextView.new.tap { _1.text = value }
128
+ else raise TypeError, "expected String, StyledString, Component or nil, got #{value.inspect}"
129
+ end
130
+ @message = value
131
+ @body_slot.content = occupant
132
+ resize_popup
133
+ end
134
+
135
+ # Appends a button and returns it. A button with a block is an action
136
+ # button: pressing it closes the dialog, then fires the block. A button
137
+ # without one is a Cancel: pressing it closes the dialog, then fires
138
+ # {#on_dismiss}.
139
+ #
140
+ # dialog.button("Delete") { delete! } # mnemonic d, underlined
141
+ # dialog.button("Keep", mnemonic: "e") # explicit letter
142
+ # dialog.button("Cancel", mnemonic: nil) # no mnemonic
143
+ #
144
+ # The mnemonic — a printable letter activating the button from anywhere in
145
+ # the dialog, case-insensitively — is underlined in the caption. Any case
146
+ # is accepted, and the underline prefers the exact case given, so the case
147
+ # picks which occurrence is cued (`"Save As"` with `"A"` underlines the
148
+ # *As*), exactly as on {MenuBar#add_item}. The default `:auto` derives the
149
+ # caption's first letter (cueing it as displayed) and is best-effort:
150
+ # silently skipped when that letter is reserved, taken, or unusable. An
151
+ # explicit letter is a promise and raises when it cannot be kept.
152
+ #
153
+ # Declare buttons before {#open}: adding one to an open dialog re-measures
154
+ # the popup, but momentarily bounces focus off the button row.
155
+ # @param caption [String, StyledString] the button label.
156
+ # @param mnemonic [Symbol, String, nil] `:auto` (default), a printable
157
+ # one-column character, or `nil` for none.
158
+ # @yield optional action, fired after the dialog closes.
159
+ # @raise [ArgumentError] on an explicit mnemonic that is reserved
160
+ # ({RESERVED_MNEMONICS}), a space, already taken, or not a one-column
161
+ # printable.
162
+ # @return [Button] the appended button.
163
+ def button(caption, mnemonic: :auto, &action)
164
+ styled = StyledString.parse(caption)
165
+ letter = resolve_mnemonic(styled, mnemonic)
166
+ # The cue keeps the case the caller (or the caption) wrote, so `:auto`
167
+ # underlines "Discard"'s leading D rather than its trailing d, and an
168
+ # explicit letter picks its occurrence by case as on MenuBar; `letter`
169
+ # is the downcased matching key.
170
+ cue = mnemonic == :auto ? styled.to_s.grapheme_clusters.first : mnemonic
171
+ btn = Button.new(letter ? underline_mnemonic(styled, cue) : styled)
172
+ btn.on_click = -> { activate(btn) }
173
+ @actions[btn] = action
174
+ @mnemonics[letter] = btn unless letter.nil?
175
+ @button_row.add(btn, Layout::Fixed[btn.caption.display_width + 4])
176
+ recenter_button_row
177
+ resize_popup
178
+ btn
179
+ end
180
+
181
+ # Opens the dialog as a centered modal {Popup} sized by {#measured_size}
182
+ # and returns that popup. ESC, `q` and an outside click dismiss it
183
+ # (firing {#on_dismiss}); every button closes it too.
184
+ # @raise [Tuile::Error] if this dialog is already open.
185
+ # @return [Popup] the mounted popup; `popup.close` closes programmatically,
186
+ # as {#close} also does.
187
+ def open
188
+ raise Tuile::Error, "#{inspect} is already open" if @popup&.open?
189
+
190
+ # A previous popup still holds this window as its content; reclaim it.
191
+ @popup&.content = nil
192
+ @chosen = false
193
+ @popup = MeasuredPopup.new(self)
194
+ @popup.on_close = -> { notice_dismissed }
195
+ @popup.open
196
+ end
197
+
198
+ # Closes the popup {#open} mounted, which counts as a dismissal
199
+ # ({#on_dismiss} fires). No-op when not open, or when used tiled.
200
+ # @return [void]
201
+ def close = @popup&.close
202
+
203
+ # The popup box this dialog wants: wide enough for the caption, the widest
204
+ # message line and the button row, tall enough for the wrapped message
205
+ # plus the button row — each capped at half of `reference`. The re-grow
206
+ # rule's caller-side measure query: the dialog measures only content it
207
+ # *owns*, so a {Component} assigned to {#message=} yields the full
208
+ # half-screen box.
209
+ # @param reference [Size] the screen size to cap against.
210
+ # @return [Size]
211
+ def measured_size(reference = screen.size)
212
+ cap = Fraction::HALF.resolve(reference)
213
+ return cap if @message.is_a?(Component)
214
+
215
+ styled = @message.nil? ? StyledString::EMPTY : StyledString.parse(@message)
216
+ widest = styled.lines.map(&:display_width).max || 0
217
+ width = [[caption.display_width + 2, button_row_width + WIDTH_CHROME, widest + WIDTH_CHROME].max,
218
+ cap.width].min
219
+ rows = styled.empty? ? 0 : styled.wrap([width - WIDTH_CHROME, 1].max).size
220
+ Size.new(width, [rows + HEIGHT_CHROME, cap.height].min)
221
+ end
222
+
223
+ # Focus lands on the first button rather than cascading into the message
224
+ # body, which sits before the button row in the tree.
225
+ # @return [void]
226
+ def on_focus
227
+ first = @actions.keys.first
228
+ if first.nil?
229
+ super
230
+ else
231
+ screen.focused = first
232
+ end
233
+ end
234
+
235
+ # Handles the dialog-wide keys: Left/Right move between the buttons,
236
+ # {BODY_SCROLL_KEYS} are hand-fed to the message body, and a mnemonic
237
+ # letter presses its button. Reached by bubbling — the focused button or
238
+ # body sees the key first, so a focused body consumes its own scroll keys
239
+ # before this runs.
240
+ # @param key [String]
241
+ # @return [Boolean] true if the key was handled.
242
+ def handle_key(key)
243
+ case key
244
+ when Keys::LEFT_ARROW then return focus_button_step(-1)
245
+ when Keys::RIGHT_ARROW then return focus_button_step(1)
246
+ when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key(key) || false
247
+ end
248
+
249
+ target = @mnemonics[key.downcase]
250
+ return false if target.nil?
251
+
252
+ activate(target)
253
+ true
254
+ end
255
+
256
+ # Opens an acknowledgement dialog: a message and one button, whose press
257
+ # is the dismissal. The button exists for discoverability — ESC and `q`
258
+ # close too, but Tuile advertises no keys anywhere, and the button is
259
+ # clickable.
260
+ # @param caption [String, StyledString, nil] the border title.
261
+ # @param message [String, StyledString, Component, nil] see {#message=}.
262
+ # @param button [String, StyledString] the button label.
263
+ # @return [Popup] the mounted popup.
264
+ def self.alert(caption, message, button: "OK")
265
+ window = new(caption)
266
+ window.message = message
267
+ window.button(button)
268
+ window.open
269
+ end
270
+
271
+ # Opens a two-button question: the block is the action, the cancel button
272
+ # (with ESC, `q` and an outside click) is the dismissal.
273
+ #
274
+ # Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
275
+ # confirm: "Delete") { delete! }
276
+ #
277
+ # @param caption [String, StyledString, nil] the border title.
278
+ # @param message [String, StyledString, Component, nil] see {#message=}.
279
+ # @param confirm [String, StyledString] the action button's label.
280
+ # @param cancel [String, StyledString] the dismissal button's label.
281
+ # @param on_dismiss [Proc, nil] see {#on_dismiss}.
282
+ # @yield the action, fired after the dialog closes.
283
+ # @raise [ArgumentError] without a block — a confirm without an action is
284
+ # an {.alert}.
285
+ # @return [Popup] the mounted popup.
286
+ def self.confirm(caption, message, confirm: "Confirm", cancel: "Cancel", on_dismiss: nil, &action)
287
+ raise ArgumentError, "block required" unless action
288
+
289
+ window = new(caption)
290
+ window.message = message
291
+ window.button(confirm, &action)
292
+ window.button(cancel)
293
+ window.on_dismiss = on_dismiss
294
+ window.open
295
+ end
296
+
297
+ # {.confirm} with Yes/No labels — the other canonical phrasing.
298
+ # @param caption [String, StyledString, nil] the border title.
299
+ # @param message [String, StyledString, Component, nil] see {#message=}.
300
+ # @param on_dismiss [Proc, nil] see {#on_dismiss}.
301
+ # @yield the action, fired on Yes after the dialog closes.
302
+ # @raise [ArgumentError] without a block.
303
+ # @return [Popup] the mounted popup.
304
+ def self.yes_no(caption, message, on_dismiss: nil, &action)
305
+ confirm(caption, message, confirm: "Yes", cancel: "No", on_dismiss:, &action)
306
+ end
307
+
308
+ # The {Popup} that {#open} wraps the dialog in: its declared size is
309
+ # derived from the dialog on every {#reposition}, so a message change and
310
+ # a SIGWINCH both re-measure against the current screen.
311
+ class MeasuredPopup < Popup
312
+ # @param window [ConfirmWindow]
313
+ def initialize(window)
314
+ # Before super: Popup#initialize ends in the first #reposition call.
315
+ @window = window
316
+ super(content: window)
317
+ end
318
+
319
+ # @return [void]
320
+ def reposition
321
+ # The ivar, not #declared_size= — the writer calls reposition itself.
322
+ @declared_size = @window.measured_size(screen.size)
323
+ super
324
+ end
325
+ end
326
+ private_constant :MeasuredPopup
327
+
328
+ private
329
+
330
+ # The chosen-button path, in the settled order: mark chosen, close the
331
+ # popup (whose on_close then skips the dismissal), fire the callback last
332
+ # so it sees the dialog already gone — a callback opening a follow-up
333
+ # popup gets clean focus-repair state.
334
+ # @param btn [Button]
335
+ # @return [void]
336
+ def activate(btn)
337
+ action = @actions[btn]
338
+ @chosen = true
339
+ @popup&.close
340
+ action.nil? ? @on_dismiss&.call : action.call
341
+ end
342
+
343
+ # The popup's on_close: fires {#on_dismiss} unless a button was chosen —
344
+ # which makes ESC, `q`, an outside click, {#close}, a direct
345
+ # {Screen#remove_popup} and teardown all one event, fired exactly once.
346
+ # @return [void]
347
+ def notice_dismissed
348
+ return if @chosen
349
+
350
+ @chosen = true
351
+ @on_dismiss&.call
352
+ end
353
+
354
+ # @param delta [Integer] -1 or 1.
355
+ # @return [Boolean] false when focus is not on a button (the key bubbles on).
356
+ def focus_button_step(delta)
357
+ buttons = @actions.keys
358
+ current = buttons.index(screen.focused)
359
+ return false if current.nil?
360
+
361
+ screen.focused = buttons[(current + delta) % buttons.size]
362
+ true
363
+ end
364
+
365
+ # Validates an explicit mnemonic (raising, as {MenuBar#add_item} does —
366
+ # none of these has a sane answer at keypress time) or best-effort derives
367
+ # one from the caption's first letter (returning nil where the explicit
368
+ # path would raise).
369
+ # @param caption [StyledString]
370
+ # @param mnemonic [Symbol, String, nil]
371
+ # @raise [ArgumentError] see {#button}.
372
+ # @return [String, nil] the downcased letter, or nil for none.
373
+ def resolve_mnemonic(caption, mnemonic)
374
+ return nil if mnemonic.nil?
375
+ return derive_mnemonic(caption) if mnemonic == :auto
376
+
377
+ raise ArgumentError, "mnemonic must not be a space: Space presses the focused button" if mnemonic == " "
378
+ unless Keys.printable?(mnemonic) && StyledString.plain(mnemonic).display_width == 1
379
+ raise ArgumentError, "mnemonic must be a single one-column printable character; got #{mnemonic.inspect}"
380
+ end
381
+
382
+ down = mnemonic.downcase
383
+ if RESERVED_MNEMONICS.include?(down)
384
+ raise ArgumentError, "mnemonic #{down.inspect} is reserved: q dismisses the dialog, g/G scroll the message"
385
+ end
386
+ raise ArgumentError, "duplicate mnemonic #{down.inspect}" if @mnemonics.key?(down)
387
+
388
+ down
389
+ end
390
+
391
+ # @param caption [StyledString]
392
+ # @return [String, nil]
393
+ def derive_mnemonic(caption)
394
+ letter = caption.to_s.grapheme_clusters.first&.downcase
395
+ return nil if letter.nil? || letter == " "
396
+ return nil unless Keys.printable?(letter) && StyledString.plain(letter).display_width == 1
397
+ return nil if RESERVED_MNEMONICS.include?(letter) || @mnemonics.key?(letter)
398
+
399
+ letter
400
+ end
401
+
402
+ # {StyledString#slice} counts **columns** while a caption search yields a
403
+ # **character** index, so the prefix is measured, never counted (the
404
+ # {MenuBar::Item} cue, duplicated per `D_float_field`'s shallow-shell rule).
405
+ # @param caption [StyledString]
406
+ # @param cue [String] the letter in the case it was given in — exact case
407
+ # first, so the case picks which occurrence is underlined.
408
+ # @return [StyledString]
409
+ def underline_mnemonic(caption, cue)
410
+ text = caption.to_s
411
+ index = text.index(cue) || text.downcase.index(cue.downcase)
412
+ return caption if index.nil?
413
+
414
+ start = StyledString.plain(text[0, index]).display_width
415
+ caption.slice(0, start) + caption.slice(start, 1).with_underline +
416
+ caption.slice(start + 1, caption.display_width - start - 1)
417
+ end
418
+
419
+ # Re-declares the button row's cross extent — the buttons' summed natural
420
+ # width — so the {Layout::Vertical} centers it. Remove-and-re-add is the
421
+ # only way to change a {Layout::Box} constraint; the row stays after the
422
+ # body because both `add`s append.
423
+ # @return [void]
424
+ def recenter_button_row
425
+ @box.remove(@button_row)
426
+ @box.add(@button_row, Layout::Fixed[1], cross: Layout::Fixed[button_row_width], align: :center)
427
+ end
428
+
429
+ # @return [Integer] columns of the button row at its natural width.
430
+ def button_row_width
431
+ widths = @actions.keys.map { _1.caption.display_width + 4 }
432
+ widths.sum + (@button_row.spacing * [widths.size - 1, 0].max)
433
+ end
434
+
435
+ # Re-measures the popup while open; a no-op tiled or before {#open}.
436
+ # @return [void]
437
+ def resize_popup
438
+ @popup.reposition if @popup&.open?
439
+ end
440
+ end
441
+ end
442
+ end
@@ -2,19 +2,32 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A mixin interface for a component with one child tops. The host must
6
- # provide a protected `layout(content)` method which repositions the
7
- # content component; the mixin manages `@content` itself.
5
+ # A component that owns exactly one child *directly*, under the name
6
+ # `content`. The includer initializes `@content` to nil and provides a
7
+ # protected `layout(content)` positioning the child; the mixin owns the swap:
8
+ #
9
+ # class Slot < Component
10
+ # include Component::HasContent
11
+ #
12
+ # def initialize = (super; @content = nil)
13
+ #
14
+ # protected
15
+ #
16
+ # def layout(content) = content.rect = rect
17
+ # end
18
+ #
19
+ # Include it when the child is **permanent and integral** — a typed field's
20
+ # inner {TextField}, an {Overlay}'s body. It does *not* mean "a component
21
+ # with one child": an includer may hold others alongside, as {Window} does
22
+ # with its footer. For a region an app swaps, hold a {Slot} instead.
23
+ #
24
+ # A tree walk finds content generically through `is_a?(HasContent)` plus a
25
+ # `content` compare, which is why this is a mixin rather than a per-class
26
+ # accessor — the same reason {HasCaption} is one.
8
27
  module HasContent
9
28
  # @return [Component, nil] the current content component.
10
29
  attr_reader :content
11
30
 
12
- # @param event [MouseEvent]
13
- # @return [void]
14
- def handle_mouse(event)
15
- content.handle_mouse(event) if !content.nil? && content.rect.contains?(event.point)
16
- end
17
-
18
31
  # Sets the new content of this component. Updates `@content` itself;
19
32
  # including classes may still override to add behaviour (e.g. a
20
33
  # special-cased Array input) but should call `super` to perform the
@@ -56,7 +56,7 @@ module Tuile
56
56
  # a read-only display field could override back to `false`. Only
57
57
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
58
58
  # composing wrappers, so it stays per-class (`DECISIONS.md`
59
- # `D-integer-field`).
59
+ # `D_integer_field`).
60
60
  # @return [Boolean]
61
61
  def focusable? = true
62
62
  end
@@ -2,30 +2,78 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A {Window} preconfigured with a {List} of static lines. Useful for
6
- # showing read-only information.
5
+ # A {Window} with a read-only body, in one of two presentations:
6
+ # **prose** ({#message=}) wraps in a scrollable {TextView}, **rows**
7
+ # ({#lines=}) stay one per row in a {List}, truncating instead of
8
+ # wrapping — the presentation for columnar output, where a wrap would
9
+ # destroy the alignment. Usable tiled (add it to a {Layout}) or as a
10
+ # popup via {.open}; the constructor and {.open} pick the presentation
11
+ # from the body's type:
7
12
  #
8
- # Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
9
- # wraps it in a {Popup}.
13
+ # Component::InfoWindow.open("Help", "A long explanation, wrapped to fit.")
14
+ # Component::InfoWindow.open("Files", ["drwx src/", "-rw- README.md"])
15
+ #
16
+ # Both write the same body slot — the last writer wins.
10
17
  class InfoWindow < Window
11
- # @param caption [String]
12
- # @param lines [Array<String>] initial content; each entry may contain
13
- # Rainbow formatting.
14
- def initialize(caption = "", lines = [])
18
+ # @param caption [String, StyledString, nil] the border title.
19
+ # @param body [String, StyledString, Component, Array, nil] an `Array`
20
+ # is assigned through {#lines=}, anything else through {#message=}.
21
+ def initialize(caption = "", body = nil)
22
+ @message = nil
15
23
  super(caption)
16
- list = Component::List.new
24
+ if body.is_a?(Array)
25
+ self.lines = body
26
+ else
27
+ self.message = body
28
+ end
29
+ end
30
+
31
+ # @return [String, StyledString, Component, nil] whatever {#message=}
32
+ # was given — set a `String`, read that `String` back. After
33
+ # {#lines=} it is the {List} that setter mounted.
34
+ attr_reader :message
35
+
36
+ # Sets the prose body. Text (`String` / {StyledString}) is rendered by
37
+ # a word-wrapping, scrollable {TextView} the window owns; a {Component}
38
+ # is mounted as-is. `nil` clears.
39
+ # @param value [String, StyledString, Component, nil]
40
+ # @raise [TypeError] on any other type.
41
+ # @return [void]
42
+ def message=(value)
43
+ occupant =
44
+ case value
45
+ when nil then nil
46
+ when Component then value
47
+ when String, StyledString then TextView.new.tap { _1.text = value }
48
+ else raise TypeError, "expected String, StyledString, Component or nil, got #{value.inspect}"
49
+ end
50
+ @message = value
51
+ self.content = occupant
52
+ end
53
+
54
+ # Sets the rows body: a {List} populated via {List#lines=} (so entries
55
+ # are coerced, `\n`-split and rstripped exactly as there), one item per
56
+ # row, long rows truncated — never wrapped. For prose use {#message=}.
57
+ # @param lines [Array] entries are `String`, `StyledString`, or anything
58
+ # that responds to `#to_s`.
59
+ # @raise [TypeError] unless an `Array`.
60
+ # @return [void]
61
+ def lines=(lines)
62
+ list = List.new
17
63
  list.lines = lines
18
- self.content = list
64
+ self.message = list
19
65
  end
20
66
 
21
67
  # Opens the info window as a popup.
22
- # @param caption [String]
23
- # @param lines [Array<String>] the content, may contain formatting.
24
- # @param size [Size, Fraction] the popup's size, applied top-down; the
25
- # list wraps and scrolls within it. Defaults to {Fraction::HALF}.
68
+ # @param caption [String, StyledString, nil] the border title.
69
+ # @param body [String, StyledString, Component, Array, nil] dispatched
70
+ # by type as in {#initialize}.
71
+ # @param declared_size [Size, Fraction] the popup's box, applied
72
+ # top-down; the body wraps or scrolls within it. Defaults to
73
+ # {Fraction::HALF}.
26
74
  # @return [Popup] the opened popup.
27
- def self.open(caption, lines, size: Fraction::HALF)
28
- Popup.new(content: InfoWindow.new(caption, lines), size: size).open
75
+ def self.open(caption, body = nil, declared_size: Fraction::HALF)
76
+ Popup.new(content: InfoWindow.new(caption, body), declared_size: declared_size).open
29
77
  end
30
78
  end
31
79
  end
@@ -190,16 +190,6 @@ module Tuile
190
190
  invalidate if @children.empty? # nothing left to paint over the gap
191
191
  end
192
192
 
193
- # Dispatches the event to the child under the mouse cursor.
194
- # @param event [MouseEvent]
195
- # @return [void]
196
- def handle_mouse(event)
197
- super
198
- @children.each do |child|
199
- child.handle_mouse(event) if child.rect.contains?(event.point)
200
- end
201
- end
202
-
203
193
  # @return [void]
204
194
  def on_focus
205
195
  super