tuile 0.8.0 → 0.10.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -23,7 +23,9 @@ module Tuile
23
23
  # cascaded to the first focusable child.
24
24
  @popup_prior_focus = {}
25
25
  @status_bar = Component::Label.new
26
- @status_bar.parent = self
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)
27
29
  end
28
30
 
29
31
  # @return [Component, nil] the tiled content component.
@@ -37,10 +39,6 @@ module Tuile
37
39
 
38
40
  def focusable? = false
39
41
 
40
- # Children for tree traversal: content first, popups in stacking order,
41
- # status bar last.
42
- def children = [*[@content].compact, *@popups, @status_bar]
43
-
44
42
  # Replaces the tiled content. Wipes focus first (the new tree starts
45
43
  # fresh), detaches the old content, then attaches the new one and
46
44
  # re-lays out.
@@ -51,10 +49,9 @@ module Tuile
51
49
  return if @content == content
52
50
 
53
51
  screen.focused = nil
54
- old = @content
55
- old&.parent = nil
52
+ remove_child(@content) unless @content.nil?
56
53
  @content = content
57
- content.parent = self
54
+ add_child(content, at: 0) # the tiled layer paints beneath everything else
58
55
  layout
59
56
  end
60
57
 
@@ -63,6 +60,12 @@ module Tuile
63
60
  # left wherever the caller positions it and does *not* take focus, so the
64
61
  # component that was focused keeps the cursor and keeps receiving keys —
65
62
  # the overlay floats above the content, driven from app code.
63
+ #
64
+ # The *whole subtree* is invalidated, not just the popup wrapper (which
65
+ # paints nothing on its own): a reopened popup may land on cells that the
66
+ # tiled content has since overpainted, and if its rect is unchanged from
67
+ # last time its content components won't re-invalidate themselves — so
68
+ # without this the popup's contents would stay blank on reopen.
66
69
  # @param window [Component::Popup]
67
70
  # @return [void]
68
71
  def add_popup(window)
@@ -71,12 +74,12 @@ module Tuile
71
74
 
72
75
  @popup_prior_focus[window] = screen.focused
73
76
  @popups << window
74
- window.parent = self
77
+ add_child(window, at: @children.index(@status_bar))
75
78
  if window.modal?
76
79
  window.center
77
80
  screen.focused = window
78
81
  end
79
- screen.invalidate(window)
82
+ window.on_tree { |c| screen.invalidate(c) }
80
83
  end
81
84
 
82
85
  # Removes a popup. If the popup held focus, focus shifts to the now-topmost
@@ -88,21 +91,35 @@ module Tuile
88
91
  raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
89
92
 
90
93
  prior = @popup_prior_focus.delete(window)
91
- # Detach first so the popup becomes its own root; then any prior
92
- # pointing *inside* that popup is detectable via `p.root == window`.
93
- window.parent = nil
94
- # If any other popup recorded its prior focus inside the popup we're
95
- # removing, forward it to *our* prior so chained closures still climb
96
- # back to the original owner instead of stopping at a detached
97
- # component.
98
- @popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
99
-
100
94
  @removing_popup_prior = prior
101
- on_child_removed(window)
95
+ remove_child(window)
96
+ # Runs after the detach, so a prior pointing *inside* the removed popup is
97
+ # detectable via `p.root == window`: forward it to *our* prior, so chained
98
+ # closures climb back to the original owner instead of stopping at a
99
+ # detached component.
100
+ @popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
102
101
  ensure
103
102
  @removing_popup_prior = nil
104
103
  end
105
104
 
105
+ # Unmounts everything: each child is detached — firing {Component#on_detached}
106
+ # down its subtree — and every slot is emptied. Terminal; the pane isn't
107
+ # reusable afterwards, and {Screen#close} is its only caller.
108
+ #
109
+ # Deliberately not named `close` ({Component::Popup#close} already means
110
+ # "remove *me* from the pane"), and deliberately not a generic
111
+ # `Component#remove_all_children`: a slot container calling that would empty
112
+ # `@children` while `#content` / `#footer` still pointed at detached
113
+ # components, which is the desync the tree API exists to prevent.
114
+ # @return [void]
115
+ def detach_all
116
+ screen.focused = nil # …so the focus repair in on_child_removed has nothing to do
117
+ children.dup.each { detach_child(_1) }
118
+ @content = nil
119
+ @popups.clear
120
+ @popup_prior_focus.clear
121
+ end
122
+
106
123
  # @param window [Component]
