tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -2,23 +2,76 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A single-line text input field with hardware-cursor caret.
5
+ # A single-line text input with a real hardware caret, scrolling
6
+ # horizontally to keep that caret in view:
6
7
  #
7
- # The field does not scroll. Any keystroke that would make {#text} longer
8
- # than `rect.width - 1` (the last column is reserved for the caret past the
9
- # last char) is rejected.
8
+ # f = TextField.new
9
+ # f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
10
+ # f.text = "hello world" # … eleven columns of text, so it scrolls
11
+ # f.caret = 11 # paints "world " — left_column 6, cursor on the last column
12
+ # f.caret = 0 # paints "hello " — left_column 0
10
13
  #
11
- # The caret is a logical index in `0..text.length`. The hardware cursor is
12
- # positioned by {Screen} after each repaint cycle when this component is
13
- # focused; see {Component#cursor_position}.
14
- class TextField < TextInput
14
+ # The field's width never bounds its contents — {#max_text_length} does, and
15
+ # only for typing.
16
+ #
17
+ # == Implementation details
18
+ #
19
+ # Two axes run through this class and are *not* interchangeable:
20
+ #
21
+ # - an **index** counts characters into {#text} — {#caret},
22
+ # {#max_text_length}, `text[i]`, every edit;
23
+ # - a **column** counts terminal cells — {#rect}, {#left_column},
24
+ # {#cursor_position}, a {MouseEvent}.
25
+ #
26
+ # They coincide only while every glyph is one column wide. A fullwidth CJK
27
+ # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
28
+ # column 6. Every crossing goes through the private `column_at` / `index_at`
29
+ # pair; adding an index to a column anywhere else is the bug those two exist
30
+ # to prevent.
31
+ #
32
+ # Indices count characters while widths measure grapheme clusters, but the
33
+ # caret never falls between the two: {AbstractStringField} keeps it on a
34
+ # cluster boundary, so a column derived from it always names a real glyph
35
+ # edge.
36
+ #
37
+ # What gets *painted* is {#display_text}, a third seam that is `text` itself
38
+ # here and the mask in {PasswordField}. Every column measurement reads it, so
39
+ # a subclass showing something else overrides that and never {#repaint} —
40
+ # overriding the paint alone leaves the measurements on the buffer while the
41
+ # cells show the substitute, and the two drift apart by a growing offset.
42
+ class TextField < AbstractStringField
15
43
  def initialize
16
44
  super
45
+ @left_column = 0
46
+ @max_text_length = nil
17
47
  @on_key_up = nil
18
48
  @on_key_down = nil
19
49
  @on_enter = nil
20
50
  end
21
51
 
52
+ # Optional cap on {#text}'s length **in characters** — a wide glyph counts
53
+ # once. Typing into a field already at the cap does nothing.
54
+ #
55
+ # Deliberately does not police {#text=}: lowering the cap under an existing
56
+ # value leaves that value intact rather than silently trimming it.
57
+ # @return [Integer, nil] maximum characters, or nil for unbounded (default).
58
+ attr_reader :max_text_length
59
+
60
+ # @param max [Integer, nil]
61
+ # @return [void]
62
+ # @raise [TypeError] unless `max` is an Integer or nil.
63
+ # @raise [ArgumentError] if `max` is negative.
64
+ def max_text_length=(max)
65
+ raise TypeError, "expected Integer or nil, got #{max.inspect}" unless max.nil? || max.is_a?(Integer)
66
+ raise ArgumentError, "expected a non-negative max, got #{max}" if max&.negative?
67
+
68
+ @max_text_length = max
69
+ end
70
+
71
+ # @return [Integer] text column drawn in the field's leftmost cell — the
72
+ # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
73
+ attr_reader :left_column
74
+
22
75
  # Optional callback fired when the UP arrow key is pressed. When set, UP
23
76
  # is consumed by the field; when nil, UP falls through to the parent
24
77
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -43,37 +96,33 @@ module Tuile
43
96
  def cursor_position
44
97
  return nil unless rect.width.positive?
45
98
 
46
- Point.new(rect.left + @caret, rect.top)
99
+ # Scrolling already keeps the caret inside the rect, so the cap is a
100
+ # guard rather than a policy: a cursor parked at rect.left + rect.width
101
+ # reads as column 0 of the next row on an auto-wrapping terminal.
102
+ offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1)
103
+ Point.new(rect.left + offset, rect.top)
47
104
  end
