tuile 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e96464e067ccbb78bd11cf6639b53dc4f53de48519f7e47143a594b520bb99c
4
- data.tar.gz: 668bd3ac1b4130919949dce95867cccfa89dac387b6f164a2c7ea027cf04c1da
3
+ metadata.gz: fd5711addbd65a00c8d471204d50973da93ac9be65ba197114ac04c94cace526
4
+ data.tar.gz: 4505d93153dc96fd439e5d69a7b1506e985fc9094f8428fe092ebc6247d62b8a
5
5
  SHA512:
6
- metadata.gz: 6e9e20049c86bab8649b3d53604a61438f27608ae62d009c6a556fe76b146e186072c46a4c5036173c3b40197d8648dee855dce401d4536060f269e159a40c98
7
- data.tar.gz: 2ddccc6f2d1664935ba7c64b523f09b3a1ba9a57c7c356620beef39fd623b4a76a60d7fd1fda109aa11e9a5693a911cc1736d0f07dc99aad507008726d3fd301
6
+ metadata.gz: 23c69343d8a0cc87143b12cd1a4a9a11862c770debf9c5381cabfcb59b55786f8be143ef362581e0a79bb1dc7f9ce512f47d755f603cb486bf4058b3487d4399
7
+ data.tar.gz: '0974d322289b63b43bdd498e172f446d7cc56df656645b8e6826fed388154dda4a287110819d305572dbbf1c1ee23011fc4e0cb208f6459d67535e2df5c1d2b1'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-18
4
+
5
+ - Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
6
+ - Add `Tuile::StyledString` for span-modeled ANSI styling, with `#wrap` (span-preserving word wrap), `#ellipsize` (width-bounded truncation), `#with_bg`, and an `EMPTY` shared instance.
7
+ - Model `Label`, `List`, and `TextView` text as `StyledString`; pre-pad clipped/physical lines.
8
+ - Extract `Tuile::Ansi` for shared ANSI helpers.
9
+ - `Window#scrollbar=` accepts any content that exposes `scrollbar_visibility=`.
10
+ - Document `TextView` in the README and `examples/sampler.rb`.
11
+ - Remove `Tuile::Wrap` (superseded by `StyledString#wrap`).
12
+ - Remove `Tuile::Truncate` (superseded by `StyledString#ellipsize`).
13
+
3
14
  ## [0.2.0] - 2026-05-15
4
15
 
5
16
  - Add `Component::TextArea` with multi-line editing, word navigation, and VT220-style Home/End handling.
data/README.md CHANGED
@@ -79,7 +79,10 @@ Save it as `hello.rb` and run `ruby hello.rb`. Press `q` or `ESC` to exit.
79
79
 
80
80
  A larger demo lives in [`examples/file_commander.rb`](examples/file_commander.rb):
81
81
  a two-pane file browser with cursor navigation, header label, and a layout
82
- that follows terminal resize.
82
+ that follows terminal resize. For a tour of every shipped component, run
83
+ [`examples/sampler.rb`](examples/sampler.rb): a two-pane sampler where the
84
+ left pane lists demos and the right pane loads the highlighted one. Tab /
85
+ Shift+Tab move focus between the list and the demo's widgets.
83
86
 
84
87
  ## How it works
85
88
 
data/examples/sampler.rb CHANGED
@@ -66,6 +66,7 @@ module SamplerExample
66
66
  ["Label", :build_label],
67
67
  ["TextField", :build_text_field],
68
68
  ["TextArea", :build_text_area],
69
+ ["TextView", :build_text_view],
69
70
  ["Button", :build_buttons],
70
71
  ["List", :build_list],
71
72
  ["Layout", :build_layout],
@@ -133,6 +134,38 @@ module SamplerExample
133
134
  end
134
135
  end
135
136
 