107
124
  # @return [Boolean] true if this pane currently hosts the popup.
108
125
  def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
@@ -123,14 +140,16 @@ module Tuile
123
140
  end
124
141
 
125
142
  # Lays out content (full pane minus the bottom row) and the status bar
126
- # (bottom row). Modal popups self-recenter via {Component::Popup#center};
127
- # non-modal overlays keep the position their owner assigned.
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).
128
147
  # @return [void]
129
148
  def layout
130
149
  return if rect.empty?
131
150
 
132
151
  @content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
133
- @popups.each { |p| p.center if p.modal? }
152
+ @popups.each(&:reposition)
134
153
  @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
135
154
  end
136
155
 
@@ -138,34 +157,27 @@ module Tuile
138
157
  # @return [void]
139
158
  def repaint; end
140
159
 
141
- # Dispatches a key in two phases, both scoped to the topmost *modal* popup
142
- # (when one is open) or else the tiled {#content}. Non-modal overlays are
143
- # never the scope: focus stays in the content beneath them, and the overlay
144
- # is driven by app code (which forwards keys to it explicitly), so it
145
- # doesn't appear in this path at all.
160
+ # Delivers a key to {Screen#focused}, then bubbles it up the focus chain —
161
+ # the first component whose `handle_key` returns true wins.
162
+ #
163
+ # Bubbling stops at the *scope* root: the topmost *modal* popup when one is
164
+ # open, else the tiled {#content}. Focus that is nil or sits outside the
165
+ # scope receives nothing, which is what keeps an open modal popup modal.
166
+ # Non-modal overlays are never the scope: focus stays in the content
167
+ # beneath them, and the overlay is driven by app code (which forwards keys
168
+ # to it explicitly), so it doesn't appear in this path at all.
146
169
  #
147
- # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
148
- # focuses that component and consumes the key. Suppressed while a
149
- # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
150
- # {Component::TextField} isn't hijacked by a sibling's shortcut.
151
- # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
152
- # ancestor chain to the scope root; the first component to return true
153
- # wins. Focus that is nil or sits outside the scope receives nothing,
154
- # which is what keeps an open modal popup modal.
170
+ # Because an ancestor sees a key only after every descendant on the chain
171
+ # declined it, the scope root is the natural home for scope-wide fallbacks
172
+ # — a form's default button, or a layout's one-key jumps to its panes (a
173
+ # focused {Component::TextField} consumes the key first, so typing is never
174
+ # hijacked).
155
175
  # @param key [String]
156
176
  # @return [Boolean] true if the key was handled.
157
177
  def handle_key(key)
158
178
  scope = modal_popup || @content
159
179
  return false if scope.nil?
160
180
 
161
- if screen.cursor_position.nil?
162
- target = scope.find_shortcut_component(key)
163
- unless target.nil?
164
- screen.focused = target
165
- return true
166
- end
167
- end
168
-
169
181
  bubble_key(key, scope)
170
182
  end
171
183
 
@@ -3,14 +3,11 @@
3
3
  module Tuile
4
4
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
5
5
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
6
- # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
7
- # character has exactly one resolved style, no overlay layers to merge.
8
- #
9
- # Where this differs from threading SGR escapes through a plain `String`:
10
- # slicing, wrapping, and concatenation operate on the structured spans, so
11
- # they never have to "figure out what SGR state is active at column N" —
12
- # the answer is just the containing span's `style`. The flip side is one
13
- # extra type to construct (or parse) before doing styled-text math.
6
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile
7
+ # the string — every character has exactly one resolved style, no overlay
8
+ # layers to merge, so the style at any column is just its span's `style`
9
+ # rather than a replay of the SGR state machine. The book's chapter 9 is
10
+ # the long-form *why* (spans vs. a `String` full of escape codes).
14
11
  #
15
12
  # ## Constructors
16
13
  #
@@ -34,30 +31,15 @@ module Tuile
34
31
  # ss.each_char_with_style { |ch, style| ... }
35
32
  # ```
36
33
  #
