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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
@@ -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