tuile 0.9.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
@@ -0,0 +1,55 @@
1
+ # Per-component back buffers + a z-order compositor
2
+
3
+ **Status:** deferred, not worth doing yet. Graduated out of `Buffer`'s
4
+ rdoc (it was speculative design musing, not caller contract — see the
5
+ doc-kinds rules in AGENTS.md). Parked here so the thinking isn't lost.
6
+
7
+ ## The idea
8
+
9
+ Today there is one global {Tuile::Buffer}: every component paints into
10
+ the same grid via `set_line` / `set_char` / `fill`, and {Tuile::Screen}
11
+ flushes its minimal diff to the terminal.
12
+
13
+ Components already paint through that drawing surface *without knowing*
14
+ whether it's the one global buffer or a private one — the indirection is
15
+ deliberate. So per-component back buffers plus a z-order compositor could
16
+ drop in **without touching component code**: give each component its own
17
+ `Buffer`, let it paint into that, and have a compositor blend the
18
+ per-component buffers in stacking order into the frame the terminal sees.
19
+
20
+ ## Why it's not worth doing yet
21
+
22
+ The current single-buffer diff already captures most of the win a
23
+ compositor would give:
24
+
25
+ - The flush drops unchanged cells from the wire, so an unchanged region
26
+ costs nothing on the terminal side regardless of how many components
27
+ overlap it.
28
+ - An occluded component that didn't change is never repainted at all —
29
+ invalidation gates it out before `repaint` runs.
30
+
31
+ So a compositor would only save **residual `repaint` CPU**: the cost of a
32
+ component re-rendering its content into a buffer, when that content then
33
+ turns out to be occluded or unchanged after compositing.
34
+
35
+ ## The one regime where it pays off
36
+
37
+ High repeat-rate scroll — a held arrow key or a spun mouse wheel — over a
38
+ **large** component on a **large** screen, where re-rendering the
39
+ component's content on every repeat is the dominant cost. A per-component
40
+ buffer would let an unchanged-but-scrolled component be re-composited
41
+ (cheap) instead of re-rendered (expensive) each repeat.
42
+
43
+ Until Tuile has a real workload in that regime, the extra machinery
44
+ (per-component buffer allocation, a compositor pass, dirty propagation
45
+ across buffer layers) buys nothing over what the single-buffer diff
46
+ already delivers.
47
+
48
+ ## If we revisit
49
+
50
+ - The component-facing drawing API (`set_line` / `set_char` / `fill`)
51
+ stays the same — that's the whole point of the existing indirection.
52
+ - What changes is *who owns the buffer* and the addition of a
53
+ composite-into-frame step between `repaint` and `Buffer#flush`.
54
+ - Measure first: prove the residual-`repaint`-CPU regime is real and
55
+ material before adding a layer.
data/lib/tuile/buffer.rb CHANGED
@@ -21,17 +21,10 @@ module Tuile
21
21
  # size. There is deliberately no per-frame whole-buffer clear or copy;
22
22
  # un-touched cells retain the previous frame's value.
23
23
  #
24
- # The bookkeeping avoids hashing and full-grid scans: a dirty flag **on each
25
- # cell** (O(1) set, no `Set` bucket math, no separate array), a per-row
26
- # boolean so {#flush} scans only the rows that changed, and one global flag
27
- # so {#dirty?} and the "nothing changed" early-out are O(1). {#flush} clears
28
- # every flag it consumes.
29
- #
30
- # Cells are **mutable and pre-allocated**: the grid builds its {Cell}s once
24
+ # Cells are **mutable and pre-allocated** — the grid builds its {Cell}s once
31
25
  # (at construction and {#resize}) and rewrites them in place, so a normal
32
- # paint allocates nothing per cell. That is why {Cell} is a plain mutable
33
- # object rather than a frozen value type. The empty state of a cell is a
34
- # space in the default style.
26
+ # paint allocates nothing per cell. That's why {Cell} is a plain mutable
27
+ # object, not a frozen value type.
35
28
  #
36
29
  # ## Wide characters
37
30
  #
@@ -40,19 +33,6 @@ module Tuile
40
33
  # nothing for, since the glyph itself advances the cursor two columns).
41
34
  # Overwriting either half of a wide glyph blanks the orphaned half, so the
