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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- 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 +52 -51
- 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 -21
- 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/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- 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 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- 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 +2043 -614
- 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
|
-
#
|
|
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
|
#
|
|
@@ -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
|
|
122
|
-
#
|
|
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.
|
|
178
|
-
#
|
|
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
|
|
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:
|
|
355
|
-
#
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
#
|
|
359
|
-
#
|
|
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
|
-
|
|
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
|
|
455
|
-
#
|
|
456
|
-
#
|
|
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?
|
|
453
|
+
return unless in_bounds?(x, y) && @cells[index(x, y)].continuation?
|
|
462
454
|
|
|
463
|
-
|
|
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
|
-
#
|
|
467
|
-
#
|
|
468
|
-
#
|
|
469
|
-
#
|
|
470
|
-
#
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
#
|
|
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,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.
|
|
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
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 &&
|
|
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 = "[
|
|
74
|
-
|
|
75
|
-
|
|
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
|