tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/buffer.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Tuile
4
4
  # An in-memory grid of styled cells mirroring the terminal screen. This is
5
5
  # the back buffer behind flicker-free rendering: components paint into it
6
- # (via {#set_text} / {#set_char} / {#fill}) instead of writing escape
6
+ # through a {Canvas}, whose {Canvas::Backend} it is, instead of writing escape
7
7
  # sequences straight to the terminal, and {#flush} emits the minimal escape
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
@@ -34,6 +34,10 @@ module Tuile
34
34
  # Overwriting either half of a wide glyph blanks the orphaned half, so the
35
35
  # grid never holds a dangling continuation or a headless one.
36
36
  class Buffer
37
+ # The three primitives below are exactly {Canvas::Backend}'s, so a canvas
38
+ # paints into a buffer with no adapter between them.
39
+ include Canvas::Backend
40
+
37
41
  # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
38
42
  # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
39
43
  # tracking") — the grid rewrites cells in place. A continuation cell (right
@@ -317,6 +321,13 @@ module Tuile
317
321
  region_cells(rect).map { |row| row.map(&:grapheme).join }
318
322
  end
319
323
 
324
+ # {#region_text} over the whole buffer, for one {Testing.paint} returned:
325
+ #
326
+ # Testing.paint(window).text # => ["┌Caption─────┐", "│ alpha │", …]
327
+ #
328
+ # @return [Array<String>] the plain text of every row, top to bottom.
329
+ def text = region_text(Rect.new(0, 0, @width, @height))
330
+
320
331
  # @param rect [Rect]
321
332
  # @return [Array<String>] each row within `rect` rendered to ANSI, top to