42
35
  # grid never holds a dangling continuation or a headless one.
43
- #
44
- # ## Future direction
45
- #
46
- # Components paint through this drawing surface ({#set_line} / {#set_char})
47
- # without knowing whether it is the one global buffer or a private one — that
48
- # indirection is deliberate, so per-component back buffers plus a z-order
49
- # compositor could drop in without touching component code. It is not worth
50
- # doing yet: the diff already drops unchanged cells from the wire, and an
51
- # occluded component that didn't change is never repainted at all, so a
52
- # compositor would only save residual `repaint` CPU. It pays off in exactly
53
- # one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
54
- # component on a large screen, where re-rendering the content each repeat is
55
- # the dominant cost.
56
36
  class Buffer
57
37
  # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
58
38
  # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
@@ -118,10 +98,15 @@ module Tuile
118
98
  # emoji), so the per-grapheme width lookup — the dominant cost of a repaint
119
99
  # (see `benchmark/display_width.rb`) — collapses to a Hash read after the
120
100
  # first sighting. Shared across all buffers and unbounded, but bounded in
121
- # practice by the font's glyph set; safe to share because all painting runs
122
- # on the single UI thread (see AGENTS.md "Threading rule").
101
+ # practice by the font's glyph set.
102
+ #
103
+ # The memo carries its weight most for emoji: resolving a sequence under
104
+ # {StyledString::EMOJI_WIDTH} costs ~20x a plain lookup, and this pays it
105
+ # once per distinct cluster. Racing writes from a non-UI thread are benign
106
+ # rather than merely absent — the value for a grapheme is deterministic, so
107
+ # a lost write only costs a recomputation.
123
108
  # @return [Hash{String => Integer}]
124
- WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g) }
109
+ WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g, emoji: StyledString::EMOJI_WIDTH) }
125
110
  private_constant :WIDTH_CACHE
126
111
 
127
112
  # Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
@@ -174,9 +159,8 @@ module Tuile
174
159
  end
175
160
 
176
161
  # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
177
- # display width and clipping at the right edge. The workhorse that replaces
178
- # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
179
- # paint. Newlines in the string are not handled — pass one physical line.
162
+ # display width and clipping at the right edge. Newlines are not handled —
163
+ # pass one physical line.
180
164
  # @param x [Integer] starting column.
181
165
  # @param y [Integer] row.
182
166
  # @param styled [StyledString]
@@ -346,21 +330,27 @@ module Tuile
346
330
  return unless in_bounds?(x, y)
347
331
  return if w <= 0
348
332
 
349
- if w == 2 && !in_bounds?(x + 1, y)
333
+ if w > 1 && !in_bounds?(x + w - 1, y)
350
334
  blank_left_partner(x, y)
351
335
  return write_cell(x, y, " ", style)
352
336
  end
353
337
 
354
- # Repair only the glyphs we'd leave half-overwritten on our flanks: a wide
355
- # glyph whose right half sits at `x`, or one whose left half sits at the
356
- # last cell we write. The cells we fully rewrite need no pre-blanking —
357
- # pre-blanking the continuation only to re-empty it would churn it
358
- # spuriously dirty, which misplaces the next flush onto the glyph's right
359
- # half (see bug/, balloon corruption).
338
+ # Repair only the glyphs we'd leave half-overwritten on our flanks: one
339
+ # whose tail reaches `x`, or one whose head sits at the last cell we write.
340
+ # The cells we fully rewrite need no pre-blanking — pre-blanking a
341
+ # continuation only to re-empty it would churn it spuriously dirty, which
342
+ # misplaces the next flush onto the glyph's right half (see bug/, balloon
343
+ # corruption).
360
344
  blank_left_partner(x, y)
361
345
  blank_right_partner(x + w - 1, y)
362
346
  write_cell(x, y, grapheme, style)
363
- write_cell(x + 1, y, "", style) if w == 2
347
+ # A while loop, not (1...w).each: this runs once per painted cell, and a
348
+ # Range allocation per cell is 8000 per full-screen repaint.
349
+ i = 1
350
+ while i < w
351
+ write_cell(x + i, y, "", style)
352
+ i += 1
353
+ end
364
354
  end
365
355
 
366
356
  # (Re)allocates a blank grid of `size` with clean dirty state. Callers