48
105
 
106
+ # Places the caret at the clicked column. A click on the right half of a
107
+ # wide glyph lands *after* it, as in any editor.
49
108
  # @param event [MouseEvent]
50
109
  # @return [void]
51
110
  def handle_mouse(event)
52
111
  super
53
112
  return unless event.button == :left && rect.contains?(event.point)
54
113
 
55
- self.caret = (event.x - rect.left).clamp(0, @text.length)
114
+ self.caret = index_at(event.x - rect.left + @left_column)
56
115
  end
57
116
 
58
117
  # @return [void]
59
118
  def repaint
60
119
  return if rect.empty?
61
120
 
62
- padded = @text + (" " * (rect.width - @text.length))
63
- screen.buffer.set_line(rect.left, rect.top, background(padded))
121
+ screen.buffer.set_line(rect.left, rect.top, background(visible_text))
64
122
  end
65
123
 
66
124
  protected
67
125
 
68
- # Truncate to fit `rect.width - 1` — single-line fields can't grow past
69
- # their width.
70
- # @param new_text [String]
71
- # @return [String]
72
- def preprocess_text(new_text)
73
- new_text = new_text.to_s
74
- new_text.length > max_text_length ? new_text[0, max_text_length] : new_text
75
- end
76
-
77
126
  # @param key [String]
78
127
  # @return [Boolean]
79
128
  def handle_text_input_key(key)
@@ -102,32 +151,121 @@ module Tuile
102
151
  true
103
152
  end
104
153
 
154
+ # @return [void]
155
+ def on_text_mutated
156
+ adjust_left_column
157
+ end
158
+
159
+ # @return [void]
160
+ def on_caret_mutated
161
+ adjust_left_column
162
+ end
163
+
105
164
  # @return [void]
106
165
  def on_width_changed
107
166
  super
108
- return if @text.length <= max_text_length
109
-
110
- @text = @text[0, [max_text_length, 0].max]
111
- @caret = @caret.clamp(0, @text.length)
112
- @on_change&.call(@text)
167
+ adjust_left_column
113
168
  end
114
169
 
115
- private
170
+ # What the field paints in place of {#text}: one display character per
171
+ # {#text} character, in order. `column_at` measures `display_text[0, i]` as
172
+ # the rendering of `text[0, i]`, so an override that changes the character
173
+ # count — or reorders — desynchronizes the caret from the display. Nothing
174
+ # enforces it at runtime; a subclass pins it with a spec.
175
+ # @return [String] {#text} itself, unless a subclass substitutes.
176
+ def display_text = @text
116
177
 
117
- # Maximum number of characters {#text} can hold given current width.
118
- # @return [Integer]
119
- def max_text_length = (rect.width - 1).clamp(0, nil)
178
+ private
120
179
 
121
180
  # @param char [String]
122
- # @return [Boolean]
181
+ # @return [Boolean] always true — a field at {#max_text_length} swallows the
182
+ # key rather than declining it, so typing can never fall through to a
183
+ # scope-wide binding.
123
184
  def insert(char)
124
- return false if @text.length >= max_text_length
185
+ return true if @max_text_length && @text.length >= @max_text_length
125
186
 
126
187
  new_text = @text.dup.insert(@caret, char)
127
188
  @caret += 1
128
189
  self.text = new_text
129
190
  true
130
191
  end