322
333
  # bottom — byte-identical to what a component's per-row `set_text` over
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Canvas
5
+ # Where a {Canvas}'s cells land: three primitives taking a fully resolved
6
+ # style. Include it to *be* one — {Tuile::Buffer} does, and needs no adapter:
7
+ #
8
+ # Canvas.new(screen.buffer)
9
+ #
10
+ # A backend is dumb by contract: no theme, no component, no background
11
+ # chain, no translation, and no default arguments — the canvas resolves all
12
+ # of that and hands down a whole style at a coordinate in the backend's own
13
+ # grid. What varies here is only *where* the cells go;
14
+ # how a write is transformed on the way there belongs to the {Canvas}, which
15
+ # is final. See `D_canvas`.
16
+ #
17
+ # **`include`, never `prepend`.** A class's own methods win over an included
18
+ # module's, which is what lets {Tuile::Buffer} carry this while defining all
19
+ # three itself; prepended, these bodies would sit *ahead* of the class and
20
+ # every write would raise.
21
+ #
22
+ # Out-of-range writes are **dropped, not raised** — the terminal-edge clip
23
+ # belongs here, which is what lets a component paint a row the terminal is
24
+ # too narrow for without checking first.
25
+ module Backend
26
+ # @param x [Integer] starting column, in this backend's own grid —
27
+ # the canvas has already applied its {Canvas#origin}.
28
+ # @param y [Integer] row, likewise.
29
+ # @param styled [StyledString] the text of one row; newlines are not handled.
30
+ # @return [void]
31
+ def set_text(x, y, styled) = raise(NotImplementedError, "#{self.class}#set_text")
32
+
33
+ # @param x [Integer] column, in this backend's own grid.
34
+ # @param y [Integer] row, likewise.
35
+ # @param grapheme [String] one grapheme cluster.
36
+ # @param style [StyledString::Style] fully resolved.
37
+ # @return [void]
38
+ def set_char(x, y, grapheme, style) = raise(NotImplementedError, "#{self.class}#set_char")
39
+
40
+ # @param rect [Rect] in this backend's own grid.
41
+ # @param style [StyledString::Style] fully resolved; only its `bg` shows.
42
+ # @return [void]
43
+ def fill(rect, style) = raise(NotImplementedError, "#{self.class}#fill")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # The paint context a component draws through: a {Backend} that says where
5
+ # cells land, plus the state every write needs — the {#origin} that puts
6
+ # `(0, 0)` at the component's own top-left, and the background to fill in
7
+ # behind content that carries none.
8
+ #
9
+ # A component never builds one. It paints onto the canvas its
10
+ # {Component#repaint} was handed, already loaded with that component's
11
+ # {ComponentBackground#effective} and positioned at its {Component#rect}, and
12
+ # derives a second for the cells that are not its own ink:
13
+ #
14
+ # def repaint(canvas)
15
+ # canvas.set_text(0, 0, label) # my well
16
+ # canvas.with(bg_color: bg.ambient) { _1.fill(tail) } # not my ink
17
+ # end
18
+ #
19
+ # Three methods, in **paint coordinates**: `(0, 0)` is the component's own
20
+ # top-left, not the screen's, and {Component#local_rect} is its whole rect
21
+ # over here. Passing {Component#rect} instead lands the write at twice the
22
+ # offset, silently. Everything outside painting stays screen-space — `rect`,
23
+ # {Mouse::Event}, {Component#cursor_position} — which `D_canvas` argues.
24
+ #
25
+ # A `nil` {#bg_color} is the terminal default, never "inherit" — inheritance
26
+ # is resolved before a canvas is built ({Screen#canvas_for}).
27
+ #
28
+ # == Implementation details
29
+ #
30
+ # Frozen, and final by convention: {#with} yields a *derived* canvas and never
31
+ # mutates, so there are no save/restore pairs and no state to leave dangling
32
+ # for whatever paints next. Subclassing is not the extension point — {Backend}
33
+ # is, and it is the half that varies. See `D_canvas`.
34
+ #
35
+ # UI-thread-confined.
36
+ class Canvas
37
+ # @return [Backend] where the cells land.
38
+ attr_reader :backend
39
+
40
+ # @return [Color, nil] the background painted behind content that states
41
+ # none; `nil` leaves the terminal default showing.
42
+ attr_reader :bg_color
43
+
44
+ # Where this canvas's `(0, 0)` sits in {#backend} coordinates — the
45
+ # component's {Component#rect}`.top_left`, as {Screen#canvas_for} built it.
46
+ # A component never reads it: adding the offset back by hand is the one
47
+ # thing it exists to make unnecessary.
48
+ # @return [Point]
49
+ attr_reader :origin
50
+
51
+ # The region of the {#backend}'s grid this canvas may write to —
52
+ # {Screen#clip_for}'s answer moved into backend coordinates, or `nil` for a
53
+ # canvas nobody bounded, which costs one test per write and nothing else.
54
+ # Every canvas {Screen#canvas_for} builds carries one; {Screen#canvas}, the
55
+ # root, is the `nil` case.
56
+ #
57
+ # In **backend** coordinates, like {#origin} and unlike every argument the
58
+ # three paint methods take: a canvas's *state* says where it sits in the
59
+ # world, its arguments are in paint coordinates (`D_clip`).
60
+ #
61
+ # An {Rect#empty? empty} clip is not `nil`: it means *paint nothing*, and it
62
+ # is what a component that can show nothing gets — collapsed, or scrolled
63
+ # clean out of its viewport. Every write is judged against the clip on its
64
+ # own terms, so this needs no special case; it is simply the case where they
65
+ # all fail.
66
+ #
67
+ # The one cell it does not protect: {Buffer#put_char} blanks the head of a
68
+ # wide glyph whose continuation half a clipped write overwrites, one column
69
+ # outside. That is the terminal's physical truth, and better than the
70
+ # dangling half-glyph the alternative leaves.
71
+ # @return [Rect, nil]
72
+ attr_reader :clip
73
+
74
+ # @param backend [Backend]
75
+ # @param bg_color [Color, nil] already resolved — a canvas consults no
76
+ # component and no theme.
77
+ # @param origin [Point] the {#origin}; the default paints in backend
78
+ # coordinates, which is what {Screen#canvas} is.
79
+ # @param clip [Rect, nil] the {#clip}, in backend coordinates —
80
+ # {Screen#canvas_for} converts.
81
+ # @raise [Error] if `backend` does not include {Backend}, which is worth
82
+ # catching here rather than mid-paint.
83
+ def initialize(backend, bg_color: nil, origin: Point::ZERO, clip: nil)
84
+ raise Error, "#{backend.class} must include Tuile::Canvas::Backend" unless backend.is_a?(Backend)
85
+
86
+ @backend = backend
87
+ @bg_color = bg_color
88
+ @origin = origin
89
+ @clip = clip
90
+ @blank_style = bg_color ? StyledString::Style.new(bg: bg_color) : StyledString::Style::DEFAULT
91
+ freeze
92
+ end
93
+
94
+ # Yields a canvas onto the same backend with a different background, for the
95
+ # span of the block — the *only* way to change it.
96
+ #
97
+ # canvas.with(bg_color: bg.ambient) do |c|
98
+ # c.fill(right)
99
+ # c.fill(below)
100
+ # end
101
+ #
102
+ # The receiver is untouched, so nothing has to be restored afterwards and
103
+ # the block cannot leave the wrong background on for whatever paints next.
104
+ # The {#origin} and the {#clip} ride along, so a derived canvas paints in
105
+ # the same coordinates and is bounded the same way.
106
+ # @param bg_color [Color, nil] the background inside the block.
107
+ # @return [Object] the block's value.
108
+ # @raise [Error] if no block is given — a derived canvas nobody scoped is
109
+ # the dangling state this shape exists to make impossible.
110
+ def with(bg_color:)
111
+ raise Error, "Canvas#with needs a block: with(bg_color:) { |canvas| … }" unless block_given?
112
+ return yield self if bg_color == @bg_color
113
+
114
+ yield Canvas.new(@backend, bg_color:, origin: @origin, clip: @clip)
115
+ end
116
+
117
+ # Writes a {StyledString}, filling {#bg_color} behind any span that states
118
+ # no background of its own — so an inherited tint, or an invalid field's
119
+ # error well, shows through content the component did not colour.
120
+ # @param x [Integer] starting column, relative to {#origin}.
121
+ # @param y [Integer] row, relative to {#origin}.
122
+ # @param styled [StyledString] the text of one row; newlines are not handled.
123
+ # @return [void]
124
+ def set_text(x, y, styled)
125
+ col = x + @origin.x
126
+ row = y + @origin.y
127
+ return @backend.set_text(col, row, styled.under_bg(@bg_color)) if @clip.nil?
128
+ return unless clipped_row?(row)
129
+
130
+ write_clipped_text(col, row, styled)
131
+ end
132
+
133
+ # {#set_text}'s single-grapheme counterpart.
134
+ # @param x [Integer] column, relative to {#origin}.
135
+ # @param y [Integer] row, relative to {#origin}.
136
+ # @param grapheme [String] one grapheme cluster.
137
+ # @param style [StyledString::Style] its `bg`, when set, wins over {#bg_color}.
138
+ # @return [void]
139
+ def set_char(x, y, grapheme, style = StyledString::Style::DEFAULT)
140
+ style = style.merge(bg: @bg_color) if @bg_color && style.bg.nil?
141
+ col = x + @origin.x
142
+ row = y + @origin.y
143
+ return @backend.set_char(col, row, grapheme, style) if @clip.nil?
144
+ return unless clipped_row?(row)
145
+
146
+ # A zero-width cluster still lands in one cell, and that cell is what the
147
+ # clip judges.
148
+ width = [Buffer.display_width(grapheme), 1].max
149
+ from = [col, @clip.left].max
150
+ to = [col + width, @clip.left + @clip.width].min
151
+ return if to <= from
152
+
153
+ # Half of a wide glyph is unrenderable, so the columns the clip keeps are
154
+ # blanked instead — {Buffer#put_char}'s own policy at the terminal's edge.
155
+ return @backend.fill(Rect.new(from, row, to - from, 1), @blank_style) if to - from < width
156
+
157
+ @backend.set_char(col, row, grapheme, style)
158
+ end
159
+
160
+ # Blanks `area` to {#bg_color}.
161
+ #
162
+ # Only for cells nothing is about to paint over: {Buffer::Cell#set} dirties
163
+ # on a real change, so blanking a cell that is then redrawn re-emits it.
164
+ # @param area [Rect] relative to {#origin} — {Component#local_rect} for the
165
+ # whole of a component, never its {Component#rect}.
166
+ # @return [void]
167
+ def fill(area)
168
+ # The early-out {#set_text} gets from `clipped_row?`: an empty clip keeps
169
+ # no cell at all, so neither rectangle below is worth building.
170
+ return if @clip && @clip.empty?
171
+
172
+ area = area.moved_by(@origin)
173
+ @backend.fill(@clip.nil? ? area : area.intersect(@clip), @blank_style)
174
+ end
175
+
176
+ private
177
+
178
+ # @param row [Integer] a row in backend coordinates.
179
+ # @return [Boolean] whether {#clip} keeps it. Callers have already checked
180
+ # that there *is* a clip.
181
+ def clipped_row?(row) = row >= @clip.top && row < @clip.top + @clip.height
182
+
183
+ # {#set_text} for the case that has to think: cut `styled` to the clip's
184
+ # columns and write what survived where it really belongs.
185
+ #
186
+ # {StyledString#slice} **drops** a cluster the boundary falls inside rather
187
+ # than splitting one, at the start as readily as at the end — so the kept
188
+ # text can begin a column later than the cut asked for, and the write
189
+ # position is derived from it rather than assumed. The column a dropped
190
+ # cluster half-covered is blanked (`D_clip`).
191
+ # @param col [Integer] starting column, in backend coordinates.
192
+ # @param row [Integer] row, likewise; the caller has checked the clip keeps it.
193
+ # @param styled [StyledString] the text of one row, uncoloured as handed in.
194
+ # @return [void]
195
+ def write_clipped_text(col, row, styled)
196
+ right = @clip.left + @clip.width
197
+ width = styled.display_width
198
+ from = [col, @clip.left].max
199
+ to = [col + width, right].min
200
+ return if to <= from
201
+
202
+ kept = col < @clip.left ? styled.slice(@clip.left - col, width) : styled
203
+ start = col + width - kept.display_width
204
+ kept = kept.slice(0, right - start) if start + kept.display_width > right
205
+ stop = start + kept.display_width
206
+
207
+ @backend.fill(Rect.new(from, row, start - from, 1), @blank_style) if start > from
208
+ @backend.fill(Rect.new(stop, row, to - stop, 1), @blank_style) if to > stop
209
+ @backend.set_text(start, row, kept.under_bg(@bg_color)) unless kept.empty?
210
+ end
211
+ end
212
+ end
data/lib/tuile/color.rb CHANGED
@@ -122,6 +122,27 @@ module Tuile
122
122
  # @return [Symbol, Integer, Array<Integer>]