@@ -451,31 +441,42 @@ module Tuile
451
441
  @any_dirty = true
452
442
  end
453
443
 
454
- # If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
455
- # orphaned left half at `x - 1`. Called before a write lands on `x`, so the
456
- # wide glyph to the left isn't left headless.
444
+ # If `(x, y)` holds a continuation, blanks the head of the glyph it belongs to
445
+ # and every continuation up to — but not including — `x`. Called before a
446
+ # write lands on `x`, so the glyph reaching into `x` isn't left headless.
447
+ # Walks left rather than assuming the head sits at `x - 1`: a glyph may be
448
+ # wider than two columns, so its tail can run several cells.
457
449
  # @param x [Integer] column
458
450
  # @param y [Integer] row
459
451
  # @return [void]
460
452
  def blank_left_partner(x, y)
461
- return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? && in_bounds?(x - 1, y)
453
+ return unless in_bounds?(x, y) && @cells[index(x, y)].continuation?
462
454
 
463
- write_cell(x - 1, y, " ", DEFAULT_STYLE)
455
+ head = x - 1
456
+ head -= 1 while in_bounds?(head, y) && @cells[index(head, y)].continuation?
457
+ return unless in_bounds?(head, y)
458
+
459
+ cx = head
460
+ while cx < x
461
+ write_cell(cx, y, " ", DEFAULT_STYLE)
462
+ cx += 1
463
+ end
464
464
  end
465
465
 
466
- # If the cell just right of `(x, y)` is a continuation (the right half of a
467
- # wide glyph whose origin is `(x, y)`), blanks it. Called before a write
468
- # lands on `x`, so overwriting a wide origin doesn't strand its continuation.
469
- # A continuation can only ever belong to the wide glyph immediately to its
470
- # left, so the empty-grapheme test is exact — and cheaper than re-measuring
471
- # the origin's width.
466
+ # Blanks the run of continuations immediately right of `(x, y)` — the tail of
467
+ # a glyph whose head is at or before `x`, and which the write landing on `x`
468
+ # is about to decapitate. A continuation always belongs to the nearest glyph
469
+ # on its left, so the empty-grapheme test is exact — and cheaper than
470
+ # re-measuring that glyph's width.
472
471
  # @param x [Integer] column
473
472
  # @param y [Integer] row
474
473
  # @return [void]
475
474
  def blank_right_partner(x, y)
476
- return unless in_bounds?(x + 1, y) && @cells[index(x + 1, y)].continuation?
477
-
478
- write_cell(x + 1, y, " ", DEFAULT_STYLE)
475
+ cx = x + 1
476
+ while in_bounds?(cx, y) && @cells[index(cx, y)].continuation?
477
+ write_cell(cx, y, " ", DEFAULT_STYLE)
478
+ cx += 1
479
+ end
479
480
  end
480
481
  end
481
482
  end
data/lib/tuile/color.rb CHANGED
@@ -29,16 +29,10 @@ module Tuile
29
29
  # Color.coerce(nil) # nil → nil
30
30
  # ```
31
31
  #
32
- # Which entry point to use is a deliberate policy split. High-traffic
33
- # call sites ({StyledString} and friends) stay lenient and {.coerce} raw
34
- # forms — you don't want factory ceremony on every styled span.
35
- # Declaration sites ({Theme}, defined once per app) are strict and take
36
- # only {Color} instances, where `Color.palette(130)` documents itself in
37
- # a way the bare `130` (palette index? RGB channel?) does not.
38
- #
39
- # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
40
- # raw numeric codes so callers (notably {StyledString}) can combine them with
41
- # other SGR attributes in a single sequence.
32
+ # {.coerce} is the lenient entry point (raw forms plus `nil`); the named
33
+ # factories and constants are the strict, self-documenting path for
34
+ # declaration sites — see the book's chapter 6 for why theme colors take
35
+ # {Color} instances only.
42
36
  class Color
43
37
  # Symbolic color names. Order is significant: indices 0..7 map to the
44
38
  # standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to
@@ -2,13 +2,33 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # Abstract base for editable text components ({TextField}, {TextArea}).
5
+ # Abstract base for the **String-valued** editable text components
6
+ # ({TextField}, {TextArea}): a field whose {HasValue#value} *is* its text.
7
+ # A field whose value is a different type (an `Integer`, a domain object)
8
+ # *composes* one of these rather than subclassing it — subclassing would
9
+ # drag this String-typed `text`/`value` seam onto its face alongside the
10
+ # real typed one.
6
11
  #