192
+
193
+ # @param index [Integer] a {#text} index in `0..text.length`.
194
+ # @return [Integer] the column it sits at. An index landing inside a
195
+ # grapheme cluster measures the whole cluster, putting the caret just
196
+ # past it.
197
+ def column_at(index) = columns_of(display_text[0, index] || "")
198
+
199
+ # @param column [Integer] a text column (0 is the first glyph).
200
+ # @return [Integer] the nearest {#text} index — a column falling in a wide
201
+ # glyph's right half resolves past it.
202
+ def index_at(column)
203
+ col = 0
204
+ i = 0
205
+ display_text.each_grapheme_cluster do |g|
206
+ w = Buffer.display_width(g)
207
+ return i if column < col + ((w + 1) / 2)
208
+
209
+ col += w
210
+ i += g.length
211
+ end
212
+ i
213
+ end
214
+
215
+ # @return [Integer] total display width of {#text}.
216
+ def text_columns = column_at(@text.length)
217
+
218
+ # @return [String] the windowed text, padded with spaces to `rect.width`.
219
+ # A wide glyph straddling the right edge is dropped rather than painted
220
+ # as a half glyph.
221
+ def visible_text
222
+ right = @left_column + rect.width
223
+ visible = +""
224
+ width = 0
225
+ col = 0
226
+ display_text.each_grapheme_cluster do |g|
227
+ start = col
228
+ col += Buffer.display_width(g)
229
+ next if start < @left_column
230
+ break if col > right
231
+
232
+ visible << g
233
+ width = col - @left_column
234
+ end
235
+ visible << (" " * (rect.width - width))
236
+ end
237
+
238
+ # Scrolls the minimum needed to keep the caret's column visible.
239
+ # @return [void]
240
+ def adjust_left_column
241
+ return unless rect.width.positive?
242
+
243
+ col = column_at(@caret)
244
+ @left_column = col if col < @left_column
245
+ @left_column = col - rect.width + 1 if col > @left_column + rect.width - 1
246
+ # The caret may park one column past the last glyph, so the scrollable
247
+ # range runs one column past the text.
248
+ @left_column = snap_to_glyph_start(@left_column.clamp(0, [text_columns - rect.width + 1, 0].max))
249
+ end
250
+
251
+ # @param column [Integer]
252
+ # @return [Integer] the smallest glyph-boundary column `>= column`, so the
253
+ # window never opens on a wide glyph's right half.
254
+ #
255
+ # Snapping *right* is the only safe direction, and not because it shows
256
+ # more: the caret's own column is always a glyph boundary, so the next
257
+ # boundary at or after `left_column` can never overshoot it. Snapping left
258
+ # instead pulls the window's right edge inward, which strands the caret
259
+ # outside it whenever wide glyphs exactly fill a narrow field.
260
+ def snap_to_glyph_start(column)
261
+ col = 0
262
+ display_text.each_grapheme_cluster do |g|
263
+ return col if col >= column
264
+
265
+ col += Buffer.display_width(g)
266
+ end
267
+ col
268
+ end
131
269
  end
132
270
  end
133
271
  end
@@ -3,31 +3,25 @@
3
3
  module Tuile
4
4
  class Component
5
5
  # A read-only viewer for prose: chunks of formatted text that scroll
6
- # vertically. Shape-wise a hybrid between {Label} (string-shaped content
7
- # via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
6
+ # vertically. Shape-wise a hybrid between {Label} (string content via
7
+ # {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
8
8
  #
9
- # Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
10
- # lines wider than the viewport are word-wrapped via {StyledString#wrap}
11
- # (style spans are preserved across wrap boundaries — unlike the older
12
- # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
13
- # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
14
- # so embedded ANSI is honored) or a {StyledString} directly; {#text}
15
- # always returns the {StyledString}.
9
+ # Text is a {StyledString}: embedded `\n` are hard line breaks, longer lines
10
+ # are word-wrapped via {StyledString#wrap} with style spans preserved across
11
+ # wrap boundaries. {#text=} takes a {String} (parsed via {StyledString.parse},
12
+ # honoring embedded ANSI) or a {StyledString}; {#text} always returns the
13
+ # {StyledString}.
16
14
  #
17
- # For incremental updates pick the right primitive: {#append} (aliased
18
- # as `<<`) is verbatim and stream-friendly — chunks are concatenated
19
- # straight onto the buffer, with embedded `\n` becoming hard breaks.
20
- # {#add_line} is the "log entry" convenience — it starts the content on
21
- # a fresh line by inserting a leading `\n` when the buffer is non-empty.
22
- # {#remove_last_n_lines} pops hard lines back off the tail — the
23
- # inverse of building up a region with {#append} / {#add_line}, so a
24
- # caller streaming reformattable content (e.g. partially-rendered
25
- # Markdown that may need to retract its last paragraph) can replace
26
- # the tail without rewriting the whole text. Turn on {#auto_scroll}
27
- # to keep the latest content in view.
15
+ # Pick the right incremental primitive: {#append} (aliased `<<`) concatenates
16
+ # a chunk verbatim onto the buffer (stream-friendly, `\n` → hard breaks);
17
+ # {#add_line} starts the chunk on a fresh line (the "log entry" convenience);
18
+ # {#remove_last_n_lines} pops hard lines off the tail, so a caller streaming
19
+ # reformattable content can retract and rewrite it; {#replace} / {#insert}
20
+ # splice a range in place. Turn on {#auto_scroll} to keep the latest content
21
+ # in view.
28
22
  #
