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
@@ -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
@@ -8,7 +8,7 @@ module Tuile
8
8
  # string needed to bring a terminal — one that already matches the buffer's
9
9
  # state as of the previous flush — up to date. Only cells that actually
10
10
  # changed are emitted, so nothing flickers regardless of terminal/multiplexer
11
- # synchronized-output support. See `ideas/back-buffer.md`.
11
+ # synchronized-output support.
12
12
  #
13
13
  # Coordinates are 0-based `(x, y)` = `(column, row)`, matching
14
14
  # {Component#rect} and `TTY::Cursor.move_to`.
@@ -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
  #
@@ -100,6 +93,28 @@ module Tuile
100
93
  DEFAULT_STYLE = StyledString::Style::DEFAULT
101
94
  private_constant :DEFAULT_STYLE
102
95
 
96
+ # Memo for {.display_width}: a grapheme's display width is fixed, and a TTY
97
+ # paints from a small, recurring alphabet (ASCII, box-drawing rules, a few
98
+ # emoji), so the per-grapheme width lookup — the dominant cost of a repaint
99
+ # (see `benchmark/display_width.rb`) — collapses to a Hash read after the
100
+ # first sighting. Shared across all buffers and unbounded, but bounded in
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.
108
+ # @return [Hash{String => Integer}]
109
+ WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g, emoji: StyledString::EMOJI_WIDTH) }
110
+ private_constant :WIDTH_CACHE
111
+
112
+ # Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
113
+ # lookup instead of calling the gem directly.
114
+ # @param grapheme [String] one grapheme cluster.
115
+ # @return [Integer] its display width in columns (0 for combining marks).
116
+ def self.display_width(grapheme) = WIDTH_CACHE[grapheme]
117
+
103
118
  # @param size [Size] grid dimensions in columns × rows.
104
119
  def initialize(size)
105
120
  allocate_grid(size)
@@ -140,26 +155,12 @@ module Tuile
140
155
  # @param style [StyledString::Style]
141
156
  # @return [void]
142
157
  def set_char(x, y, grapheme, style = DEFAULT_STYLE)
143
- return unless in_bounds?(x, y)
144
-
145
- w = Unicode::DisplayWidth.of(grapheme)
146
- return if w <= 0
147
-
148
- if w == 2 && !in_bounds?(x + 1, y)
149
- repair_orphans(x, y)
150
- return write_cell(x, y, " ", style)
151
- end
152
-
153
- repair_orphans(x, y)
154
- repair_orphans(x + 1, y) if w == 2
155
- write_cell(x, y, grapheme, style)
156
- write_cell(x + 1, y, "", style) if w == 2
158
+ put_char(x, y, grapheme, Buffer.display_width(grapheme), style)
157
159
  end
158
160
 
159
161
  # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
160
- # display width and clipping at the right edge. The workhorse that replaces
161
- # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
162
- # 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.
163
164
  # @param x [Integer] starting column.
164
165
  # @param y [Integer] row.
165
166
  # @param styled [StyledString]
@@ -168,12 +169,13 @@ module Tuile
168
169
  col = x
169
170
  styled.spans.each do |span|
170
171
  span.text.grapheme_clusters.each do |g|
171
- w = Unicode::DisplayWidth.of(g)
172
+ w = Buffer.display_width(g)
172
173
  next if w <= 0 # combining mark with no base in this run: skip
173
174
 
174
175
  break if col >= @width # rest of the line is clipped
175
176
 
176
- set_char(col, y, g, span.style)
177
+ # Reuse the width we just computed: put_char skips re-measuring `g`.
178
+ put_char(col, y, g, w, span.style)
177
179
  col += w
178
180
  end
179
181
  end
@@ -313,6 +315,44 @@ module Tuile
313
315
 
314
316
  private
315
317
 
318
+ # Core of {#set_char} with the grapheme's display width already known.
319
+ # {#set_line} computes each width once while advancing the column and passes
320
+ # it straight through, so the paint hot path measures every grapheme exactly
321
+ # once (and that once is a {.display_width} memo read). See {#set_char} for
322
+ # the wide-glyph / clipping / out-of-bounds contract.
323
+ # @param x [Integer] column.
324
+ # @param y [Integer] row.
325
+ # @param grapheme [String] one grapheme cluster.
326
+ # @param w [Integer] `grapheme`'s display width (0, 1, or 2).
327
+ # @param style [StyledString::Style]
328
+ # @return [void]
329
+ def put_char(x, y, grapheme, w, style)
330
+ return unless in_bounds?(x, y)
331
+ return if w <= 0
332
+
333
+ if w > 1 && !in_bounds?(x + w - 1, y)
334
+ blank_left_partner(x, y)
335
+ return write_cell(x, y, " ", style)
336
+ end
337
+
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).
344
+ blank_left_partner(x, y)
345
+ blank_right_partner(x + w - 1, y)
346
+ write_cell(x, y, grapheme, style)
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
354
+ end
355
+
316
356
  # (Re)allocates a blank grid of `size` with clean dirty state. Callers