7
12
  # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
8
13
  # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
9
14
  # that single-line and multi-line inputs both need: ESC handling,
10
15
  # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
11
- # `focusable?`/`tab_stop?` flags.
16
+ # `tab_stop?` flag (`focusable?` comes from {HasValue}).
17
+ #
18
+ # {#caret} counts *characters* into {#text} but may only sit *between*
19
+ # grapheme clusters — the glyphs a terminal draws. Both write sites snap it
20
+ # forward onto the enclosing cluster's end, and every edit steps by a whole
21
+ # cluster:
22
+ #
23
+ # f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
24
+ # f.caret = 1 # into the middle of the e-acute …
25
+ # f.caret # => 2, its end — where the caret already drew
26
+ # f.handle_key(Keys::BACKSPACE)
27
+ # f.text # => "x": the whole glyph went, not its accent
28
+ #
29
+ # Insertion stays character-native, so `String#insert` merges a typed
30
+ # combining mark into its base; {#text=}'s snap covers the case where that
31
+ # re-segments the text around the caret.
12
32
  #
13
33
  # Subclasses implement the layout-specific pieces ({#cursor_position},
14
34
  # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
@@ -25,12 +45,15 @@ module Tuile
25
45
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
26
46
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
27
47
  # keep the caret visible).
28
- class TextInput < Component
48
+ class AbstractStringField < Component
49
+ include HasValue
50
+
29
51
  def initialize
30
52
  super
31
53
  @text = +""
32
54
  @caret = 0
33
55
  @on_change = nil
56
+ @on_value_change = nil
34
57
  @on_key = nil
35
58
  @on_escape = method(:default_on_escape)
36
59
  end
@@ -38,10 +61,24 @@ module Tuile
38
61
  # @return [String] current text contents.
39
62
  attr_reader :text
40
63
 
41
- # @return [Boolean] true iff {#text} is the empty string.
42
- def empty? = @text.empty?
64
+ # A text component's value *is* its text: {#value}/{#value=} are the
65
+ # {HasValue} seam over the same buffer as {#text}/{#text=}, so a form can
66
+ # drive it alongside typed fields. `text` stays the text-native name.
67
+ # @return [String]
68
+ def value = text
43
69
 
44
- # @return [Integer] caret index in `0..text.length`.
70
+ # @param new_value [String, #to_s]
71
+ # @return [void]
72
+ def value=(new_value)
73
+ self.text = new_value.to_s
74
+ end
75
+
76
+ # `""` (not `nil`): a text field is empty when its buffer is blank.
77
+ # @return [String]
78
+ def empty_value = ""
79
+
80
+ # @return [Integer] caret index in `0..text.length`, counting characters
81
+ # and always on a grapheme-cluster boundary (see the class doc).
45
82
  attr_reader :caret
46
83
 
47
84
  # Optional callback fired whenever {#text} changes. Receives the new text
@@ -72,30 +109,32 @@ module Tuile
72
109
  # @return [Proc, Method, nil] no-arg callable, or nil.
73
110
  attr_accessor :on_escape
74
111
 
75
- def focusable? = true
76
-
77
112
  def tab_stop? = true
78
113
 
79
114
  # Sets the text. Runs {#preprocess_text} first (subclasses may filter or
80
- # truncate). Caret is clamped to the new text length. Fires {#on_change}
81
- # only on a real change.
115
+ # truncate). Caret is clamped to the new text length, then snapped back
116
+ # onto a cluster boundary of the *new* text. Fires {#on_change} only on a
117
+ # real change.
82
118
  # @param new_text [String]
83
119
  def text=(new_text)
84
120
  new_text = preprocess_text(new_text)
85
121
  return if @text == new_text
86
122
 
87
123
  @text = +new_text
88
- @caret = @caret.clamp(0, @text.length)
124
+ @caret = snap_to_cluster(@caret.clamp(0, @text.length))
89
125
  on_text_mutated
90
126
  invalidate
91
127
  @on_change&.call(@text)
128
+ on_value_change&.call(@text)
92
129
  end
93
130
 