137
+ def build_text_view
138
+ prompt = Tuile::Component::Label.new
139
+ prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
140
+ "Tab here, then: ↑↓ / jk scroll a line; PgUp/PgDn a page; Ctrl+U/D half a page; " \
141
+ "Home/End / g/G jump to the edges."
142
+ window = Tuile::Component::Window.new("Excerpt")
143
+ view = Tuile::Component::TextView.new
144
+ view.text = "#{Rainbow("Tuile").green} is a small component-oriented terminal-UI framework built on top of " \
145
+ "the TTY toolkit. Apps build a tree of Components under a singleton Screen; the screen runs " \
146
+ "an event loop, dispatches keys and mouse events, and repaints invalidated components in " \
147
+ "batch.\n\n" \
148
+ "The name is #{Rainbow("French").cyan} for #{Rainbow("\"roof tile\"").yellow} — small pieces " \
149
+ "that compose into a larger whole. This excerpt wraps to the viewer's current width; resize " \
150
+ "the terminal to see the wrap recompute, and scroll to see the rest.\n\n" \
151
+ "Components do not paint immediately. They call invalidate (which records them in the " \
152
+ "Screen's pending-repaint set); after an event-loop tick drains the queue, Screen#repaint " \
153
+ "walks the set, sorts by depth, and paints parents before children. Popups deliberately " \
154
+ "overdraw the tiled tree on top.\n\n" \
155
+ "All UI mutations must run on the thread that owns Screen#run_event_loop. Background work " \
156
+ "marshals back via screen.event_queue.submit { … }. Most UI methods check the lock and " \
157
+ "raise if you violate the contract; FakeScreen short-circuits the check so tests can mutate " \
158
+ "freely."
159
+ window.content = view
160
+ window.scrollbar = true
161
+ panel(prompt, window) do |r|
162
+ inner = inner_rect(r)
163
+ prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 2)
164
+ view_height = [inner.height - 5, 4].max
165
+ window.rect = Tuile::Rect.new(inner.left, inner.top + 4, inner.width, view_height)
166
+ end
167
+ end
168
+
136
169
  def build_buttons
137
170
  label = Tuile::Component::Label.new
138
171
  label.text = "Buttons fire on Enter, Space, or a left-click. Tab to focus, then activate."
data/lib/tuile/ansi.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # ANSI escape sequence constants. Tuile emits colors and text attributes
5
+ # via Rainbow, which produces **SGR** sequences ("Select Graphic
6
+ # Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m` bold,
7
+ # `\e[0m` reset).
8
+ module Ansi
9
+ # SGR reset (`ESC [ 0 m`). Restores the terminal's default foreground,
10
+ # background, and text attributes.
11
+ # @return [String]
12
+ RESET = "\e[0m"
13
+ end
14
+ end
@@ -2,38 +2,66 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A label which shows static text. No word-wrapping; clips long lines.
5
+ # A label which shows static text. No word-wrapping; long lines are
6
+ # truncated with an ellipsis. Text is modeled as a {StyledString};
7
+ # {#text=} accepts a {String} (parsed via {StyledString.parse}, so
8
+ # embedded ANSI is honored) or a {StyledString} directly. {#text}
9
+ # always returns the {StyledString}.
6
10
  class Label < Component
7
11
  def initialize
8
12
  super
9
- @lines = []
13
+ @text = StyledString::EMPTY
10
14
  @clipped_lines = []
15
+ @blank_line = ""
11
16
  end
12
17
 
13
- # @param text [String, nil] draws this text. May contain ANSI formatting.
14
- # Clipped automatically.
18
+ # @return [StyledString] the current text. Defaults to an empty
19
+ # {StyledString}.
20
+ attr_reader :text
21
+
22
+ # Replaces the text. A `String` is parsed via {StyledString.parse}
23
+ # (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
24
+ # coerced to an empty {StyledString}. Lines wider than {#rect} are
25
+ # truncated with an ellipsis at paint time.
26
+ # @param value [String, StyledString, nil]
15
27
  # @return [void]
16
- def text=(text)
17
- @lines = text.to_s.split("\n")
28
+ def text=(value)
29
+ new_text = StyledString.parse(value)
30
+ return if @text == new_text
31
+
32
+ @text = new_text
18
33
  @content_size = nil
19
- update_clipped_text
34
+ update_clipped_lines
35
+ invalidate
20
36
  end
21
37
 