123
123
  attr_reader :value
124
124
 
125
+ # This color's red, green and blue, for an app doing color math — a
126
+ # contrast check, a tint derived from {Screen#background_color}:
127
+ #
128
+ # Color.hex("#ff6400").rgb # => [255, 100, 0]
129
+ # Color::DEEP_SKY_BLUE1.rgb # => [0, 175, 255], palette cell 39
130
+ # Color::MAGENTA.rgb # => nil, the terminal's scheme decides
131
+ # Color.palette(5).rgb # => nil, likewise
132
+ #
133
+ # `nil` for the 16 named colors, in either spelling, because the scheme
134
+ # remaps them and any answer would be a guess. Indices 16..255 answer
135
+ # xterm's cube and grey ramp: a terminal *may* redefine those too (OSC 4),
136
+ # but in practice none does.
137
+ #
138
+ # @return [Array<Integer>, nil] frozen, each channel 0..255.
139
+ def rgb
140
+ case @value
141
+ when Array then @value
142
+ when Integer then PALETTE_RGB[@value]
143
+ end
144
+ end
145
+
125
146
  # SGR parameter codes for emitting this color as either a foreground
126
147
  # (`target: :fg`) or background (`target: :bg`). Returned as an array so
127
148
  # callers can splice them into a multi-attribute SGR (e.g. bold + color).