37
- # ## Rendering
38
- #
39
- # - `#to_s` — plain text, no SGR.
40
- # - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
41
- # the last span carried a non-default style. Transitions to the default
42
- # style emit `\e[0m` (shorter than re-emitting every off-code).
43
- #
44
34
  # ## Parser
45
35
  #
46
- # {.parse} is strict by default: it recognizes only the SGR codes
47
- # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
48
- # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
49
- # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
50
- # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
51
- # round-trip parse(to_ansi(x)) == x contract honest.
52
- #
53
- # Pass `lenient: true` to instead **discard** everything the parser can't
54
- # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
55
- # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
56
- # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
57
- # silently dropped. This is the mode for piping in colored output you don't
58
- # control (e.g. `git --color` through a pager): "give me the colors, throw
59
- # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
60
- # round-trip back to `x`.
36
+ # {.parse} is strict by default — it recognizes only the SGR codes for
37
+ # {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
38
+ # raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
39
+ # round-trip honest. Pass `lenient: true` to instead discard everything it
40
+ # can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
41
+ # only the recognized colors — lossy by design, for piping in colored output
42
+ # you don't control. See the book for the full rationale.
61
43
  class StyledString
62
44
  # Raised by {.parse} on malformed or unsupported escape sequences.
63
45
  class ParseError < Error; end
@@ -111,10 +93,6 @@ module Tuile
111
93
  # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
112
94
  # (`\e[0m`, one code) when `other` is the default style — shorter than
113
95
  # turning each attribute off individually.
114
- #
115
- # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
116
- # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
117
- # terminal currently holds), so both emit identical minimal sequences.
118
96
  # @param other [Style] the style to transition to.
119
97
  # @return [String]
120
98
  def sgr_to(other)
@@ -419,6 +397,19 @@ module Tuile
419
397
  end
420
398
  end
421
399
 
400
+ # The framework's single emoji-width policy, passed to every
401
+ # `Unicode::DisplayWidth.of` call in Tuile.
402
+ #
403
+ # `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
404
+ # sequences — the ones vendors actually ship a single glyph for — and *sums*
405
+ # the parts of anything else. That is the one setting never wrong in the
406
+ # dangerous direction: under-measuring lets a glyph overrun its cell, which
407
+ # shifts the rest of the row, desyncs the cursor and escapes the component's
408
+ # rect, while over-measuring leaves a blank column. `D-cluster-width` has the
409
+ # per-setting reasoning.
410
+ # @return [Symbol]
411
+ EMOJI_WIDTH = :rgi
412
+
422
413
  # @return [Array<Span>] the frozen, normalized span list — no empty-text
423
414
  # entries, no two adjacent entries sharing a style.
424
415
  attr_reader :spans
@@ -430,10 +421,9 @@ module Tuile
430
421
 
431
422
  # Total display width in terminal columns, accounting for Unicode wide
432
423
  # characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
433
- # Memoized — safe because spans are frozen and immutable.
434
424
  # @return [Integer]
435
425
  def display_width
436
- @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text) }
426
+ @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text, emoji: EMOJI_WIDTH) }
437
427
  end
438
428
 
439
429
  # @return [Boolean]
@@ -450,7 +440,7 @@ module Tuile
450
440
  # emits `\e[0m` (one code) instead of the longer "turn each attribute
451
441
  # off" form. Always closes with `\e[0m` when the last span carried a
452
442
  # non-default style, so the styled run doesn't bleed into subsequent
453
- # output. Memoized — safe because spans are frozen and immutable.
443
+ # output.
454
444
  # @return [String]
455
445
  def to_ansi
456
446
  @to_ansi ||= build_ansi
@@ -600,6 +590,24 @@ module Tuile
600
590
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
601
591
  end
602
592
 
593
+ # Returns a copy with `bg` set **only on spans that have none**; a span with
594
+ # an explicit bg is left untouched. The fill-unset counterpart of {#with_bg}
595
+ # (which overrides every span) — it slides a background *under* the content,
596
+ # so a log line keeps its red error-level bg while its plain text picks up an
597
+ # inherited panel tint.
598
+ #
599
+ # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background color,
600
+ # coerced via {Color.coerce}. `nil` returns `self` unchanged.
601
+ # @return [StyledString]
602
+ def under_bg(bg)
603
+ return self if bg.nil?
604
+
605
+ bg = Color.coerce(bg)
606
+ self.class.new(@spans.map do |span|
607
+ span.style.bg.nil? ? Span.new(text: span.text, style: span.style.merge(bg: bg)) : span
608
+ end)
609
+ end
610
+
603
611
  # Returns a new {StyledString} with `fg` applied to every span, preserving