94
- # Sets the caret position. Clamped to `0..text.length`. Fires
95
- # {#on_caret_mutated} hook for subclasses (e.g. {TextArea} scrolls).
131
+ # Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
132
+ # boundary, so an index that fell inside a cluster reads back as that
133
+ # cluster's end. Fires the {#on_caret_mutated} hook for subclasses (e.g.
134
+ # {TextArea} scrolls).
96
135
  # @param new_caret [Integer]
97
136
  def caret=(new_caret)
98
- new_caret = new_caret.clamp(0, @text.length)
137
+ new_caret = snap_to_cluster(new_caret.clamp(0, @text.length))
99
138
  return if @caret == new_caret
100
139
 
101
140
  @caret = new_caret
@@ -134,6 +173,15 @@ module Tuile
134
173
  # @return [String] possibly transformed text.
135
174
  def preprocess_text(new_text) = new_text.to_s
136
175
 
176
+ # The one measurement primitive both inputs share: a caret index counts
177
+ # characters, but every rect, cursor and click counts columns, and only
178
+ # this converts between them.
179
+ # @param str [String]
180
+ # @return [Integer] `str`'s width in terminal columns, measured per
181
+ # grapheme cluster — so a combining mark adds nothing and a fullwidth
182
+ # glyph adds two.
183
+ def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) }
184
+
137
185
  # Hook called after {#text} has been mutated, before invalidation /
138
186
  # {#on_change}. Default no-op. Subclasses use this to invalidate caches
139
187
  # ({TextArea}'s wrap cache) and update derived state.
@@ -148,7 +196,8 @@ module Tuile
148
196
 
149
197
  # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
150
198
  # that have identical semantics in single-line and multi-line inputs:
151
- # LEFT/RIGHT arrows, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
199
+ # LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
200
+ # moves), CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
152
201
  # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
153
202
  # DELETE, printable insertion) and call `super` to fall back to the
154
203
  # common navigation handling.
@@ -156,8 +205,8 @@ module Tuile
156
205
  # @return [Boolean] true if the key was handled.
157
206
  def handle_text_input_key(key)
158
207
  case key
159
- when Keys::LEFT_ARROW then self.caret = @caret - 1
160
- when Keys::RIGHT_ARROW then self.caret = @caret + 1
208
+ when Keys::LEFT_ARROW then self.caret = cluster_boundary_before(@caret)
209
+ when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret)
161
210
  when Keys::CTRL_LEFT_ARROW then self.caret = word_left
162
211
  when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
163
212
  when Keys::ESC
@@ -170,27 +219,71 @@ module Tuile
170
219
  true
171
220
  end
172
221
 
222
+ # Removes the whole grapheme cluster before the caret — one press, one
223
+ # glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
224
+ # Hangul syllable each go whole).
173
225
  # @return [void]
174
226
  def delete_before_caret
175
227
  return if @caret.zero?
176
228
 
229
+ start = cluster_boundary_before(@caret)
177
230
  new_text = @text.dup
178
- new_text.slice!(@caret - 1)
179
- @caret -= 1
231
+ new_text.slice!(start...@caret)
232
+ @caret = start
180
233
  self.text = new_text
181
234
  end
182
235
 
236
+ # Removes the whole grapheme cluster at the caret.
183
237
  # @return [void]
184
238
  def delete_at_caret
185
239
  return if @caret >= @text.length
186
240
 
187
241
  new_text = @text.dup
188
- new_text.slice!(@caret)
242
+ new_text.slice!(@caret...cluster_boundary_after(@caret))
189
243
  self.text = new_text
190
244
  end
191
245
 
192
246
  private
193
247
 