@@ -214,17 +235,12 @@ module Tuile
214
235
 
215
236
  private
216
237
 
217
- # This color's RGB — the palette cell's own coordinates when the value is
218
- # an index. Only ever asked of a non-Symbol value; a named color has no
219
- # RGB of its own, since the terminal's scheme decides what it looks like.
238
+ # {#rgb}, except an index 0..15 answers xterm's default for it — the guess
239
+ # `:ansi16` quantization needs something to match against, and {#rgb}
240
+ # must not hand out. Only ever asked of a non-Symbol value.
220
241
  # @return [Array<Integer>] red, green and blue, each 0..255.
221
242
  def rgb_triple
222
- return @value if @value.is_a?(Array)
223
- return ANSI16_RGB[@value] if @value < 16
224
- return [8 + (10 * (@value - 232))] * 3 if @value >= 232
225
-
226
- cube = @value - 16
227
- [CUBE_LEVELS[cube / 36], CUBE_LEVELS[(cube / 6) % 6], CUBE_LEVELS[cube % 6]]
243
+ rgb || ANSI16_RGB[@value]
228
244
  end
229
245
 
230
246
  # Written flat — destructured rather than splatted, `x * x` rather than
@@ -293,6 +309,19 @@ module Tuile
293
309
  CUBE_INDEX = Array.new(256) { |c| (0...6).min_by { |i| (CUBE_LEVELS[i] - c).abs } }.freeze