604
612
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
605
613
  # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
@@ -683,7 +691,7 @@ module Tuile
683
691
  out = []
684
692
  col = 0
685
693
  @spans.each do |span|
686
- span_width = Unicode::DisplayWidth.of(span.text)
694
+ span_width = Unicode::DisplayWidth.of(span.text, emoji: EMOJI_WIDTH)
687
695
  span_end = col + span_width
688
696
 
689
697
  next col = span_end if span_end <= start
@@ -707,74 +715,92 @@ module Tuile
707
715
  return [hard_line] if hard_line.empty?
708
716
 
709
717
  result = []
710
- line_chars = []
718
+ line_glyphs = []
711
719
  line_w = 0
712
720
 
713
- tokenize_for_wrap(hard_line).each do |type, chars, w|
721
+ tokenize_for_wrap(hard_line).each do |type, glyphs, w|
714
722
  if type == :space
715
723
  if line_w.zero?
716
724
  # leading whitespace on a wrapped continuation: drop
717
725
  elsif line_w + w <= width
718
- line_chars.concat(chars)
726
+ line_glyphs.concat(glyphs)
719
727
  line_w += w
720
728
  else
721
- result << chars_to_styled(line_chars)
722
- line_chars = []
729
+ result << glyphs_to_styled(line_glyphs)
730
+ line_glyphs = []
723
731
  line_w = 0
724
732
  end
725
733
  elsif line_w + w <= width
726
- line_chars.concat(chars)
734
+ line_glyphs.concat(glyphs)
727
735
  line_w += w
728
736
  elsif w > width
729
- result << chars_to_styled(line_chars) unless line_w.zero?
730
- chunks = hard_break_chars(chars, width)
731
- chunks[0..-2].each { |chunk| result << chars_to_styled(chunk) }
732
- line_chars = chunks.last
733
- line_w = line_chars.sum { |triple| triple[2] }
737
+ result << glyphs_to_styled(line_glyphs) unless line_w.zero?
738
+ chunks = hard_break_glyphs(glyphs, width)
739
+ chunks[0..-2].each { |chunk| result << glyphs_to_styled(chunk) }
740
+ line_glyphs = chunks.last
741
+ line_w = line_glyphs.sum { |triple| triple[2] }
734
742
  else
735
- result << chars_to_styled(line_chars)
736
- line_chars = chars
743
+ result << glyphs_to_styled(line_glyphs)
744
+ line_glyphs = glyphs
737
745
  line_w = w
738
746
  end
739
747
  end
740
- result << chars_to_styled(line_chars)
748
+ result << glyphs_to_styled(line_glyphs)
741
749
  result
742
750
  end
743
751
 
752
+ # Splits into whitespace/word tokens by **grapheme cluster**, not character:
753
+ # a cluster is the unit a terminal draws, so measuring its parts separately
754
+ # would both mis-total an emoji sequence and let a wrap break a letter away
755
+ # from its combining mark.
744
756
  # @param hard_line [StyledString]
745
- # @return [Array<Array>] tokens shaped `[type, chars, w]` where `type` is
746
- # `:space` or `:word`, `chars` is an `Array<[String, Style, Integer]>`
747
- # (char, style, display width), and `w` is the token's total width.
757
+ # @return [Array<Array>] tokens shaped `[type, glyphs, w]` where `type` is
758
+ # `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
759
+ # (grapheme cluster, style, display width), and `w` is the token's total
760
+ # width.
748
761
  def tokenize_for_wrap(hard_line)
749
762
  tokens = []
750
- current_chars = []
763
+ current_glyphs = []
751
764
  current_w = 0
752
765
  current_type = nil
753
766
 
754
- hard_line.each_char_with_style do |c, s|
755
- type = [" ", "\t"].include?(c) ? :space : :word
756
- cw = Unicode::DisplayWidth.of(c)
767
+ each_glyph_with_style(hard_line) do |g, s|
768
+ type = [" ", "\t"].include?(g) ? :space : :word
769
+ gw = Buffer.display_width(g)
757
770
  if current_type && current_type != type