248
+ # @param index [Integer] a {#text} index in `0..text.length`.
249
+ # @return [Integer] the smallest grapheme-cluster boundary `>= index`.
250
+ def snap_to_cluster(index)
251
+ offset = 0
252
+ @text.each_grapheme_cluster do |g|
253
+ return offset if offset >= index
254
+
255
+ offset += g.length
256
+ end
257
+ offset
258
+ end
259
+
260
+ # @param index [Integer]
261
+ # @return [Integer] the greatest grapheme-cluster boundary `< index`, or
262
+ # 0 at the start of the text.
263
+ def cluster_boundary_before(index)
264
+ last = 0
265
+ offset = 0
266
+ @text.each_grapheme_cluster do |g|
267
+ offset += g.length
268
+ return last if offset >= index
269
+
270
+ last = offset
271
+ end
272
+ last
273
+ end
274
+
275
+ # @param index [Integer]
276
+ # @return [Integer] the smallest grapheme-cluster boundary `> index`, or
277
+ # `text.length` at the end of the text.
278
+ def cluster_boundary_after(index)
279
+ offset = 0
280
+ @text.each_grapheme_cluster do |g|
281
+ offset += g.length
282
+ return offset if offset > index
283
+ end
284
+ offset
285
+ end
286
+
194
287
  # Default {#on_escape} action: clear focus. Component deactivates; user
195
288
  # can re-focus by clicking or tabbing back in.
196
289
  # @return [void]
@@ -12,34 +12,26 @@ module Tuile
12
12
  # {Component#handle_mouse}.
13
13
  #
14
14
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
15
- # show `[ caption ]` — that natural width is `caption.length + 4`.
15
+ # show `[ caption ]` — that natural width is `caption.display_width + 4`.
16
+ # A narrower {#rect} truncates the label with an ellipsis; a wider one leaves
17
+ # a tail that focuses but doesn't activate (see {#extent}).
16
18
  class Button < Component
17
- # @param caption [String] the button's label.
19
+ include Component::HasCaption
20
+
21
+ # @param caption [String, StyledString, nil] the button's label, coerced
22
+ # the same way {HasCaption#caption=} coerces it.
18
23
  # @yield optional `on_click` callback; same as assigning {#on_click=}.
19
- def initialize(caption = "", &on_click)
24
+ def initialize(caption = nil, &on_click)
20
25
  super()
21
- @caption = caption.to_s
26
+ self.caption = caption
22
27
  @on_click = on_click
23
28
  end
24
29
 
25
- # @return [String] the button's label.
26
- attr_reader :caption
27
-
28
30
  # Callback fired when the button is activated (Enter, Space, or
29
31
  # left-click). The callable receives no arguments.
30
32
  # @return [Proc, Method, nil] no-arg callable, or nil.
31
33
  attr_accessor :on_click
32
34
 
33
- # Sets a new caption and invalidates the button. No-op if unchanged.
34
- # @param new_caption [String]
35
- def caption=(new_caption)
36
- new_caption = new_caption.to_s
37
- return if @caption == new_caption
38
-
39
- @caption = new_caption
40
- invalidate
41
- end
42
-
43
35
  def focusable? = true
44
36
 
45
37
  def tab_stop? = true
@@ -56,11 +48,23 @@ module Tuile
56
48
  end
57
49
  end
58
50
 
51
+ # The cells the button actually paints: one row, `caption.display_width + 4`
52
+ # columns, clipped to {#rect}. Both the focus highlight and the click hit
53
+ # test use it, so a click on the blank tail of an over-wide rect — or on a
54
+ # lower row, when the rect is taller than one — does not fire {#on_click}.
55
+ # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
56
+ # by geometry. Same rule as {Checkbox#extent}, which documents the two
57
+ # traps behind it.
58
+ # @return [Rect]
59
+ def extent = Rect.new(rect.left, rect.top, [caption.display_width + 4, rect.width].min, 1)
60
+
61
+ # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
62
+ # a click anywhere in {#rect} still focuses.
59
63
  # @param event [MouseEvent]
60
64
  # @return [void]
61
65
  def handle_mouse(event)
62
66
  super
63
- return unless event.button == :left && rect.contains?(event.point)
67
+ return unless event.button == :left && extent.contains?(event.point)
64
68
 
65
69
  @on_click&.call
66
70
  end
@@ -70,9 +74,9 @@ module Tuile
70
74
  super
71
75
  return if rect.empty?
72
76
 
73
- label = "[ #{@caption} ]"[0, rect.width]
74
- styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
75
- screen.buffer.set_line(rect.left, rect.top, styled)
77
+ label = (StyledString.plain("[ ") + caption + StyledString.plain(" ]")).ellipsize(rect.width)
78
+ label = label.with_bg(screen.theme.active_bg_color) if active?
79
+ draw_line(rect.left, rect.top, label)
76
80
  end
77
81
  end
78
82
  end