294
310
  private_constant :CUBE_INDEX
295
311
 
312
+ # Palette index 0..255 → its RGB, frozen, or nil for 0..15 — what {#rgb}
313
+ # answers for an index, precomputed so it allocates nothing.
314
+ # @return [Array<Array<Integer>, nil>]
315
+ PALETTE_RGB = Array.new(256) do |index|
316
+ if index < 16 then nil
317
+ elsif index >= 232 then ([8 + (10 * (index - 232))] * 3).freeze
318
+ else
319
+ cube = index - 16
320
+ [CUBE_LEVELS[cube / 36], CUBE_LEVELS[(cube / 6) % 6], CUBE_LEVELS[cube % 6]].freeze
321
+ end
322
+ end.freeze
323
+ private_constant :PALETTE_RGB
324
+
296
325
  # xterm's default RGB for each of the 16 named colors, in {COLOR_SYMBOLS}
297
326
  # order — what `:ansi16` quantization matches against. A terminal scheme
298
327
  # may redefine these; see {#quantize}.
@@ -9,9 +9,9 @@ module Tuile
9
9
  # drag this String-typed `text`/`value` seam onto its face alongside the
10
10
  # real typed one.
11
11
  #
12
- # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
13
- # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
14
- # that single-line and multi-line inputs both need: ESC handling,
12
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index, the
13
+ # {#on_escape} slot — and the keyboard machinery that single-line and
14
+ # multi-line inputs both need: ESC handling,
15
15
  # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, CTRL+W
16
16
  # word-delete, and the `tab_stop?` flag (`focusable?` comes from
17
17
  # {HasValue}).
@@ -21,14 +21,14 @@ module Tuile
21
21
  # forward onto the enclosing cluster's end, and every edit steps by a whole
22
22
  # cluster:
23
23
  #
24
- # f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
24
+ # f.value = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
25
25
  # f.caret = 1 # into the middle of the e-acute …
26
26
  # f.caret # => 2, its end — where the caret already drew
27
27
  # f.handle_key?(Keys::BACKSPACE)
28
28
  # f.text # => "x": the whole glyph went, not its accent
29
29
  #
30
30
  # Insertion stays character-native, so `String#insert` merges a typed
31
- # combining mark into its base; {#text=}'s snap covers the case where that
31
+ # combining mark into its base; {#set_value}'s snap covers the case where that
32
32
  # re-segments the text around the caret.
33
33
  #
34
34
  # Subclasses implement the layout-specific pieces ({#cursor_position},
@@ -66,13 +66,15 @@ module Tuile
66
66
  # share one slot, and a filter written on a *key* callback let the same