758
- tokens << [current_type, current_chars, current_w]
759
- current_chars = []
771
+ tokens << [current_type, current_glyphs, current_w]
772
+ current_glyphs = []
760
773
  current_w = 0
761
774
  end
762
775
  current_type = type
763
- current_chars << [c, s, cw]
764
- current_w += cw
776
+ current_glyphs << [g, s, gw]
777
+ current_w += gw
765
778
  end
766
- tokens << [current_type, current_chars, current_w] unless current_chars.empty?
779
+ tokens << [current_type, current_glyphs, current_w] unless current_glyphs.empty?
767
780
  tokens
768
781
  end
769
782
 
770
- # @param chars [Array<Array>] `[char, style, width]` triples.
783
+ # Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
784
+ # style boundary takes the style of its first span — pathological input, and
785
+ # splitting the cluster to honor both styles would paint a headless mark.
786
+ # @param styled [StyledString]
787
+ # @yieldparam glyph [String] one grapheme cluster.
788
+ # @yieldparam style [Style]
789
+ # @return [void]
790
+ def each_glyph_with_style(styled)
791
+ styled.spans.each do |span|
792
+ span.text.each_grapheme_cluster { |g| yield g, span.style }
793
+ end
794
+ end
795
+
796
+ # @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
771
797
  # @param width [Integer]
772
- # @return [Array<Array<Array>>] each inner Array is a `chars`-shaped chunk.
773
- def hard_break_chars(chars, width)
798
+ # @return [Array<Array<Array>>] each inner Array is a `glyphs`-shaped chunk.
799
+ def hard_break_glyphs(glyphs, width)
774
800
  chunks = []
775
801
  current = []
776
802
  current_w = 0
777
- chars.each do |triple|
803
+ glyphs.each do |triple|
778
804
  cw = triple[2]
779
805
  if current_w + cw > width && current_w.positive?
780
806
  chunks << current
@@ -788,20 +814,20 @@ module Tuile
788
814
  chunks
789
815
  end
790
816
 
791
- # @param chars [Array<Array>] `[char, style, width]` triples.
817
+ # @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
792
818
  # @return [StyledString]
793
- def chars_to_styled(chars)
794
- return self.class.new if chars.empty?
819
+ def glyphs_to_styled(glyphs)
820
+ return self.class.new if glyphs.empty?
795
821
 
796
822
  spans = []
797
823
  current_text = +""
798
- current_style = chars.first[1]
799
- chars.each do |c, s, _|
824
+ current_style = glyphs.first[1]
825
+ glyphs.each do |g, s, _|
800
826
  if s == current_style
801
- current_text << c
827
+ current_text << g
802
828
  else
803
829
  spans << Span.new(text: current_text, style: current_style)
804
- current_text = +c
830
+ current_text = +g
805
831
  current_style = s
806
832
  end
807
833
  end
@@ -809,6 +835,9 @@ module Tuile
809
835
  self.class.new(spans)
810
836
  end
811
837
 
838
+ # Walks **grapheme clusters**, so a slice boundary can never fall inside one:
839
+ # cutting a cluster would strand a combining mark with no base, which the
840
+ # painter drops outright, silently losing the accent off a letter.
812
841
  # @param text [String]
813
842
  # @param start_col [Integer]
814
843
  # @param len_col [Integer]
@@ -816,18 +845,18 @@ module Tuile
816
845
  def slice_text_by_columns(text, start_col, len_col)
817
846
  out = +""
818
847
  col = 0
819
- text.each_char do |c|
820
- cw = Unicode::DisplayWidth.of(c)
821
- char_end = col + cw
822
- if char_end <= start_col
848
+ text.each_grapheme_cluster do |g|
849
+ gw = Buffer.display_width(g)
850
+ glyph_end = col + gw
851
+ if glyph_end <= start_col
823
852
  # entirely before slice — skip
824
853
  elsif col >= start_col + len_col
825
854
  break
826
- elsif col >= start_col && char_end <= start_col + len_col
827
- out << c
855
+ elsif col >= start_col && glyph_end <= start_col + len_col
856
+ out << g
828
857
  end