29
- # TextView is meant to be the content of a {Window} — focus indication and
30
- # keyboard-hint surfacing rely on the surrounding window chrome.
23
+ # Meant to be the content of a {Window} — focus indication and keyboard-hint
24
+ # surfacing rely on the surrounding window chrome.
31
25
  class TextView < Component
32
26
  def initialize
33
27
  super
@@ -55,21 +49,14 @@ module Tuile
55
49
  @auto_scroll = false
56
50
  @follow = true
57
51
  @scrollbar_visibility = :gone
58
- # The view always has at least one region — an implicit default. It
59
- # owns whatever hard lines exist that no later region claims. App
60
- # code that never calls {#create_region} sees the same behavior as
61
- # before (a single region containing everything); apps that do call
62
- # {#create_region} stack additional regions at the spatial tail.
52
+ # Always ≥1 region; the implicit default owns any hard lines no
53
+ # app-created region claims. See {Region}.
63
54
  @regions = [Region.send(:new, self)]
64
55
  end
65
56
 
66
- # @return [StyledString] the current text. Defaults to an empty
67
- # {StyledString}. Internally the text is stored as an array of hard
68
- # lines so {#append} can stay O(appended) instead of re-scanning the
69
- # whole buffer; the joined {StyledString} returned here is
70
- # reconstructed on first read after a mutation and cached, so
71
- # repeated reads are O(1) but the first read after {#append} pays
72
- # O(total spans).
57
+ # @return [StyledString] the current text (empty by default). Rebuilt
58
+ # lazily on the first read after a mutation (O(total spans)), then
59
+ # cached — repeated reads are O(1).
73
60
  def text
74
61
  @text ||= build_text
75
62
  end
@@ -144,19 +131,12 @@ module Tuile
144
131
  # @return [Boolean] true iff {#text} is empty (no hard lines).
145
132
  def empty? = @hard_lines.empty?
146
133
 
147
- # Appends `str` verbatim. Embedded `\n` characters become hard line
148
- # breaks; otherwise the text is concatenated onto the current last
149
- # hard line. Designed for streaming use (e.g. an LLM chat window
150
- # receiving partial messages — feed each chunk straight in). Accepts
151
- # the same input forms as {#text=}; empty/`nil` input is a no-op.
152
- #
153
- # For the "add an entry on a new line" pattern use {#add_line}.
154
- #
155
- # Cost is O(appended + width-of-current-last-hard-line) — the
156
- # previously last hard line is re-wrapped (because the extension may
157
- # cause it to wrap differently), any additional hard lines created by
158
- # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
159
- # and rebuilt on demand.
134
+ # Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
135
+ # the text is concatenated onto the current last hard line. Designed for
136
+ # streaming use (feed each partial chunk straight in). Accepts the same
137
+ # input forms as {#text=}; empty/`nil` is a no-op. For the "entry on a new
138
+ # line" pattern use {#add_line}. Cost is O(appended + width of the last
139
+ # hard line), which is re-wrapped since the extension may wrap differently.
160
140
  # @param str [String, StyledString, nil]
161
141
  # @return [void]
162
142
  def append(str)
@@ -221,19 +201,11 @@ module Tuile
221
201
  end
222
202
  end
223
203
 
224
- # Drops the last `n` hard lines from the buffer. The inverse of
225
- # building up a tail region with {#append} / {#add_line}: a caller
226
- # streaming partially-rendered content whose tail must occasionally
227
- # be retracted (e.g. Markdown-to-ANSI where a new token reformats
228
- # the table being built) can call `remove_last_n_lines(k)` followed
229
- # by `append(new_tail)` to replace the damaged region in place.
230
- #
231
- # `n == 0` and the empty-buffer case are no-ops (no invalidation).
232
- # `n >= hard-line count` empties the buffer.
233
- #
234
- # Operates on **hard lines** (the `\n`-delimited entries the
235
- # buffer stores), not on wrapped physical rows — same granularity
236
- # as {#add_line}. Cost is O(rendered-rows of the popped lines).
204
+ # Drops the last `n` hard lines from the buffer — the inverse of building
205
+ # up a tail with {#append} / {#add_line}, so a caller can `remove` then
206
+ # `append` to rewrite a damaged tail in place. Operates on **hard lines**
207
+ # (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
208
+ # empty buffer are no-ops; `n >= hard-line count` empties the buffer.
237
209
  # @param n [Integer] number of hard lines to drop; must be >= 0.