67
67
  # characters in through a paste (`D_input_filters`, book ch7).
68
68
  #
69
- # The mutation pipeline is a template method: {#text=} and {#caret=}
70
- # detect no-ops, mutate state, fire {#on_change}, and invalidate.
69
+ # The mutation pipeline is a template method: {#set_value} and {#caret=}
70
+ # detect no-ops, mutate state, fire {HasValue#on_value_change}, and invalidate.
71
+ # Typing, a paste and the deleting keys write with `from_user: true`, every
72
+ # other write with `false` ({HasValue::ValueChangeEvent#from_user?}).
71
73
  # Subclasses inject their own behavior via four protected hooks:
72
74
  #
73
75
  # - {#insert_text} — **the one filter seam**: every insertion runs through
74
76
  # it, typed or pasted, so what the buffer may hold is decided here.
75
- # - {#preprocess_text} — filter for a whole assignment to {#text=},
77
+ # - {#preprocess_text} — filter for a whole assignment to {#set_value},
76
78
  # which insertion does *not* pass through.
77
79
  # - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
78
80
  # clipboard reaches {#insert_text} ({TextField} keeps its first line).
@@ -86,24 +88,41 @@ module Tuile
86
88
  super
87
89
  @text = +""
88
90
  @caret = 0
89
- @on_change = nil
90
- @on_value_change = nil
91
- @on_escape = method(:default_on_escape)
91
+ @escape_clears_focus = true
92
+ # Unconditional on purpose: a field used as the face of a composed one
93
+ # ({ComboBox}, {IntegerField} …) is *told* to drop its well with
94
+ # {ComponentBackground::INHERIT} — a second well would make the
95
+ # composer's own bg_color inert over the very cells this field paints.
96
+ bg.default_color = ComponentBackground::INPUT_WELL
92
97
  end
93
98
 
94
99
  # @return [String] current text contents.
95
100
  attr_reader :text
96
101
 
97
- # A text component's value *is* its text: {#value}/{#value=} are the
98
- # {HasValue} seam over the same buffer as {#text}/{#text=}, so a form can
99
- # drive it alongside typed fields. `text` stays the text-native name.
102
+ # A text component's value *is* its text: {#value} reads the same buffer
103
+ # as {#text}, and {#set_value} is its one writer.
100
104
  # @return [String]
101
105
  def value = text
102
106
 
107
+ # Replaces the text. Runs {#preprocess_text} first, then clamps the caret
108
+ # to the new text and snaps it onto a cluster boundary of it. Fires
109
+ # {HasValue#on_value_change} only on a real change — never on {#caret=}.
110
+ #
111
+ # field.value = "admin"
112
+ # field.caret = field.text.length # the caret is clamped, never moved to the end
113
+ #
103
114
  # @param new_value [String, #to_s]
115
+ # @param from_user [Boolean] see {HasValue#set_value}.
104
116
  # @return [void]
105
- def value=(new_value)
106
- self.text = new_value.to_s
117
+ def set_value(new_value, from_user:)
118
+ new_value = preprocess_text(new_value)
119
+ return if @text == new_value
120
+
121
+ @text = +new_value
122
+ @caret = snap_to_cluster(@caret.clamp(0, @text.length))
123
+ handle_text_mutated
124
+ invalidate
125
+ on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: @text, from_user:))
107
126
  end
108
127
 
109
128
  # `""` (not `nil`): a text field is empty when its buffer is blank.
@@ -114,39 +133,37 @@ module Tuile
114
133
  # and always on a grapheme-cluster boundary (see the class doc).
115
134
  attr_reader :caret
116
135
 
