tuile 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
@@ -5,10 +5,15 @@ module Tuile
5
5
  #
6
6
  # {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
7
7
  # invalidation set). All actual UI lives under a {ScreenPane}: the tiled
8
- # {#content}, the modal {#popups} stack, and the bottom {#status_bar}.
9
- # Putting them under a single Component parent gives focus traversal a real
10
- # root, makes {Component#attached?} a one-liner, and lets popup-focus repair
11
- # fall out of the standard {Component#on_child_removed} hook.
8
+ # {#content} and the {#popups} stack. Putting them under a single Component
9
+ # parent gives focus traversal a real root, makes {Component#attached?} a
10
+ # one-liner, and lets popup-focus repair fall out of the standard
11
+ # {Component#on_child_removed} hook.
12
+ #
13
+ # The pane owns no chrome of its own — no status bar, no reserved row.
14
+ # {#content} gets the full pane rect, and an app that wants a status line
15
+ # builds one into its own layout and drives it from
16
+ # {Screen#on_focus_changed=} (`D-status-bar`).
12
17
  #
13
18
  # The pane is not a {Component::Layout}: popups deliberately overlap content
14
19
  # (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
@@ -22,10 +27,6 @@ module Tuile
22
27
  # user was, instead of falling through to {#content} and getting
23
28
  # cascaded to the first focusable child.
24
29
  @popup_prior_focus = {}
25
- @status_bar = Component::Label.new
26
- # Added first and never removed, so it is always the last child — which is
27
- # the anchor `add_popup` inserts against.
28
- add_child(@status_bar)
29
30
  end
30
31
 
31
32
  # @return [Component, nil] the tiled content component.
@@ -34,8 +35,6 @@ module Tuile
34
35
  # topmost. Holds both modal popups and non-modal overlays
35
36
  # ({Component::Popup#modal?}). The array must not be mutated by callers.
36
37
  attr_reader :popups
37
- # @return [Component::Label] the bottom status bar.
38
- attr_reader :status_bar
39
38
 
40
39
  def focusable? = false
41
40
 
@@ -74,7 +73,7 @@ module Tuile
74
73
 
75
74
  @popup_prior_focus[window] = screen.focused
76
75
  @popups << window
77
- add_child(window, at: @children.index(@status_bar))
76
+ add_child(window) # appended: popups paint over the tiled content
78
77
  if window.modal?
79
78
  window.center
80
79
  screen.focused = window
@@ -126,9 +125,9 @@ module Tuile
126
125
 
127
126
  # @return [Component::Popup, nil] the topmost *modal* popup, or nil when
128
127
  # only non-modal overlays (or no popups) are open. This is the "modal
129
- # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
130
- # the status bar, and confines Tab cycling. Non-modal overlays are
131
- # excluded — they float above the content without capturing input.
128
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, and
129
+ # confines Tab cycling. Non-modal overlays are excluded — they float above
130
+ # the content without capturing input.
132
131
  def modal_popup = @popups.reverse_each.find(&:modal?)
133
132
 
134
133
  # Re-lays out children whenever the pane's own rect changes.
@@ -139,18 +138,17 @@ module Tuile
139
138
  layout
140
139
  end
141
140
 
142
- # Lays out content (full pane minus the bottom row) and the status bar
143
- # (bottom row). Each popup re-resolves its {Component::Popup#size} against
144
- # the new screen via {Component::Popup#reposition} — so a {Fraction} size
145
- # tracks resize — repositioning itself (modal popups recenter; non-modal
146
- # overlays keep the top-left their owner assigned).
141
+ # Gives {#content} the whole pane rect — the pane reserves nothing for
142
+ # itself. Each popup re-resolves its {Component::Popup#size} against the new
143
+ # screen via {Component::Popup#reposition} — so a {Fraction} size tracks
144
+ # resize — repositioning itself (modal popups recenter; non-modal overlays
145
+ # keep the top-left their owner assigned).
147
146
  # @return [void]
148
147
  def layout
149
148
  return if rect.empty?
150
149
 
151
- @content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
150
+ @content&.rect = rect
152
151
  @popups.each(&:reposition)
153
- @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
154
152
  end
155
153
 
156
154
  # Pane paints nothing itself; its children paint over the entire rect.