22
- # @return [Size]
38
+ # @return [Size] longest hard-line's display width × number of hard
39
+ # lines. Reported on the *unclipped* text — sizing is intrinsic to
40
+ # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
23
41
  def content_size
24
- @content_size ||= begin
25
- width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
26
- Size.new(width, @lines.size)
27
- end
42
+ @content_size ||=
43
+ if @text.empty?
44
+ Size::ZERO
45
+ else
46
+ hard_lines = @text.lines
47
+ width = hard_lines.map(&:display_width).max || 0
48
+ Size.new(width, hard_lines.size)
49
+ end
28
50
  end
29
51
 
52
+ # Paints the text into {#rect}.
53
+ #
54
+ # Skips the {Component#repaint} default's auto-clear: every row is
55
+ # painted explicitly (with pre-padded blanks past the last line), so
56
+ # the "fully draw over your rect" contract is met without an upfront
57
+ # wipe.
30
58
  # @return [void]
31
59
  def repaint
32
- super
33
- height = rect.height.clamp(0, nil)
34
- lines_to_print = @clipped_lines.length.clamp(nil, height)
35
- (0..lines_to_print - 1).each do |index|
36
- screen.print TTY::Cursor.move_to(rect.left, rect.top + index), @clipped_lines[index]
60
+ return if rect.empty? || rect.left.negative? || rect.top.negative?
61
+
62
+ (0...rect.height).each do |row|
63
+ line = @clipped_lines[row] || @blank_line
64
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
37
65
  end
38
66
  end
39
67
 
@@ -42,21 +70,31 @@ module Tuile
42
70
  # @return [void]
43
71
  def on_width_changed
44
72
  super
45
- update_clipped_text
73
+ update_clipped_lines
46
74
  end
47
75
 
48
76
  private
49
77
 
78
+ # Recomputes {@clipped_lines} for the current text and rect width.
79
+ # Each line is ellipsized to fit, padded with trailing spaces out to
80
+ # the full width, and pre-rendered to ANSI so {#repaint} is just a
81
+ # lookup + screen.print per row. {@blank_line} covers rows past the
82
+ # last text line.
50
83
  # @return [void]
51
- def update_clipped_text
52
- len = rect.width.clamp(0, nil)
53
- clipped = @lines.map do |line|
54
- Truncate.truncate(line, length: len)
55
- end
56
- return if @clipped_lines == clipped
84
+ def update_clipped_lines
85
+ width = rect.width.clamp(0, nil)
86
+ @blank_line = " " * width
87
+ @clipped_lines = @text.lines.map { |line| pad_to(line.ellipsize(width), width).to_ansi }
88
+ end
57
89
 
58
- @clipped_lines = clipped
59
- invalidate
90
+ # @param line [StyledString]
91
+ # @param width [Integer]
92
+ # @return [StyledString]
93
+ def pad_to(line, width)
94
+ diff = width - line.display_width
95
+ return line if diff <= 0
96
+
97
+ line + StyledString.plain(" " * diff)
60
98
  end
61
99
  end
62
100
  end
@@ -2,20 +2,31 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A scrollable list of String items with cursor support.
5
+ # A scrollable list of items with cursor support.
6
6
  #
7
- # Items are lines painted directly into the component's {#rect}. Lines are
8
- # automatically clipped horizontally. Vertical scrolling is supported via
9
- # {#top_line}; the list can also automatically scroll to the bottom if
10
- # {#auto_scroll} is enabled.
7
+ # Items are modeled as {StyledString}s and painted directly into the
8
+ # component's {#rect}. Lines wider than the viewport are ellipsized via
9
+ # {StyledString#ellipsize} (span styles are preserved across the cut
10
+ # unlike the older ANSI-as-bytes truncation, color does *not* get
11
+ # dropped on the surviving characters). Vertical scrolling is supported
12
+ # via {#top_line}; the list can also automatically scroll to the bottom
13
+ # if {#auto_scroll} is enabled.
11
14
  #
12
15
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
13
- # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
14
- # automatically.
16
+ # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
17
+ # list automatically. The cursor highlight overlays a dark background
18
+ # while preserving each span's foreground color.
15
19
  class List < Component