238
210
  # @raise [TypeError] if `n` isn't an `Integer`.
239
211
  # @raise [ArgumentError] if `n` is negative.
@@ -266,40 +238,21 @@ module Tuile
266
238
  invalidate
267
239
  end
268
240
 
269
- # Replaces a contiguous range of hard lines with the parsed content
270
- # of `str`. The replacement is parsed exactly like {#text=} and
271
- # {#append}: a {String} is run through {StyledString.parse} (so
272
- # embedded ANSI is honored), a {StyledString} is used as-is, `nil`
273
- # behaves like an empty replacement (the range is deleted). Embedded
274
- # `"\n"` in the replacement produces multiple hard lines, so a single
275
- # `replace` can grow or shrink the buffer.
241
+ # Replaces a contiguous range of hard lines with the parsed content of
242
+ # `str` (parsed like {#text=}: `String` → {StyledString.parse}, `nil` →
243
+ # empty, so `nil` deletes the range). Embedded `"\n"` yields multiple hard
244
+ # lines, so one `replace` can grow or shrink the buffer. `range` selects
245
+ # which hard lines to swap out:
276
246
  #
277
- # `range` selects which hard lines to swap out:
278
- #
279
- # - an `Integer` `n` is shorthand for `n..n` (replace one existing
280
- # line — `n` must be in `[0, hard-line count)`);
281
- # - a non-empty `Range` of hard-line indices replaces those lines;
282
- # - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
283
- # `hard_lines.size...hard_lines.size`) is *insertion* at that
284
- # position — no lines are removed. {#insert} is a thin alias for
285
- # this case.
286
- #
287
- # Endpoints must be non-negative integers; `begin` may equal
288
- # `hard-line count` (insertion at the end), `end` may not exceed
289
- # `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
290
- # are not accepted.
291
- #
292
- # Cost is roughly `O(from + length + new content)`: the splice
293
- # updates only the affected slice of the physical-row buffer, using
294
- # the per-hard-line wrap-count cache to locate the starting offset
295
- # without re-wrapping preceding lines. Lines outside the splice are
296
- # never re-wrapped. {#top_line} is clamped if the new line count
297
- # puts it past the end; {#auto_scroll} pins it to the bottom as
298
- # usual. The call is a no-op (no invalidation) when the parsed
299
- # replacement equals the covered range (vacuously true for an empty
300
- # range plus empty replacement, so `replace(n...n, "")` is a cheap
301
- # no-op).
247
+ # - an `Integer` `n` is shorthand for `n..n` (replace one existing line);
248
+ # - a non-empty `Range` replaces those lines;
249
+ # - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
250
+ # *insertion* at that position — nothing removed. {#insert} aliases this.
302
251
  #
252
+ # Splices in place — only the affected slice of the physical-row buffer is
253
+ # touched, no preceding lines re-wrapped (cost O(from + length + new
254
+ # content)). A no-op when the replacement equals the covered range, so
255
+ # `replace(n...n, "")` is cheap.
303
256
  # @param range [Range, Integer] hard-line indices to replace.
304
257
  # @param str [String, StyledString, nil] replacement content.
305
258
  # @raise [TypeError] if `range` is neither an `Integer` nor a `Range`,
@@ -417,6 +370,8 @@ module Tuile
417
370
  # Skips the {Component#repaint} default's auto-clear: every row is
418
371
  # painted explicitly (with padded blanks past the last line), so the
419
372
  # "fully draw over your rect" contract is met without an upfront wipe.
373
+ # Rows go through {Component#draw_line}, so content and blank rows inherit
374
+ # {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
420
375
  # @return [void]
421
376
  def repaint
422
377
  return if rect.empty?
@@ -426,7 +381,7 @@ module Tuile
426
381
  end
427
382
  (0...rect.height).each do |row|
