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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- 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.
|
|
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
|
-
#
|
|
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
|
|
33
|
-
# object
|
|
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
|
-
|
|
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.
|
|
161
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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)`
|
|
397
|
-
#
|
|
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
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
write_cell(
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
|
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
|
-
# `
|
|
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
|
|
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
|
-
#
|
|
42
|
-
|
|
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
|
-
# @
|
|
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
|
|
81
|
-
# only on a
|
|
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
|
-
#
|
|
95
|
-
#
|
|
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,
|
|
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
|
|
160
|
-
when Keys::RIGHT_ARROW then self.caret = @caret
|
|
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!(
|
|
179
|
-
@caret
|
|
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 ]
|
|
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
|
-
|
|
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 =
|
|
24
|
+
def initialize(caption = nil, &on_click)
|
|
20
25
|
super()
|
|
21
|
-
|
|
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 &&
|
|
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 = "[
|
|
76
|
-
|
|
77
|
-
|
|
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
|