20
+ # 256-color SGR index for the cursor-row background highlight. Matches
21
+ # what `Rainbow(...).bg(:darkslategray)` emits.
22
+ # @return [Integer]
23
+ CURSOR_BG = 59
24
+
16
25
  def initialize
17
26
  super
18
27
  @lines = []
28
+ @padded_lines = []
29
+ @blank_padded = StyledString::EMPTY
19
30
  @auto_scroll = false
20
31
  @top_line = 0
21
32
  @cursor = Cursor::None.new
@@ -28,19 +39,19 @@ module Tuile
28
39
 
29
40
  # @return [Proc, nil] callback fired when an item is chosen — by pressing
30
41
  # Enter on the cursor's item, or by left-clicking an item. Called as
31
- # `proc.call(index, line)` with the chosen 0-based index and its line.
32
- # Never fires when the cursor's position is outside the content (e.g.
33
- # {Cursor::None}, or empty content).
42
+ # `proc.call(index, line)` with the chosen 0-based index and its
43
+ # {StyledString} line. Never fires when the cursor's position is
44
+ # outside the content (e.g. {Cursor::None}, or empty content).
34
45
  attr_accessor :on_item_chosen
35
46
 
36
47
  # @return [Proc, nil] callback fired when the `(index, line)` tuple under
37
48
  # the cursor changes. Called as `proc.call(index, line)` where `line`
38
- # is `nil` when the cursor is off-content ({Cursor::None}, empty list,
39
- # or `index` past the last line). Fires on cursor moves (key, mouse,
40
- # search), on {#cursor=}, and on {#lines=}/{#add_lines} when the line
41
- # at the cursor's index changes (or its in-range/out-of-range status
42
- # flips). Useful for keeping a details pane in sync with the
43
- # highlighted row.
49
+ # is the {StyledString} at the cursor, or `nil` when the cursor is
50
+ # off-content ({Cursor::None}, empty list, or `index` past the last
51
+ # line). Fires on cursor moves (key, mouse, search), on {#cursor=},
52
+ # and on {#lines=}/{#add_lines} when the line at the cursor's index
53
+ # changes (or its in-range/out-of-range status flips). Useful for
54
+ # keeping a details pane in sync with the highlighted row.
44
55
  attr_accessor :on_cursor_changed
45
56
 
46
57
  # @return [Boolean] if true and a line is added or new content is set,
@@ -77,6 +88,7 @@ module Tuile
77
88
  return if @scrollbar_visibility == value
78
89
 
79
90
  @scrollbar_visibility = value
91
+ rebuild_padded_lines
80
92
  invalidate
81
93
  end
82
94
 
@@ -109,16 +121,22 @@ module Tuile
109
121
  invalidate
110
122
  end
111
123
 
112
- # Sets new lines. Each entry is coerced via `#to_s`, split on `\n` into
113
- # separate lines, and trailing whitespace stripped symmetric with
114
- # {#add_lines}, so the stored `@lines` is always `Array<String>`.
115
- # @param lines [Array] new lines. Entries need only respond to `#to_s`.
124
+ # Sets new lines. Each entry is coerced into a {StyledString} (a
125
+ # `String` is parsed via {StyledString.parse}, so embedded ANSI is
126
+ # honored; a {StyledString} is used as-is; anything else is stringified
127
+ # via `#to_s` first), then split on `\n` into separate lines via
128
+ # {StyledString#lines}, with trailing empty pieces dropped and trailing
129
+ # ASCII whitespace stripped — symmetric with {#add_lines}, so the
130
+ # stored `@lines` is always `Array<StyledString>`.
131
+ # @param lines [Array] entries are `String`, `StyledString`, or anything
132
+ # that responds to `#to_s`.
116
133
  # @return [void]
117
134
  def lines=(lines)
118
135
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
119
136
 
120
- @lines = lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
137
+ @lines = parse_input_lines(lines)
121
138
  @content_size = nil
139
+ rebuild_padded_lines
122
140
  update_top_line_if_auto_scroll
123
141
  notify_cursor_changed
124
142
  invalidate
@@ -132,9 +150,11 @@ module Tuile
132
150
  # end