829
- # any other case = partial overlap with a wide char — drop
830
- col = char_end
858
+ # any other case = partial overlap with a wide glyph — drop
859
+ col = glyph_end
831
860
  end
832
861
  out
833
862
  end
data/lib/tuile/theme.rb CHANGED
@@ -1,67 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tuile
4
- # A set of semantic colors the built-in components read when painting.
5
- # The current theme lives at {Screen#theme}; components must look it up
6
- # at paint time (inside `repaint`) rather than caching values, so that
7
- # assigning {Screen#theme=} restyles everything via a single
8
- # invalidate-everything pass.
4
+ # A set of semantic colors the built-in components read when painting. The
5
+ # current theme lives at {Screen#theme}; components must look it up at paint
6
+ # time (inside `repaint`) rather than caching values, so a {Screen#theme=}
7
+ # restyles everything via one invalidate-everything pass. Book ch6 is the
8
+ # concept in full (why accents-only, dark/light, live OS flips).
9
9
  #
10
- # The primary API is the rendering helpers — {#active_bg},
11
- # {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
12
- # the token's SGR color (on the channel appropriate for the token's
13
- # role) and reset:
10
+ # The rendering helpers — {#active_bg}, {#active_border}, {#input_bg},
11
+ # {#hint} — wrap a plain string in the token's SGR color (on the channel
12
+ # appropriate for the token's role) and reset:
14
13
  #
15
14
  # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
16
15
  # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
17
16
  #
18
- # The helpers pass content through verbatim, so input may carry other
19
- # escape sequences (e.g. {Component::Window} feeds its border string,
20
- # cursor moves included). For span-aware styling — applying a token to a
21
- # {StyledString} while preserving per-span colors — use the `*_color`
22
- # readers instead (e.g. {Component::List} highlights its cursor row via
23
- # `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
24
- # helper; structured text → `*_color` reader + {StyledString}.
17
+ # Content passes through verbatim (so it may carry other escapes). For
18
+ # span-aware styling — a token applied to a {StyledString} without flattening
19
+ # its per-span colors — use the `*_color` readers instead
20
+ # (`with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome → helper;
21
+ # structured text → `*_color` reader + {StyledString}.
25
22
  #
26
- # Two built-in themes are provided: {DARK} (the default; the colors Tuile
27
- # has always used) and {LIGHT} (counterparts legible on light terminal
28
- # backgrounds). A custom theme is one `with` away:
23
+ # Two built-in themes ship: {DARK} (default) and {LIGHT}. A custom one is one
24
+ # `with` away, and every token must be a {Color} instance — not the lenient
25
+ # {Color.coerce} forms, since a theme is declared once so the verbosity
26
+ # self-documents:
29
27
  #
30
28
  # screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
31
29
  #
32
- # Tokens deliberately cover only the *accents* Tuile paints. Everything
33
- # else inherits the terminal's own default foreground/background, which
34
- # already matches the user's terminal theme perfectly — that's why there
35
- # is no global `bg`/`fg` token.
36
- #
37
- # Every token is a {Color} — and must be passed as one. Unlike the
38
- # lenient {Color.coerce} call sites elsewhere in the framework, a theme
39
- # is declared once per app, so it takes only {Color} instances: at a
40
- # declaration site `Color.palette(130)` documents itself in a way the
41
- # bare `130` does not (palette index? RGB channel?) — and the named
42
- # palette constants (`Color::DARK_ORANGE3` *is* 130; see
43
- # {Color::PALETTE_NAMES}) go one step further.
44
- #
45
30
  # ## App-specific tokens
46
31
  #
47
- # Beyond the built-in tokens, an app can carry its own colors in
48
- # {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
49
- # {#[]} (fail-fast: a typo raises `KeyError`) and render with the
50
- # generic {#fg} / {#bg} helpers:
32
+ # An app carries its own colors in {#custom} (frozen `Hash{Symbol => Color}`).
33
+ # Look them up with {#[]} (fail-fast on typos) and render with the generic
34
+ # {#fg} / {#bg} helpers; subclass for semantic readers (`Data#with` keeps the
35
+ # subclass). Pair dark/light variants in a {ThemeDef} for {Screen#theme_def=}.
51
36
  #