117
- # Optional callback fired whenever {#text} changes. Receives the new text
118
- # as a single argument. Not fired by {#caret=} (text unchanged) and not
119
- # fired when a setter is a no-op.
120
- # @return [Proc, Method, nil] one-arg callable, or nil.
121
- attr_accessor :on_change
122
-
123
- # Callback fired when ESC is pressed. Defaults to a closure that clears
124
- # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
125
- # of bubbling to the parent — and, in particular, instead of reaching the
126
- # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
127
- # to the parent again; set to any other callable to replace the default.
128
- # @return [Proc, Method, nil] no-arg callable, or nil.
129
- attr_accessor :on_escape
136
+ # What {#on_escape} fires.
137
+ #
138
+ # @!attribute [r] source
139
+ # @return [AbstractStringField] the field ESC reached.
140
+ EscapeEvent = Data.define(:source) { include Tuile::Event }
141
+
142
+ # @!method on_escape
143
+ # Fired with an {EscapeEvent} when ESC is pressed, after
144
+ # {#escape_clears_focus} has had its say. Append to react as well as
145
+ # blur; turn that flag off first to react *instead*:
146
+ #
147
+ # field.escape_clears_focus = false
148
+ # field.on_escape << method(:close_search)
149
+ #
150
+ # **Empty means the field declines ESC** — but only while
151
+ # {#escape_clears_focus} is off too, since a field that blurs has
152
+ # handled the key. With both, ESC bubbles to the parent, and on to the
153
+ # screen's ESC-to-quit.
154
+ # @return [Listeners]
155
+ listener :on_escape
156
+
157
+ # Whether ESC clears focus (`true` by default), so text entry visibly
158
+ # cancels instead of quitting the app. It runs before {#on_escape} fires,
159
+ # and it — not a listener an app must remove by identity — is the whole
160
+ # representation of the default, so turning it off is the one way to give
161
+ # ESC another meaning. See `D_escape_opt_out`.
162
+ # @return [Boolean]
163
+ attr_accessor :escape_clears_focus
130
164
 
131
165
  def tab_stop? = true
132
166
 
133
- # Sets the text. Runs {#preprocess_text} first (subclasses may filter or
134
- # truncate). Caret is clamped to the new text length, then snapped back
135
- # onto a cluster boundary of the *new* text. Fires {#on_change} only on a
136
- # real change.
137
- # @param new_text [String]
138
- def text=(new_text)
139
- new_text = preprocess_text(new_text)
140
- return if @text == new_text
141
-
142
- @text = +new_text
143
- @caret = snap_to_cluster(@caret.clamp(0, @text.length))
144
- handle_text_mutated
145
- invalidate
146
- @on_change&.call(@text)
147
- on_value_change&.call(@text)
148
- end
149
-
150
167
  # Clamps to `0..text.length`, then snaps forward onto a grapheme-cluster
151
168
  # boundary, so an index that fell inside a cluster reads back as that
152
169
  # cluster's end. Fires the {#handle_caret_mutated} hook for subclasses (e.g.
@@ -169,8 +186,9 @@ module Tuile
169
186
  # @return [Boolean]
170
187
  def handle_key?(key) = handle_text_input_key?(key)
171
188
 
172
- # Inserts pasted text at the caret as **one** mutation, so {#on_change}
173
- # fires once for the whole paste rather than once per character.
189
+ # Inserts pasted text at the caret as **one** mutation, so
190
+ # {HasValue#on_value_change} fires once for the whole paste rather than
191
+ # once per character.
174
192
  # {#preprocess_paste} filters it first.
175
193
  # @param text [String]
176
194
  # @return [void]
@@ -210,8 +228,10 @@ module Tuile
210
228
  # (a date — `"2020-13-45"` is well-formed at every character) reports bad
211
229
  # input rather than filtering it (`D_input_filters`, book ch7).
212
230
  #
213
- # {#text=} does *not* pass through here: only user input is filtered, so a
214
- # programmatic {HasValue#value=} may still write what no key types.
231
+ # {#set_value} does *not* pass through here: only user input is filtered,
232
+ # so a programmatic write may still hold what no key types. The same line
233
+ # decides the origin: an insertion is the user's, so it writes with
234
+ # `from_user: true`.
215
235
  # @param str [String]
216
236
  # @return [Boolean] true if the text changed.
217
237
  def insert_text(str)
@@ -219,25 +239,11 @@ module Tuile
219
239
 
220
240
  new_text = @text.dup.insert(@caret, str)
221
241
  @caret += str.length