@@ -181,18 +179,67 @@ module Tuile
181
179
  bubble_key(key, scope)
182
180
  end
183
181
 
182
+ # Delivers pasted text along the same focus chain {#handle_key} bubbles
183
+ # along, and with the same scoping — first {Component#handle_paste}
184
+ # returning true wins.
185
+ # @param text [String]
186
+ # @return [Boolean] true if the text was consumed.
187
+ def handle_paste(text)
188
+ scope = modal_popup || @content
189
+ return false if scope.nil?
190
+
191
+ chain = focus_chain(scope)
192
+ return false if chain.nil?
193
+
194
+ chain.each { |c| return true if c.handle_paste(text) }
195
+ false
196
+ end
197
+
184
198
  # Mouse events check popups in reverse stacking order (topmost first), and
185
199
  # fall through to content only when no popup is hit *and* no modal popup is
186
200
  # open. This preserves modal click-blocking — an open modal eats clicks
187
201
  # even outside its rect — while a non-modal overlay blocks nothing: clicks
188
202
  # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
189
203
  # content beneath.
204
+ #
205
+ # A left click also *dismisses* the open popups it landed outside of that
206
+ # asked for it ({Component::Popup#close_on_outside_click?}). That is a
207
+ # second thing happening on a click, but not a second dispatch: the click is
208
+ # still delivered exactly once, down one chain, and a dismissed popup is
209
+ # closed rather than told.
210
+ #
211
+ # "Outside" is measured against the {Component::Popup#owner} chain, not
212
+ # against one rect and not against stacking order: the popup the click hit
213
+ # is kept, and so is every popup that one *belongs to*, transitively. That
214
+ # is what stops a dialog being dismissed by a click on a dropdown its own
215
+ # field opened, and a menu cascade being dismissed by a click on one of its
216
+ # own deeper panels. Order carries no meaning here — between unrelated
217
+ # overlays it is merely the order they opened in — so ownership is declared
218
+ # rather than inferred from the stack.
219
+ #
220
+ # Two halves of the ordering are load-bearing, and both are specced:
221
+ #
222
+ # - **Snapshot before routing.** A popup the delivered click *opens* must
223
+ # not be in the set (it would immediately dismiss itself — every
224
+ # {Component::Select} would be unopenable by mouse).
225
+ # - **Close after routing.** A widget toggling its own overlay from a click
226
+ # on its face closes it during delivery, and {Component::Popup#close} is
227
+ # idempotent, so the dismissal no-ops. Close *first* and the widget sees
228
+ # a shut overlay and reopens it — a Select's dropdown could then never be
229
+ # dismissed by clicking the Select.
230
+ #
231
+ # The snapshot is a fresh array for a third reason: a handler may close
232
+ # further popups, and `@popups` must not be mutated mid-iteration.
190
233
  # @param event [MouseEvent]
191
234
  # @return [void]
192
235
  def handle_mouse(event)
193
- clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
194
- clicked = @content if clicked.nil? && modal_popup.nil?
236
+ hit = @popups.reverse_each.find { _1.rect.contains?(event.point) }
237
+ dismissable = event.button == :left ? @popups - kept_by(hit) : []
238
+
239
+ clicked = hit || (@content if modal_popup.nil?)
195
240
  clicked&.handle_mouse(event)
241
+
242
+ dismissable.each { _1.close if _1.close_on_outside_click? }
196
243
  end
197
244
 
198
245
  # Focus repair when a child detaches. Default {Component#on_child_removed}
@@ -231,6 +278,32 @@ module Tuile
231
278
 
232
279
  private
233
280
 
281
+ # The popups a click counts as landing *inside*: the one it hit, plus every
282
+ # popup that one belongs to, up the {Component::Popup#owner} chain. An owner
283
+ # is any component, so it is resolved to the popup enclosing it (a popup
284
+ # resolves to itself) — which keeps the relationship a live tree question
285
+ # rather than one frozen when the overlay opened. The `include?` guard makes
286
+ # a mis-wired cycle terminate instead of hanging the UI thread.
287
+ # @param hit [Component::Popup, nil] the popup the click landed in, if any.
288
+ # @return [Array<Component::Popup>]
289
+ def kept_by(hit)
290
+ kept = []
291
+ popup = hit
292
+ while popup && !kept.include?(popup)
293
+ kept << popup
294
+ popup = enclosing_popup(popup.owner)
295
+ end
296
+ kept
297
+ end
298
+
299
+ # @param component [Component, nil]
300
+ # @return [Component::Popup, nil] `component` itself when it is a popup,
301
+ # else the nearest popup above it, else nil.
302
+ def enclosing_popup(component)
303
+ component = component.parent until component.nil? || component.is_a?(Component::Popup)
304
+ component
305
+ end
306
+
234
307
  # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