133
151
  # ```
134
152
  # @yield [buffer]
135
- # @yieldparam buffer [Array<String>] mutable buffer to push lines into.
153
+ # @yieldparam buffer [Array] mutable buffer to push lines into. Each
154
+ # entry is parsed the same way as the items passed to {#lines=}.
136
155
  # @yieldreturn [void]
137
- # @return [Array<String>] current lines (when called without a block).
156
+ # @return [Array<StyledString>] current lines (when called without a
157
+ # block).
138
158
  def lines
139
159
  return @lines unless block_given?
140
160
 
@@ -144,21 +164,24 @@ module Tuile
144
164
  end
145
165
 
146
166
  # Adds a line.
147
- # @param line [String]
167
+ # @param line [String, StyledString, #to_s]
148
168
  # @return [void]
149
169
  def add_line(line)
150
170
  add_lines [line]
151
171
  end
152
172
 
153
- # Appends given lines. Each entry is coerced via `#to_s`, split on `\n`
154
- # into separate lines, and trailing whitespace stripped symmetric with
155
- # {#lines=}.
156
- # @param lines [Array] entries need only respond to `#to_s`.
173
+ # Appends given lines. Each entry is parsed the same way as in
174
+ # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
175
+ # empty pieces dropped and trailing ASCII whitespace stripped.
176
+ # @param lines [Array] entries are `String`, `StyledString`, or anything
177
+ # that responds to `#to_s`.
157
178
  # @return [void]
158
179
  def add_lines(lines)
159
180
  screen.check_locked
160
- @lines += lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
181
+ new_lines = parse_input_lines(lines)
182
+ @lines += new_lines
161
183
  @content_size = nil
184
+ @padded_lines += new_lines.map { |line| pad_to_row(line) }
162
185
  update_top_line_if_auto_scroll
163
186
  notify_cursor_changed
164
187
  invalidate
@@ -167,8 +190,8 @@ module Tuile
167
190
  # @return [Size]
168
191
  def content_size
169
192
  @content_size ||= begin
170
- content_width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
171
- width = @lines.empty? ? 0 : content_width + 2
193
+ content_w = @lines.map(&:display_width).max || 0
194
+ width = @lines.empty? ? 0 : content_w + 2
172
195
  Size.new(width, @lines.size)
173
196
  end
174
197
  end
@@ -206,6 +229,8 @@ module Tuile
206
229
  # Moves the cursor to the next line whose text contains `query`
207
230
  # (case-insensitive substring match). Search wraps around the end of the
208
231
  # list. Only lines reachable by the current {#cursor} are considered.
232
+ # Matching uses the line's plain text — span styles do not affect the
233
+ # match.
209
234
  #
210
235
  # @param query [String] substring to match. Empty query never matches.
211
236
  # @param include_current [Boolean] when true, the current cursor position
@@ -250,21 +275,19 @@ module Tuile
250
275
  # Paints the list items into {#rect}.
251
276
  #
252
277
  # Skips the {Component#repaint} default's auto-clear: every row of
253
- # {#rect} is painted below (with padded content past the last item),
278
+ # {#rect} is painted below (with blank padding past the last item),
254
279
  # so the parent contract — "fully draw over your rect" — is met
255
280
  # without an upfront wipe.
256
281
  # @return [void]
257
282
  def repaint
258
283
  return if rect.empty?
259
284
 
260
- width = rect.width
261
285
  scrollbar = if scrollbar_visible?
262
286
  VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
263
287
  end
264
- (0..(rect.height - 1)).each do |line_no|
265
- line_index = line_no + @top_line
266
- line = paintable_line(line_index, line_no, width, scrollbar)
267
- screen.print TTY::Cursor.move_to(rect.left, line_no + rect.top), line
288
+ (0...rect.height).each do |row|
289
+ line = paintable_line(row + @top_line, row, scrollbar)
290
+ screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
268
291
  end
269
292
  end
270
293
 
@@ -453,8 +476,56 @@ module Tuile
453
476
  end
454
477
  end
455
478
 
