tuile 0.1.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +10 -10
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +320 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +192 -63
- data/lib/tuile/component/text_area.rb +376 -0
- data/lib/tuile/component/text_field.rb +46 -4
- data/lib/tuile/component/text_view.rb +351 -0
- data/lib/tuile/component/window.rb +13 -5
- data/lib/tuile/component.rb +53 -5
- data/lib/tuile/event_queue.rb +14 -1
- data/lib/tuile/keys.rb +24 -4
- data/lib/tuile/screen.rb +127 -39
- data/lib/tuile/screen_pane.rb +29 -7
- data/lib/tuile/styled_string.rb +761 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +958 -53
- metadata +9 -17
|
@@ -5,9 +5,11 @@ module Tuile
|
|
|
5
5
|
# A layout doesn't paint anything by itself: its job is to position child
|
|
6
6
|
# components.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# Children that fully tile the layout's rect repaint themselves and
|
|
9
|
+
# cover everything; children that leave gaps (e.g. a form with widgets
|
|
10
|
+
# of varying widths) trigger {Component#repaint}'s default behavior —
|
|
11
|
+
# the background is cleared and children are re-invalidated so they
|
|
12
|
+
# paint over a clean surface.
|
|
11
13
|
class Layout < Component
|
|
12
14
|
def initialize
|
|
13
15
|
super
|
|
@@ -17,6 +19,16 @@ module Tuile
|
|
|
17
19
|
# @return [Array<Component>]
|
|
18
20
|
def children = @children.to_a
|
|
19
21
|
|
|
22
|
+
# Layouts are focusable containers — like {Window} and {Popup}, they
|
|
23
|
+
# don't accept input themselves but they need to participate in the
|
|
24
|
+
# {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
|
|
25
|
+
# {TextField} ends up focusing the field rather than parking focus on
|
|
26
|
+
# the popup. Layouts don't paint any visible chrome of their own
|
|
27
|
+
# (the auto-cleared background is just blank space), so this has no
|
|
28
|
+
# mouse-routing consequences — clicks on a gap area land back on the
|
|
29
|
+
# Layout itself and the on_focus cascade forwards to a tab stop.
|
|
30
|
+
def focusable? = true
|
|
31
|
+
|
|
20
32
|
# Adds a child component to this layout.
|
|
21
33
|
# @param child [Component, Array<Component>]
|
|
22
34
|
# @return [void]
|
|
@@ -53,11 +65,6 @@ module Tuile
|
|
|
53
65
|
Size.new(right - rect.left, bottom - rect.top)
|
|
54
66
|
end
|
|
55
67
|
|
|
56
|
-
# @return [void]
|
|
57
|
-
def repaint
|
|
58
|
-
clear_background if @children.empty?
|
|
59
|
-
end
|
|
60
|
-
|
|
61
68
|
# Dispatches the event to the child under the mouse cursor.
|
|
62
69
|
# @param event [MouseEvent]
|
|
63
70
|
# @return [void]
|
|
@@ -83,10 +90,20 @@ module Tuile
|
|
|
83
90
|
# @return [void]
|
|
84
91
|
def on_focus
|
|
85
92
|
super
|
|
86
|
-
#
|
|
87
|
-
# start
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
# Forward focus to the first interactive widget in the subtree so the
|
|
94
|
+
# user can start typing / cursoring immediately. Prefer a {#tab_stop?}
|
|
95
|
+
# descendant (TextField, List, Button…) so we skip past intermediate
|
|
96
|
+
# containers like a {Window} or another {Layout}. Fall back to the
|
|
97
|
+
# first focusable direct child for the rare case where the layout has
|
|
98
|
+
# focusable but non-tab-stop children (e.g. an empty {Window}).
|
|
99
|
+
first_tab_stop = nil
|
|
100
|
+
on_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
|
|
101
|
+
if first_tab_stop
|
|
102
|
+
screen.focused = first_tab_stop
|
|
103
|
+
else
|
|
104
|
+
first_focusable = @children.find(&:focusable?)
|
|
105
|
+
screen.focused = first_focusable unless first_focusable.nil?
|
|
106
|
+
end
|
|
90
107
|
end
|
|
91
108
|
|
|
92
109
|
# Absolute layout. Extend this class, register any children, and
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -2,35 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A scrollable list of
|
|
5
|
+
# A scrollable list of items with cursor support.
|
|
6
6
|
#
|
|
7
|
-
# Items are
|
|
8
|
-
#
|
|
9
|
-
# {#
|
|
10
|
-
#
|
|
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
|
|
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
|
|
22
33
|
@scrollbar_visibility = :gone
|
|
23
34
|
@show_cursor_when_inactive = false
|
|
24
35
|
@on_item_chosen = nil
|
|
36
|
+
@on_cursor_changed = nil
|
|
37
|
+
@last_cursor_state = cursor_state
|
|
25
38
|
end
|
|
26
39
|
|
|
27
40
|
# @return [Proc, nil] callback fired when an item is chosen — by pressing
|
|
28
41
|
# Enter on the cursor's item, or by left-clicking an item. Called as
|
|
29
|
-
# `proc.call(index, line)` with the chosen 0-based index and its
|
|
30
|
-
# Never fires when the cursor's position is
|
|
31
|
-
# {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).
|
|
32
45
|
attr_accessor :on_item_chosen
|
|
33
46
|
|
|
47
|
+
# @return [Proc, nil] callback fired when the `(index, line)` tuple under
|
|
48
|
+
# the cursor changes. Called as `proc.call(index, line)` where `line`
|
|
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.
|
|
55
|
+
attr_accessor :on_cursor_changed
|
|
56
|
+
|
|
34
57
|
# @return [Boolean] if true and a line is added or new content is set,
|
|
35
58
|
# auto-scrolls to the bottom.
|
|
36
59
|
attr_reader :auto_scroll
|
|
@@ -65,6 +88,7 @@ module Tuile
|
|
|
65
88
|
return if @scrollbar_visibility == value
|
|
66
89
|
|
|
67
90
|
@scrollbar_visibility = value
|
|
91
|
+
rebuild_padded_lines
|
|
68
92
|
invalidate
|
|
69
93
|
end
|
|
70
94
|
|
|
@@ -83,6 +107,7 @@ module Tuile
|
|
|
83
107
|
old_position = @cursor.position
|
|
84
108
|
@cursor = cursor
|
|
85
109
|
invalidate if old_position != cursor.position
|
|
110
|
+
notify_cursor_changed
|
|
86
111
|
end
|
|
87
112
|
|
|
88
113
|
# Sets the top line.
|
|
@@ -96,17 +121,24 @@ module Tuile
|
|
|
96
121
|
invalidate
|
|
97
122
|
end
|
|
98
123
|
|
|
99
|
-
# Sets new lines. Each entry is coerced
|
|
100
|
-
#
|
|
101
|
-
# {
|
|
102
|
-
#
|
|
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`.
|
|
103
133
|
# @return [void]
|
|
104
134
|
def lines=(lines)
|
|
105
135
|
raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
|
|
106
136
|
|
|
107
|
-
@lines = lines
|
|
137
|
+
@lines = parse_input_lines(lines)
|
|
108
138
|
@content_size = nil
|
|
139
|
+
rebuild_padded_lines
|
|
109
140
|
update_top_line_if_auto_scroll
|
|
141
|
+
notify_cursor_changed
|
|
110
142
|
invalidate
|
|
111
143
|
end
|
|
112
144
|
|
|
@@ -118,9 +150,11 @@ module Tuile
|
|
|
118
150
|
# end
|
|
119
151
|
# ```
|
|
120
152
|
# @yield [buffer]
|
|
121
|
-
# @yieldparam buffer [Array
|
|
153
|
+
# @yieldparam buffer [Array] mutable buffer to push lines into. Each
|
|
154
|
+
# entry is parsed the same way as the items passed to {#lines=}.
|
|
122
155
|
# @yieldreturn [void]
|
|
123
|
-
# @return [Array<
|
|
156
|
+
# @return [Array<StyledString>] current lines (when called without a
|
|
157
|
+
# block).
|
|
124
158
|
def lines
|
|
125
159
|
return @lines unless block_given?
|
|
126
160
|
|
|
@@ -130,36 +164,42 @@ module Tuile
|
|
|
130
164
|
end
|
|
131
165
|
|
|
132
166
|
# Adds a line.
|
|
133
|
-
# @param line [String]
|
|
167
|
+
# @param line [String, StyledString, #to_s]
|
|
134
168
|
# @return [void]
|
|
135
169
|
def add_line(line)
|
|
136
170
|
add_lines [line]
|
|
137
171
|
end
|
|
138
172
|
|
|
139
|
-
# Appends given lines. Each entry is
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
# @param lines [Array] entries
|
|
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`.
|
|
143
178
|
# @return [void]
|
|
144
179
|
def add_lines(lines)
|
|
145
180
|
screen.check_locked
|
|
146
|
-
|
|
181
|
+
new_lines = parse_input_lines(lines)
|
|
182
|
+
@lines += new_lines
|
|
147
183
|
@content_size = nil
|
|
184
|
+
@padded_lines += new_lines.map { |line| pad_to_row(line) }
|
|
148
185
|
update_top_line_if_auto_scroll
|
|
186
|
+
notify_cursor_changed
|
|
149
187
|
invalidate
|
|
150
188
|
end
|
|
151
189
|
|
|
152
190
|
# @return [Size]
|
|
153
191
|
def content_size
|
|
154
192
|
@content_size ||= begin
|
|
155
|
-
|
|
156
|
-
width = @lines.empty? ? 0 :
|
|
193
|
+
content_w = @lines.map(&:display_width).max || 0
|
|
194
|
+
width = @lines.empty? ? 0 : content_w + 2
|
|
157
195
|
Size.new(width, @lines.size)
|
|
158
196
|
end
|
|
159
197
|
end
|
|
160
198
|
|
|
161
199
|
def focusable? = true
|
|
162
200
|
|
|
201
|
+
def tab_stop? = true
|
|
202
|
+
|
|
163
203
|
# @param key [String] a key.
|
|
164
204
|
# @return [Boolean] true if the key was handled.
|
|
165
205
|
def handle_key(key)
|
|
@@ -178,6 +218,7 @@ module Tuile
|
|
|
178
218
|
true
|
|
179
219
|
elsif @cursor.handle_key(key, @lines.size, viewport_lines)
|
|
180
220
|
move_viewport_to_cursor
|
|
221
|
+
notify_cursor_changed
|
|
181
222
|
invalidate
|
|
182
223
|
true
|
|
183
224
|
else
|
|
@@ -188,6 +229,8 @@ module Tuile
|
|
|
188
229
|
# Moves the cursor to the next line whose text contains `query`
|
|
189
230
|
# (case-insensitive substring match). Search wraps around the end of the
|
|
190
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.
|
|
191
234
|
#
|
|
192
235
|
# @param query [String] substring to match. Empty query never matches.
|
|
193
236
|
# @param include_current [Boolean] when true, the current cursor position
|
|
@@ -222,6 +265,7 @@ module Tuile
|
|
|
222
265
|
line = event.y - rect.top + top_line
|
|
223
266
|
if @cursor.handle_mouse(line, event, @lines.size)
|
|
224
267
|
move_viewport_to_cursor
|
|
268
|
+
notify_cursor_changed
|
|
225
269
|
invalidate
|
|
226
270
|
end
|
|
227
271
|
fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
|
|
@@ -229,19 +273,21 @@ module Tuile
|
|
|
229
273
|
end
|
|
230
274
|
|
|
231
275
|
# Paints the list items into {#rect}.
|
|
276
|
+
#
|
|
277
|
+
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
278
|
+
# {#rect} is painted below (with blank padding past the last item),
|
|
279
|
+
# so the parent contract — "fully draw over your rect" — is met
|
|
280
|
+
# without an upfront wipe.
|
|
232
281
|
# @return [void]
|
|
233
282
|
def repaint
|
|
234
|
-
super
|
|
235
283
|
return if rect.empty?
|
|
236
284
|
|
|
237
|
-
width = rect.width
|
|
238
285
|
scrollbar = if scrollbar_visible?
|
|
239
286
|
VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
|
|
240
287
|
end
|
|
241
|
-
(0
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
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
|
|
245
291
|
end
|
|
246
292
|
end
|
|
247
293
|
|
|
@@ -302,9 +348,9 @@ module Tuile
|
|
|
302
348
|
go_down_by(1, line_count)
|
|
303
349
|
when *Keys::UP_ARROWS
|
|
304
350
|
go_up_by(1)
|
|
305
|
-
when Keys::
|
|
351
|
+
when *Keys::HOMES
|
|
306
352
|
go_to_first
|
|
307
|
-
when Keys::
|
|
353
|
+
when *Keys::ENDS_
|
|
308
354
|
go_to_last(line_count)
|
|
309
355
|
when Keys::CTRL_U
|
|
310
356
|
go_up_by(viewport_lines / 2)
|
|
@@ -430,8 +476,56 @@ module Tuile
|
|
|
430
476
|
end
|
|
431
477
|
end
|
|
432
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
|
+
|
|
433
490
|
private
|
|
434
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
|
+
|
|
435
529
|
# @return [Boolean] true if the cursor sits on a real content line.
|
|
436
530
|
def cursor_on_item?
|
|
437
531
|
pos = @cursor.position
|
|
@@ -446,6 +540,26 @@ module Tuile
|
|
|
446
540
|
@on_item_chosen&.call(pos, @lines[pos])
|
|
447
541
|
end
|
|
448
542
|
|
|
543
|
+
# @return [Array((Integer, StyledString, nil))]
|
|
544
|
+
# `[position, line_at_position]`, with `line` nil when the cursor is
|
|
545
|
+
# off-content.
|
|
546
|
+
def cursor_state
|
|
547
|
+
pos = @cursor.position
|
|
548
|
+
line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
|
|
549
|
+
[pos, line]
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Fires {#on_cursor_changed} if {#cursor_state} differs from the last
|
|
553
|
+
# fired state. Idempotent — safe to call after any mutation.
|
|
554
|
+
# @return [void]
|
|
555
|
+
def notify_cursor_changed
|
|
556
|
+
state = cursor_state
|
|
557
|
+
return if state == @last_cursor_state
|
|
558
|
+
|
|
559
|
+
@last_cursor_state = state
|
|
560
|
+
@on_cursor_changed&.call(*state)
|
|
561
|
+
end
|
|
562
|
+
|
|
449
563
|
# @param query [String]
|
|
450
564
|
# @param include_current [Boolean]
|
|
451
565
|
# @param reverse [Boolean]
|
|
@@ -458,11 +572,12 @@ module Tuile
|
|
|
458
572
|
|
|
459
573
|
ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
|
|
460
574
|
query_lc = query.downcase
|
|
461
|
-
match = ordered.find { |idx|
|
|
575
|
+
match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
|
|
462
576
|
return false unless match
|
|
463
577
|
|
|
464
578
|
@cursor.go(match)
|
|
465
579
|
move_viewport_to_cursor
|
|
580
|
+
notify_cursor_changed
|
|
466
581
|
invalidate
|
|
467
582
|
true
|
|
468
583
|
end
|
|
@@ -541,42 +656,56 @@ module Tuile
|
|
|
541
656
|
@scrollbar_visibility == :visible
|
|
542
657
|
end
|
|
543
658
|
|
|
544
|
-
#
|
|
545
|
-
#
|
|
546
|
-
#
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
664
|
+
|
|
665
|
+
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
666
|
+
end
|
|
550
667
|
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
553
677
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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))
|
|
557
694
|
end
|
|
558
695
|
|
|
559
696
|
# @param index [Integer] 0-based index into {#lines}.
|
|
560
697
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
561
|
-
# @param
|
|
562
|
-
#
|
|
563
|
-
#
|
|
564
|
-
#
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
content_width = scrollbar ? width - 1 : width
|
|
568
|
-
line = @lines[index] || ""
|
|
569
|
-
line = trim_to(line, content_width - 2)
|
|
570
|
-
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
|
|
571
704
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
end
|
|
577
|
-
return line unless scrollbar
|
|
578
|
-
|
|
579
|
-
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
|
|
580
709
|
end
|
|
581
710
|
end
|
|
582
711
|
end
|