tuile 0.11.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. 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
@@ -536,7 +536,7 @@ module Tuile
536
536
  result
537
537
  end
538
538
 
539
- # Word-wraps to physical lines that each fit within `width` display
539
+ # Word-wraps to rows that each fit within `width` display
540
540
  # columns, preserving spans and styles across breaks. Greedy word-wrap,
541
541
  # hard-break for words wider than `width`, leading whitespace dropped on
542
542
  # wrapped continuations, hard `"\n"` breaks preserved as separate output
@@ -560,16 +560,16 @@ module Tuile
560
560
  # @param width [Integer, nil] target column width. `nil` or `<= 0` skips
561
561
  # wrapping and returns each hard-line as-is, so callers can pass a
562
562
  # stale viewport width without crashing.
563
- # @return [Array<StyledString>] one entry per physical (output) line.
563
+ # @return [Array<StyledString>] one entry per output row.
564
564
  # An empty receiver returns `[]`.
565
565
  def wrap(width)
566
566
  return [] if empty?
567
567
 
568
- hard_lines = lines
569
- return hard_lines if width.nil? || width <= 0
568
+ input_lines = lines
569
+ return input_lines if width.nil? || width <= 0
570
570
 
571
571
  result = []
572
- hard_lines.each { |hl| result.concat(wrap_one(hl, width)) }
572
+ input_lines.each { |line| result.concat(wrap_one(line, width)) }
573
573
  result
574
574
  end
575
575
 
@@ -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}>"
@@ -717,17 +757,17 @@ module Tuile
717
757
  self.class.new(out)
718
758
  end
719
759
 
720
- # @param hard_line [StyledString] one hard-broken line — no embedded `"\n"`.
760
+ # @param line [StyledString] one line — no embedded `"\n"`.
721
761
  # @param width [Integer]
722
762
  # @return [Array<StyledString>]
723
- def wrap_one(hard_line, width)
724
- return [hard_line] if hard_line.empty?
763
+ def wrap_one(line, width)
764
+ return [line] if line.empty?
725
765
 
726
766
  result = []
727
767
  line_glyphs = []
728
768
  line_w = 0
729
769
 
730
- tokenize_for_wrap(hard_line).each do |type, glyphs, w|
770
+ tokenize_for_wrap(line).each do |type, glyphs, w|
731
771
  if type == :space
732
772
  if line_w.zero? && (!result.empty? || w > width)
733
773
  # Nothing to emit: a continuation's leading run was consumed by the
@@ -763,18 +803,18 @@ module Tuile
763
803
  # a cluster is the unit a terminal draws, so measuring its parts separately
764
804
  # would both mis-total an emoji sequence and let a wrap break a letter away
765
805
  # from its combining mark.
766
- # @param hard_line [StyledString]
806
+ # @param line [StyledString]
767
807
  # @return [Array<Array>] tokens shaped `[type, glyphs, w]` where `type` is
768
808
  # `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
769
809
  # (grapheme cluster, style, display width), and `w` is the token's total
770
810
  # width.
771
- def tokenize_for_wrap(hard_line)
811
+ def tokenize_for_wrap(line)
772
812
  tokens = []
773
813
  current_glyphs = []
774
814
  current_w = 0
775
815
  current_type = nil
776
816
 
777
- each_glyph_with_style(hard_line) do |g, s|
817
+ each_glyph_with_style(line) do |g, s|
778
818
  type = [" ", "\t"].include?(g) ? :space : :word
779
819
  gw = Buffer.display_width(g)
780
820
  if current_type && current_type != type
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.11.0"
5
+ VERSION = "0.13.0"
6
6
  end
@@ -18,20 +18,20 @@ module Tuile
18
18
 
19
19
  # @param height [Integer] number of rows in the scrollbar (== viewport
20
20
  # height).
21
- # @param line_count [Integer] total number of content lines.
22
- # @param top_line [Integer] index of the first visible content line.
23
- def initialize(height, line_count:, top_line:)
21
+ # @param row_count [Integer] total number of content rows.
22
+ # @param scroll_top_row [Integer] index of the first visible content row.
23
+ def initialize(height, row_count:, scroll_top_row:)
24
24
  @height = height
25
25
 
26
26
  return unless height >= 1
27
27
 
28
- if line_count <= height
28
+ if row_count <= height
29
29
  @handle_height = height
30
30
  @handle_start = 0
31
31
  @handle_end = height - 1
32
32
  else
33
- @handle_height = [(height * height / line_count.to_f).ceil, 1].max
34
- @handle_start = (height * top_line / line_count.to_f).floor
33
+ @handle_height = [(height * height / row_count.to_f).ceil, 1].max
34
+ @handle_start = (height * scroll_top_row / row_count.to_f).floor
35
35
  @handle_end = @handle_start + @handle_height - 1
36
36
  end
37
37
  end