317
357
  # follow with {#mark_all_dirty} when the terminal doesn't match the new
318
358
  # grid — construction and {#resize} both do.
@@ -342,11 +382,19 @@ module Tuile
342
382
  c = @cells[base + x]
343
383
  if c.dirty
344
384
  c.dirty = false
345
- unless run_open
346
- out << TTY::Cursor.move_to(x, y)
347
- run_open = true
348
- end
385
+ # A continuation cell (right half of a wide glyph) renders nothing of
386
+ # its own and must never open a run: positioning the cursor onto its
387
+ # column would land the next glyph on the wide glyph's left half,
388
+ # corrupting it. When the wide glyph itself is dirty it is emitted from
389
+ # its origin cell and advances the cursor across this column; when the
390
+ # glyph is intact this column needs no output at all. (A continuation
391
+ # can be left spuriously dirty by an in-place wide-glyph repaint, so we
392
+ # can't assume its origin was emitted in this same run.)
349
393
  unless c.continuation?
394
+ unless run_open
395
+ out << TTY::Cursor.move_to(x, y)
396
+ run_open = true
397
+ end
350
398
  out << style.sgr_to(c.style) << c.grapheme
351
399
  style = c.style
352
400
  end
@@ -393,19 +441,41 @@ module Tuile
393
441
  @any_dirty = true
394
442
  end
395
443
 
396
- # If `(x, y)` is half of a wide glyph, blanks the *other* half, so a write
397
- # that lands on either half doesn't strand the remaining one.
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.
398
449
  # @param x [Integer] column
399
450
  # @param y [Integer] row
400
451
  # @return [void]
401
- def repair_orphans(x, y)
402
- return unless in_bounds?(x, y)
452
+ def blank_left_partner(x, y)
453
+ return unless in_bounds?(x, y) && @cells[index(x, y)].continuation?
454
+
455
+ head = x - 1
456
+ head -= 1 while in_bounds?(head, y) && @cells[index(head, y)].continuation?
457
+ return unless in_bounds?(head, y)
403
458
 
404
- c = @cells[index(x, y)]
405
- if c.continuation?
406
- write_cell(x - 1, y, " ", DEFAULT_STYLE) if in_bounds?(x - 1, y)
407
- elsif Unicode::DisplayWidth.of(c.grapheme) == 2 && in_bounds?(x + 1, y)
408
- write_cell(x + 1, y, " ", DEFAULT_STYLE)
459
+ cx = head
460
+ while cx < x
461
+ write_cell(cx, y, " ", DEFAULT_STYLE)
462
+ cx += 1
463
+ end
464
+ end
465
+
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.
471
+ # @param x [Integer] column
472
+ # @param y [Integer] row
473
+ # @return [void]
474
+ def blank_right_partner(x, y)
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
409
479
  end
410
480
  end
411
481
  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,36 +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 ]`; {#content_size} reports that natural width.
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
- self.content_size = natural_size
24
28
  end
25
29
 
26
- # @return [String] the button's label.
27
- attr_reader :caption
28
-
29
30
  # Callback fired when the button is activated (Enter, Space, or
30
31
  # left-click). The callable receives no arguments.
31
32
  # @return [Proc, Method, nil] no-arg callable, or nil.
32
33
  attr_accessor :on_click
33
34
 
34
- # Sets a new caption and invalidates the button. No-op if unchanged.
35
- # @param new_caption [String]
36
- def caption=(new_caption)
37
- new_caption = new_caption.to_s
38
- return if @caption == new_caption
39
-
40
- @caption = new_caption
41
- invalidate
42
- self.content_size = natural_size
43
- end
44
-
45
35
  def focusable? = true
46
36
 
47
37
  def tab_stop? = true
@@ -58,11 +48,23 @@ module Tuile
58
48
  end
59
49
  end
60
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.
61
63
  # @param event [MouseEvent]
62
64
  # @return [void]
63
65
  def handle_mouse(event)
64
66
  super
65
- return unless event.button == :left && rect.contains?(event.point)
67
+ return unless event.button == :left && extent.contains?(event.point)
66
68
 
67
69
  @on_click&.call
68
70
  end
@@ -72,16 +74,10 @@ module Tuile
72
74
  super
73
75
  return if rect.empty?
74
76
 
75
- label = "[ #{@caption} ]"[0, rect.width]
76
- styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
77
- 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)
78
80
  end
79
-
80
- private
81
-
82
- # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
83
- # @return [Size]
84
- def natural_size = Size.new(@caption.length + 4, 1)
85
81
  end
86
82
  end
87
83
  end