428
383
  line = paintable_line(row + @top_line, row, scrollbar)
429
- screen.buffer.set_line(rect.left, rect.top + row, line)
384
+ draw_line(rect.left, rect.top + row, line)
430
385
  end
431
386
  end
432
387
 
@@ -645,19 +600,12 @@ module Tuile
645
600
  invalidate
646
601
  end
647
602
 
648
- # Adjusts region line counts after a {@hard_lines} splice that
649
- # removed `removed_count` lines at index `from` and inserted
650
- # `added_count` in their place. Two passes:
651
- #
652
- # 1. Subtract each region's overlap with the removed range (uses
653
- # the original counts to compute positions). Remember the first
654
- # region that lost lines — that's the natural home for the
655
- # replacement content.
656
- # 2. Credit `added_count` to that region. For pure insertions (no
657
- # removal), there's no "first overlapping region" to pick from;
658
- # walk regions and credit the latest one starting at `from` (the
659
- # boundary tiebreaker matches the spatial-tail-routing of
660
- # {#append}). Past-the-end inserts fall back to the tail region.
603
+ # Adjusts region line counts after a {@hard_lines} splice that removed
604
+ # `removed_count` lines at `from` and inserted `added_count`. Subtracts
605
+ # each region's overlap with the removed range, then credits the added
606
+ # lines to the first region that lost lines. Pure insertions have no such
607
+ # region — they credit the latest region starting at `from`, matching
608
+ # {#append}'s spatial-tail routing (past-the-end falls back to the tail).
661
609
  # @param from [Integer]
662
610
  # @param removed_count [Integer]
663
611
  # @param added_count [Integer]
@@ -956,14 +904,9 @@ module Tuile
956
904
  end
957
905
 
958
906
  # Verbatim append into this region's tail. Same semantics as
959
- # {TextView#append} but scoped to the region: embedded `"\n"`
960
- # creates new hard lines within the region, no-leading-newline
961
- # input extends the region's last hard line. Empty / `nil` input
962
- # is a no-op (but still raises when detached). When the region is
963
- # the spatial tail of the view, this uses the incremental
964
- # {TextView#append} path; mid-document regions splice the affected
965
- # slice of the physical-row buffer (lines outside the region are
966
- # not re-wrapped).
907
+ # {TextView#append} but scoped: embedded `"\n"` creates new hard lines
908
+ # within the region, other input extends the region's last hard line.
909
+ # Empty / `nil` is a no-op (but still raises when detached).
967
910
  # @param str [String, StyledString, nil]
968
911
  # @raise [RuntimeError] when the region is detached.
969
912
  # @return [void]
@@ -13,12 +13,14 @@ module Tuile
13
13
  # by {Component#invalidate}; subclasses don't need to re-check.)
14
14
  class Window < Component
15
15
  include Component::HasContent
16
+ include Component::HasCaption
16
17
 
17
- # @param caption [String]
18
- def initialize(caption = "")
18
+ # @param caption [String, StyledString, nil] the border title, coerced
19
+ # the same way {HasCaption#caption=} coerces it.
20
+ def initialize(caption = nil)
19
21
  super()
20
22
  @border_right = 1
21
- @caption = caption
23
+ self.caption = caption
22
24
  @content = nil
23
25
  # Optional bottom-row widget slot (e.g. a search field), spanning the
24
26
  # full inner width; and optional bottom-border chrome text embedded in
@@ -75,10 +77,12 @@ module Tuile
75
77
  end
76
78
 
77
79
  old = @footer
78
- old&.parent = nil
80
+ # Same slot-swap order as HasContent#content=: notified last, so the
81
+ # focus repair cascades into the new occupant rather than the old.
82
+ detach_child(old) unless old.nil?
79
83
  @footer = new_footer
80
84
  unless new_footer.nil?
81
- new_footer.parent = self
85
+ add_child(new_footer) # appended: the footer paints over the border row
82
86
  new_footer.invalidate
83
87
  layout_footer
84
88
  end
@@ -86,11 +90,6 @@ module Tuile
86
90
  on_child_removed(old) unless old.nil?
87
91
  end
88
92
 
89
- # @return [Array<Component>]
90
- def children
91
- @footer.nil? ? super : super + [@footer]
92
- end
93
-
94
93
  # @param event [MouseEvent]