222
- self.text = new_text
242
+ set_value(new_text, from_user: true)
223
243
  true
224
244
  end
225
245
 
226
- # The field's background well, looked up from the current {Screen#theme}
227
- # at paint time: {Theme#active_bg_color} while this input is on the active
228
- # (focus) chain, {Theme#input_bg_color} otherwise — visibly a field either
229
- # way, distinctly highlighted when focused. An app overrides the pair by
230
- # setting {Component#bg_color}, which wins over this.
231
- #
232
- # Unconditional on purpose. A field used as the face of a composed one
233
- # ({Component::ComboBox}, {Component::IntegerField} …) is *told* to drop
234
- # its well — that widget assigns {Component::BG_INHERIT} at construction,
235
- # since it owns the surface and a second well would make its own
236
- # {Component#bg_color} inert over the very cells this field paints.
237
- # @return [Color]
238
- def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
239
-
240
- # Input filter for a whole assignment to {#text=}. Nothing overrides it
246
+ # Input filter for a whole assignment to {#set_value}. Nothing overrides it
241
247
  # today; a subclass that does is filtering the *programmatic* setter, not
242
248
  # user input — that is {#insert_text}.
243
249
  # @param new_text [String]
@@ -254,8 +260,8 @@ module Tuile
254
260
  def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) }
255
261
 
256
262
  # Hook called after {#text} has been mutated, before invalidation /
257
- # {#on_change}. Default no-op. Subclasses use this to invalidate caches
258
- # ({TextArea}'s wrap cache) and update derived state.
263
+ # {HasValue#on_value_change}. Default no-op. Subclasses use this to
264
+ # invalidate caches ({TextArea}'s wrap cache) and update derived state.
259
265
  # @return [void]
260
266
  def handle_text_mutated; end
261
267
 
@@ -283,9 +289,10 @@ module Tuile
283
289
  when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
284
290
  when Keys::CTRL_W then delete_back_to(word_left)
285
291
  when Keys::ESC
286
- return false if @on_escape.nil?
292
+ return false if !@escape_clears_focus && on_escape.empty?
287
293
 
288
- @on_escape.call
294
+ screen.focused = nil if @escape_clears_focus
295
+ on_escape.fire(EscapeEvent.new(source: self))
289
296
  else
290
297
  return false
291
298
  end
@@ -299,7 +306,8 @@ module Tuile
299
306
  def delete_before_caret = delete_back_to(cluster_boundary_before(@caret))
300
307
 
301
308
  # Removes the text between `index` and the caret, leaving the caret at
302
- # `index` — one mutation, so {#on_change} fires once.
309
+ # `index` — one mutation, so {HasValue#on_value_change} fires once, as the
310
+ # user's: every caller is a deleting key.
303
311
  #
304
312
  # `index` is snapped forward onto a grapheme-cluster boundary, so a
305
313
  # caller may compute it by counting characters.
@@ -312,7 +320,7 @@ module Tuile
312
320
  new_text = @text.dup
313
321
  new_text.slice!(start...@caret)
314
322
  @caret = start
315
- self.text = new_text
323
+ set_value(new_text, from_user: true)
316
324
  end
317
325
 
318
326
  # Removes the whole grapheme cluster at the caret.
@@ -322,7 +330,7 @@ module Tuile
322
330
 
323
331
  new_text = @text.dup
324
332
  new_text.slice!(@caret...cluster_boundary_after(@caret))
325
- self.text = new_text
333
+ set_value(new_text, from_user: true)
326
334
  end
327
335
 
328
336
  private
@@ -366,13 +374,6 @@ module Tuile
366
374
  offset
367
375
  end
368
376
 
369
- # Default {#on_escape} action: clear focus. Component deactivates; user
370
- # can re-focus by clicking or tabbing back in.
371
- # @return [void]
372
- def default_on_escape
373
- screen.focused = nil
374
- end
375
-
376
377
  # Caret target for ctrl+left: skip whitespace going left, then a run of
377
378
  # non-whitespace. Lands at the beginning of the current word, or the
378
379
  # beginning of the previous word if already there.