52
37
  # theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
53
38
  # theme[:accent] # => Color, e.g. for StyledString#with_fg
54
39
  # theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
55
40
  #
56
- # Apps wanting semantic readers can subclass — `Data#with` preserves the
57
- # subclass, so an `AppTheme` stays an `AppTheme` through `with`:
58
- #
59
41
  # class AppTheme < Tuile::Theme
60
42
  # def accent(text) = fg(:accent, text)
61
43
  # end
62
44
  #
63
- # Pair the dark and light variants in a {ThemeDef} and hand it to
64
- # {Screen#theme_def=} so OS appearance flips pick the right one.
45
+ # For a color slot resolved *live* at paint — currently
46
+ # {Component#bg_color=} — assign a {Ref} instead of reading + rebuilding
47
+ # the token in {Component#on_theme_changed}; it tracks theme swaps on its
48
+ # own. Baked content colors ({Component::Label} text and friends) can't:
49
+ # they live in a frozen {StyledString} and still need the hook.
65
50
  #
66
51
  # @!attribute [r] active_bg_color
67
52
  # Background highlight of the component the user is interacting with:
@@ -117,6 +102,58 @@ module Tuile
117
102
  # loudly, not paint in a default.
118
103
  def [](token) = custom.fetch(token)
119
104
 
105
+ # The built-in chrome color tokens — every {Data} member bar {#custom}. A
106
+ # {Ref} resolves a name in this set as the chrome color; anything else as a
107
+ # {#custom} token.
108
+ # @return [Array<Symbol>]
109
+ CHROME_TOKENS = (members - %i[custom]).freeze
110
+
111
+ # @param name [Symbol] a token name.
112
+ # @return [Boolean] true iff `name` is a built-in chrome token (see
113
+ # {CHROME_TOKENS}) rather than a {#custom} one.
114
+ def self.chrome_token?(name) = CHROME_TOKENS.include?(name)
115
+
116
+ # Builds a {Ref} — a live theme reference for a late-resolved color slot
117
+ # like {Component#bg_color=}. Sugar for `Theme::Ref.new(name)`.
118
+ # @param name [Symbol] a built-in chrome token ({#input_bg_color} etc.) or
119
+ # a {#custom} token name.
120
+ # @return [Ref]
121
+ def self.ref(name) = Ref.new(name)
122
+
123
+ # A live reference to a theme token, resolved against the current theme at
124
+ # paint time rather than baked to a concrete {Color}. Assign one where a
125
+ # slot is resolved late — currently {Component#bg_color=} — and it follows
126
+ # light/dark flips with no {Component#on_theme_changed} hook:
127
+ #
128
+ # panel.bg_color = Tuile::Theme.ref(:panel_bg) # a #custom token
129
+ # dropdown.bg_color = Tuile::Theme.ref(:input_bg_color) # built-in chrome
130
+ #
131
+ # The name may be a built-in chrome token ({CHROME_TOKENS}) or a {#custom}
132
+ # one; a chrome name takes precedence on the (pathological) collision. This
133
+ # does *not* add a global bg/fg token — it only lets a slot point at a
134
+ # color the theme *already* carries, resolved the same way framework chrome
135
+ # already resolves it.
136
+ #
137
+ # Distinct from {Color.coerce}'s symbol support, which names one of the 16
138
+ # ANSI colors and yields a fixed {Color}; a Ref names a *theme* token and
139
+ # re-reads it each paint.
140
+ #
141
+ # Immutable.
142
+ class Ref < Data.define(:name)
143
+ # Resolves to the concrete {Color} `name` maps to in `theme` — a built-in
144
+ # chrome reader when `name` is one ({Theme.chrome_token?}), else a
145
+ # {#custom} token.
146
+ # @param theme [Theme]
147
+ # @return [Color]
148
+ # @raise [KeyError] when `name` is neither a chrome token nor a {#custom}
149
+ # token in `theme`.
150
+ def resolve(theme)
151
+ return theme.public_send(name) if Theme.chrome_token?(name)
152
+
153
+ theme[name]
154
+ end
155
+ end
156
+
120
157
  # Renders `text` in the foreground color of the app-specific `token`
121
158
  # — the generic counterpart of {#hint} for {#custom} tokens.
122
159
  # @param token [Symbol]