95
94
  # @return [void]
96
95
  def handle_mouse(event)
@@ -122,16 +121,6 @@ module Tuile
122
121
  layout(content)
123
122
  end
124
123
 
125
- # @return [String] the current caption, empty by default.
126
- attr_reader :caption
127
-
128
- # Sets new caption and repaints the window.
129
- # @param new_caption [String]
130
- def caption=(new_caption)
131
- @caption = new_caption
132
- invalidate
133
- end
134
-
135
124
  # Fully repaints the window: both frame and contents.
136
125
  #
137
126
  # Window deliberately paints over its entire rect (border around the
@@ -150,14 +139,6 @@ module Tuile
150
139
  repaint_border
151
140
  end
152
141
 
153
- # @param key [String, nil]
154
- # @return [void]
155
- def key_shortcut=(key)
156
- super
157
- # The shortcut key is shown in the caption — repaint.
158
- invalidate
159
- end
160
-
161
142
  protected
162
143
 
163
144
  # @param content [Component]
@@ -166,9 +147,12 @@ module Tuile
166
147
  content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
167
148
  end
168
149
 
169
- # Paints the window border into the {Screen#buffer}. Title is clipped to
170
- # the inner width so the box never overflows {#rect}; when the window is
171
- # active the whole border is drawn in {Theme#active_border_color}.
150
+ # Paints the window border via {Component#draw_line}/{Component#draw_char},
151
+ # so the border cells inherit {Component#effective_bg_color} — a
152
+ # {Component#bg_color} on the window tints border and content alike. Both
153
+ # border lines are clipped by *display* width, so no caption overflows the
154
+ # box; when the window is active the whole border — the caption's own
155
+ # colors included — is drawn in {Theme#active_border_color}.
172
156
  # @return [void]
173
157
  def repaint_border
174
158
  return if rect.empty?
@@ -178,19 +162,28 @@ module Tuile
178
162
  top = rect.top
179
163
  left = rect.left
180
164
  inner_w = [w - 2, 0].max
181
- title = frame_caption.to_s
182
- title = title[0, inner_w] if title.length > inner_w
183
- dashes = "─" * (inner_w - title.length)
184
165
 
185
166
  fg = active? ? screen.theme.active_border_color : nil
186
167
  bar = StyledString::Style.new(fg: fg)
187
- buf = screen.buffer
188
- buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
168
+ draw_line(left, top, top_border(inner_w, fg).slice(0, w))
189
169
  (1..(h - 2)).each do |dy|
190
- buf.set_char(left, top + dy, "│", bar)
191
- buf.set_char(left + w - 1, top + dy, "│", bar)
170
+ draw_char(left, top + dy, "│", bar)
171
+ draw_char(left + w - 1, top + dy, "│", bar)
192
172
  end
193
- buf.set_line(left, top + h - 1, bottom_border(inner_w, fg)) if h >= 2
173
+ draw_line(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
174
+ end
175
+
176
+ # Builds the top border line: corners, {#caption} embedded at its own
177
+ # width, dashes filling the remainder. The caption keeps its own styling
178
+ # unless `fg` is set — an active window's border claims it.
179
+ # @param inner_w [Integer] the border's interior width.
180
+ # @param fg [Color, nil] the active-border color, or nil when inactive.
181
+ # @return [StyledString]
182
+ def top_border(inner_w, fg)
183
+ title = caption.slice(0, inner_w)
184
+ title = title.with_fg(fg) if fg
185
+ dashes = StyledString.styled("─" * (inner_w - title.display_width), fg: fg)
186
+ StyledString.styled("┌", fg: fg) + title + dashes + StyledString.styled("┐", fg: fg)
194
187
  end
195
188
 
196
189
  # Builds the bottom border line. The corners take the border color; the
@@ -212,14 +205,6 @@ module Tuile
212
205
  StyledString.styled("└", fg: fg) + interior + StyledString.styled("┘", fg: fg)
213
206
  end
214
207
 
215
- # The caption text as it appears in the rendered border, including the
216
- # shortcut prefix when {#key_shortcut} is set.
217
- # @return [String]
218
- def frame_caption
219
- c = @caption || ""
220
- key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
221
- end
222
-
223
208
  private
224
209
 
225
210
  # Positions the footer over the bottom border row, spanning the full