479
+ protected
480
+
481
+ # Rebuilds pre-padded lines when the wrap width changes. The wrap width
482
+ # depends on {#rect}`.width` and the scrollbar gutter, both of which
483
+ # trigger this hook.
484
+ # @return [void]
485
+ def on_width_changed
486
+ super
487
+ rebuild_padded_lines
488
+ end
489
+
456
490
  private
457
491
 
492
+ # Coerces and flattens a list of input entries into trimmed
493
+ # {StyledString} lines. Each entry becomes a {StyledString} (String
494
+ # via {StyledString.parse}, StyledString passed through, anything else
495
+ # via `#to_s`), then split on `\n` via {StyledString#lines} — with
496
+ # trailing empty pieces dropped (matching `String#split("\n")`'s
497
+ # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
498
+ # whitespace stripped on each resulting line.
499
+ # @param entries [Array]
500
+ # @return [Array<StyledString>]
501
+ def parse_input_lines(entries)
502
+ entries.flat_map { |entry| split_to_lines(entry) }
503
+ end
504
+
505
+ # @param entry [Object]
506
+ # @return [Array<StyledString>]
507
+ def split_to_lines(entry)
508
+ styled = entry.is_a?(StyledString) ? entry : StyledString.parse(entry.to_s)
509
+ parts = styled.lines
510
+ parts.pop while parts.last && parts.last.empty?
511
+ parts.map { |line| rstrip_styled(line) }
512
+ end
513
+
514
+ # Returns `line` with trailing ASCII whitespace (space/tab) dropped,
515
+ # preserving span styles on the surviving prefix. Whitespace chars are
516
+ # all single-column ASCII, so byte-count delta equals column-count
517
+ # delta and {StyledString#slice} can do the cut.
518
+ # @param line [StyledString]
519
+ # @return [StyledString]
520
+ def rstrip_styled(line)
521
+ plain = line.to_s
522
+ trailing = plain.length - plain.rstrip.length
523
+ return line if trailing.zero?
524
+ return StyledString::EMPTY if trailing == plain.length
525
+
526
+ line.slice(0, line.display_width - trailing)
527
+ end
528
+
458
529
  # @return [Boolean] true if the cursor sits on a real content line.
459
530
  def cursor_on_item?
460
531
  pos = @cursor.position
@@ -469,8 +540,9 @@ module Tuile
469
540
  @on_item_chosen&.call(pos, @lines[pos])
470
541
  end
471
542
 
472
- # @return [Array((Integer, String, nil))] `[position, line_at_position]`,
473
- # with `line` nil when the cursor is off-content.
543
+ # @return [Array((Integer, StyledString, nil))]
544
+ # `[position, line_at_position]`, with `line` nil when the cursor is
545
+ # off-content.
474
546
  def cursor_state
475
547
  pos = @cursor.position
476
548
  line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
@@ -500,7 +572,7 @@ module Tuile
500
572
 
501
573
  ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
502
574
  query_lc = query.downcase
503
- match = ordered.find { |idx| Rainbow.uncolor(@lines[idx]).downcase.include?(query_lc) }
575
+ match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
504
576
  return false unless match
505
577
 
506
578
  @cursor.go(match)
@@ -584,42 +656,56 @@ module Tuile
584
656
  @scrollbar_visibility == :visible
585
657
  end
586
658
 
587
- # Trims string exactly to `width` columns.
588
- # @param str [String]
589
- # @param width [Integer]
590
- # @return [String]
591
- def trim_to(str, width)
592
- return " " * width if str.empty?
659
+ # @return [Integer] column width available for line content (rect width
660
+ # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
661
+ # is non-positive.
662
+ def content_width
663
+ return 0 if rect.width <= 0
593
664
 
594
- truncated_line = Truncate.truncate(str, length: width)
595
- return truncated_line unless truncated_line == str
665
+ rect.width - (scrollbar_visible? ? 1 : 0)
666
+ end
667
+
668
+ # Recomputes {@padded_lines} for the current rect width and scrollbar
669
+ # visibility. Each line is ellipsized to fit and pre-padded with
670
+ # single-space gutters on each side, so {#paintable_line} only has to
671
+ # apply the cursor highlight (if any) and append the scrollbar glyph.
672
+ # @return [void]
673
+ def rebuild_padded_lines
674
+ @padded_lines = @lines.map { |line| pad_to_row(line) }
675
+ @blank_padded = pad_to_row(StyledString::EMPTY)
676
+ end
596
677
 