235
308
  # stopping at (and including) `scope`. Delivers to no one — returning false
236
309
  # — when focus is nil or sits outside `scope`; the latter is what makes an
@@ -240,6 +313,18 @@ module Tuile
240
313
  # @param scope [Component] the modal scope root (topmost popup or content).
241
314
  # @return [Boolean] true if some component on the chain handled the key.
242
315
  def bubble_key(key, scope)
316
+ chain = focus_chain(scope)
317
+ return false if chain.nil?
318
+
319
+ chain.each { |c| return true if c.handle_key(key) }
320
+ false
321
+ end
322
+
323
+ # {Screen#focused} and its ancestors up to and including `scope`.
324
+ # @param scope [Component] the modal scope root (topmost popup or content).
325
+ # @return [Array<Component>, nil] the chain, innermost first; nil when
326
+ # focus is nil or sits outside `scope`.
327
+ def focus_chain(scope)
243
328
  chain = []
244
329
  cursor = screen.focused
245
330
  until cursor.nil?
@@ -248,10 +333,7 @@ module Tuile
248
333
 
249
334
  cursor = cursor.parent
250
335
  end
251
- return false unless chain.last.equal?(scope)
252
-
253
- chain.each { |c| return true if c.handle_key(key) }
254
- false
336
+ chain.last.equal?(scope) ? chain : nil
255
337
  end
256
338
 
257
339
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
@@ -630,6 +630,46 @@ module Tuile
630
630
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
631
631
  end
632
632
 
633
+ # Returns a new {StyledString} with `bold` applied to every span, preserving
634
+ # each span's text and other style attributes (`fg`, `bg`, `italic`,
635
+ # `underline`, `strikethrough`). The bold-attribute counterpart of
636
+ # {#with_bg} / {#with_fg}: it emphasizes a whole run of app-authored,
637
+ # possibly multi-span content — a widget marking one caption out of several
638
+ # as selected, where the caption may already carry its own colors.
639
+ #
640
+ # There is deliberately no `under_bold` (the fill-unset counterpart
641
+ # {#under_bg} provides for backgrounds): a background is inherited down the
642
+ # component tree, so a span with none has a meaningful "unset" state to
643
+ # fill, while `bold` is a plain per-span attribute that is either on or
644
+ # off. Pass `bold: false` to clear it.
645
+ #
646
+ # @param bold [Boolean] whether the spans should be bold.
647
+ # @return [StyledString]
648
+ def with_bold(bold: true)
649
+ self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bold:)) })
650
+ end
651
+
652
+ # Returns a new {StyledString} with `underline` applied to every span,
653
+ # preserving each span's text and other style attributes (`fg`, `bg`,
654
+ # `bold`, `italic`, `strikethrough`). Slice and rejoin to underline *part*
655
+ # of a string, which is what a one-character cue needs:
656
+ #
657
+ # cap = StyledString.parse("File")
658
+ # cap.slice(0, 1).with_underline + cap.slice(1, cap.display_width - 1)
659
+ # # => "File" with the F underlined — a menu mnemonic
660
+ #
661
+ # Note {#slice} counts **columns**, not characters, so a caption with a
662
+ # wide glyph before the cue needs the prefix measured rather than counted.
663
+ #
664
+ # There is deliberately no `under_underline`, for the reason {#with_bold}
665
+ # spells out. Pass `underline: false` to clear it.
666
+ #
667
+ # @param underline [Boolean] whether the spans should be underlined.
668
+ # @return [StyledString]
669
+ def with_underline(underline: true)
670
+ self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(underline:)) })
671
+ end
672
+
633
673
  # @return [String]
634
674
  def inspect
635
675
  "#<#{self.class.name} #{to_s.inspect}>"
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.12.0"
5
+ VERSION = "0.13.0"
6
6
  end