597
- length = Unicode::DisplayWidth.of(Rainbow.uncolor(str))
598
- str += " " * (width - length) if length < width
599
- str
678
+ # Pads `line` to one full row of the viewport (scrollbar gutter
679
+ # excluded). Lines wider than the content area are ellipsized via
680
+ # {StyledString#ellipsize} (span styles survive the cut); shorter
681
+ # lines are padded with default-styled spaces.
682
+ # @param line [StyledString]
683
+ # @return [StyledString] exactly {#content_width} display columns wide
684
+ # (or {StyledString::EMPTY} when content_width is non-positive).
685
+ def pad_to_row(line)
686
+ cw = content_width
687
+ return StyledString::EMPTY if cw <= 0
688
+ return StyledString.plain(" " * cw) if cw < 2
689
+
690
+ text_width = cw - 2
691
+ body = line.ellipsize(text_width)
692
+ fill = cw - 2 - body.display_width
693
+ StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
600
694
  end
601
695
 
602
696
  # @param index [Integer] 0-based index into {#lines}.
603
697
  # @param row_in_viewport [Integer] 0-based row within the viewport.
604
- # @param width [Integer] number of columns the line should occupy.
605
- # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil if
606
- # not shown.
607
- # @return [String] paintable line exactly `width` columns wide;
608
- # highlighted if cursor is here.
609
- def paintable_line(index, row_in_viewport, width, scrollbar)
610
- content_width = scrollbar ? width - 1 : width
611
- line = @lines[index] || ""
612
- line = trim_to(line, content_width - 2)
613
- line = " #{line} "
698
+ # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
699
+ # if not shown.
700
+ # @return [String] paintable ANSI-encoded line exactly `rect.width`
701
+ # columns wide; highlighted if cursor is here.
702
+ def paintable_line(index, row_in_viewport, scrollbar)
703
+ base = index < @lines.size ? @padded_lines[index] : @blank_padded
614
704
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
615
- line = if is_cursor
616
- Rainbow(Rainbow.uncolor(line)).bg(:darkslategray)
617
- else
618
- line
619
- end
620
- return line unless scrollbar
621
-
622
- line + scrollbar.scrollbar_char(row_in_viewport)
705
+ styled = is_cursor ? base.with_bg(CURSOR_BG) : base
706
+ out = styled.to_ansi
707
+ out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
708
+ out
623
709
  end
624
710
  end
625
711
  end
@@ -138,8 +138,6 @@ module Tuile
138
138
  ACTIVE_BG_SGR = TextField::ACTIVE_BG_SGR
139
139
  # @return [String]
140
140
  INACTIVE_BG_SGR = TextField::INACTIVE_BG_SGR
141
- # @return [String]
142
- SGR_RESET = TextField::SGR_RESET
143
141
 
144
142
  # @return [void]
145
143
  def repaint
@@ -156,7 +154,7 @@ module Tuile
156
154
  chunk = @text[r[:start], r[:length]] || ""
157
155
  chunk + (" " * (rect.width - r[:length]))
158
156
  end
159
- screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, SGR_RESET
157
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, Ansi::RESET
160
158
  end
161
159
  end
162
160
 
@@ -158,9 +158,6 @@ module Tuile
158
158
  # (terminal black), so we emit the escape directly to reach the ramp.
159
159
  # @return [String]
160
160
  INACTIVE_BG_SGR = "\e[48;5;238m"
161
- # SGR reset.
162
- # @return [String]
163
- SGR_RESET = "\e[0m"
164
161
 
165
162
  # @return [void]
166
163
  def repaint
@@ -168,7 +165,7 @@ module Tuile
168
165
 
169
166
  bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
170
167
  padded = @text + (" " * (rect.width - @text.length))
171
- screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, SGR_RESET
168
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
172
169
  end
173
170
 
174
171
  protected