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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +4 -1
- data/examples/sampler.rb +33 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/list.rb +155 -69
- data/lib/tuile/component/text_area.rb +1 -3
- data/lib/tuile/component/text_field.rb +1 -4
- data/lib/tuile/component/text_view.rb +351 -0
- data/lib/tuile/component/window.rb +2 -2
- data/lib/tuile/styled_string.rb +761 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +640 -85
- metadata +4 -2
- data/lib/tuile/truncate.rb +0 -83
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
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).
|
|
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}. Use {#append} for incremental "log
|
|
16
|
+
# line" style updates; turn on {#auto_scroll} to keep the latest content
|
|
17
|
+
# in view.
|
|
18
|
+
#
|
|
19
|
+
# TextView is meant to be the content of a {Window} — focus indication and
|
|
20
|
+
# keyboard-hint surfacing rely on the surrounding window chrome.
|
|
21
|
+
class TextView < Component
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
# `@hard_lines` is the logical model — one entry per `\n`-delimited
|
|
25
|
+
# line of the original text, width-independent. `@physical_lines` is
|
|
26
|
+
# the rendered view — each hard line word-wrapped to `wrap_width`
|
|
27
|
+
# and padded with trailing blanks, so painting a row is a lookup.
|
|
28
|
+
# Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
|
|
29
|
+
# extends both.
|
|
30
|
+
@hard_lines = []
|
|
31
|
+
@physical_lines = []
|
|
32
|
+
@text = StyledString::EMPTY
|
|
33
|
+
@content_size = Size::ZERO
|
|
34
|
+
@blank_line = StyledString::EMPTY
|
|
35
|
+
@top_line = 0
|
|
36
|
+
@auto_scroll = false
|
|
37
|
+
@scrollbar_visibility = :gone
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [StyledString] the current text. Defaults to an empty
|
|
41
|
+
# {StyledString}. Internally the text is stored as an array of hard
|
|
42
|
+
# lines so {#append} can stay O(appended) instead of re-scanning the
|
|
43
|
+
# whole buffer; the joined {StyledString} returned here is
|
|
44
|
+
# reconstructed on first read after a mutation and cached, so
|
|
45
|
+
# repeated reads are O(1) but the first read after {#append} pays
|
|
46
|
+
# O(total spans).
|
|
47
|
+
def text
|
|
48
|
+
@text ||= build_text
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Integer] index of the first visible physical line.
|
|
52
|
+
attr_reader :top_line
|
|
53
|
+
|
|
54
|
+
# @return [Symbol] `:gone` or `:visible`.
|
|
55
|
+
attr_reader :scrollbar_visibility
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] if true, mutating the text scrolls the viewport so
|
|
58
|
+
# the last line stays in view. Default `false`.
|
|
59
|
+
attr_reader :auto_scroll
|
|
60
|
+
|
|
61
|
+
# Replaces the text. Embedded `\n` characters become hard line breaks.
|
|
62
|
+
# A `String` is parsed via {StyledString.parse} (so embedded ANSI is
|
|
63
|
+
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
64
|
+
# empty {StyledString}.
|
|
65
|
+
# @param value [String, StyledString, nil]
|
|
66
|
+
# @return [void]
|
|
67
|
+
def text=(value)
|
|
68
|
+
new_text = StyledString.parse(value)
|
|
69
|
+
return if text == new_text
|
|
70
|
+
|
|
71
|
+
@text = new_text
|
|
72
|
+
@hard_lines = new_text.empty? ? [] : new_text.lines
|
|
73
|
+
@content_size = compute_content_size
|
|
74
|
+
rewrap
|
|
75
|
+
update_top_line_if_auto_scroll
|
|
76
|
+
invalidate
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Appends `str` as a new physical line. If the current text is empty,
|
|
80
|
+
# behaves like `text = str`; otherwise prepends a newline so the new
|
|
81
|
+
# content lands on a fresh line. Accepts the same input forms as
|
|
82
|
+
# {#text=}.
|
|
83
|
+
#
|
|
84
|
+
# Cost is O(appended) rather than O(total) — the existing wrapped
|
|
85
|
+
# buffer is reused, only the new hard line(s) are wrapped and padded,
|
|
86
|
+
# and `@content_size` is updated incrementally. The cached
|
|
87
|
+
# {#text} is invalidated and rebuilt on demand.
|
|
88
|
+
# @param str [String, StyledString, nil]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def append(str)
|
|
91
|
+
screen.check_locked
|
|
92
|
+
appended = StyledString.parse(str)
|
|
93
|
+
if @hard_lines.empty?
|
|
94
|
+
self.text = appended
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
new_hard_lines = appended.lines
|
|
99
|
+
@text = nil
|
|
100
|
+
@hard_lines.concat(new_hard_lines)
|
|
101
|
+
new_width = new_hard_lines.map(&:display_width).max || 0
|
|
102
|
+
@content_size = Size.new(
|
|
103
|
+
[@content_size.width, new_width].max,
|
|
104
|
+
@content_size.height + new_hard_lines.size
|
|
105
|
+
)
|
|
106
|
+
width = wrap_width
|
|
107
|
+
new_hard_lines.each { |hl| append_physical_lines(hl, width) }
|
|
108
|
+
update_top_line_if_auto_scroll
|
|
109
|
+
invalidate
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Clears the text. Equivalent to `text = ""`.
|
|
113
|
+
# @return [void]
|
|
114
|
+
def clear
|
|
115
|
+
self.text = StyledString::EMPTY
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param new_top_line [Integer] 0 or greater. Not clamped against the
|
|
119
|
+
# number of lines (matches {List#top_line=}).
|
|
120
|
+
# @return [void]
|
|
121
|
+
def top_line=(new_top_line)
|
|
122
|
+
raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
|
|
123
|
+
raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
|
|
124
|
+
return if @top_line == new_top_line
|
|
125
|
+
|
|
126
|
+
@top_line = new_top_line
|
|
127
|
+
invalidate
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param value [Symbol] `:gone` or `:visible`.
|
|
131
|
+
# @return [void]
|
|
132
|
+
def scrollbar_visibility=(value)
|
|
133
|
+
raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
|
|
134
|
+
return if @scrollbar_visibility == value
|
|
135
|
+
|
|
136
|
+
@scrollbar_visibility = value
|
|
137
|
+
rewrap
|
|
138
|
+
invalidate
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Sets `auto_scroll`. If true, immediately scrolls to the bottom.
|
|
142
|
+
# @param value [Boolean]
|
|
143
|
+
# @return [void]
|
|
144
|
+
def auto_scroll=(value)
|
|
145
|
+
@auto_scroll = value ? true : false
|
|
146
|
+
update_top_line_if_auto_scroll
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def focusable? = true
|
|
150
|
+
|
|
151
|
+
def tab_stop? = true
|
|
152
|
+
|
|
153
|
+
# @return [Size] longest hard-line's display width × number of hard
|
|
154
|
+
# lines. Reported on the *unwrapped* text — wrap-aware sizing would
|
|
155
|
+
# be circular (width depends on width). Empty text returns
|
|
156
|
+
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
157
|
+
# {#append}, so reads are O(1).
|
|
158
|
+
attr_reader :content_size
|
|
159
|
+
|
|
160
|
+
# @param key [String]
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def handle_key(key)
|
|
163
|
+
return false unless active?
|
|
164
|
+
return true if super
|
|
165
|
+
|
|
166
|
+
case key
|
|
167
|
+
when *Keys::DOWN_ARROWS then move_top_line_by(1)
|
|
168
|
+
when *Keys::UP_ARROWS then move_top_line_by(-1)
|
|
169
|
+
when Keys::PAGE_DOWN then move_top_line_by(viewport_lines)
|
|
170
|
+
when Keys::PAGE_UP then move_top_line_by(-viewport_lines)
|
|
171
|
+
when Keys::CTRL_D then move_top_line_by(viewport_lines / 2)
|
|
172
|
+
when Keys::CTRL_U then move_top_line_by(-viewport_lines / 2)
|
|
173
|
+
when *Keys::HOMES, "g" then move_top_line_to(0)
|
|
174
|
+
when *Keys::ENDS_, "G" then move_top_line_to(top_line_max)
|
|
175
|
+
else return false
|
|
176
|
+
end
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param event [MouseEvent]
|
|
181
|
+
# @return [void]
|
|
182
|
+
def handle_mouse(event)
|
|
183
|
+
super
|
|
184
|
+
case event.button
|
|
185
|
+
when :scroll_down then move_top_line_by(4)
|
|
186
|
+
when :scroll_up then move_top_line_by(-4)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Paints the text into {#rect}.
|
|
191
|
+
#
|
|
192
|
+
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
193
|
+
# painted explicitly (with padded blanks past the last line), so the
|
|
194
|
+
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
195
|
+
# @return [void]
|
|
196
|
+
def repaint
|
|
197
|
+
return if rect.empty?
|
|
198
|
+
|
|
199
|
+
scrollbar = if scrollbar_visible?
|
|
200
|
+
VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
|
|
201
|
+
end
|
|
202
|
+
(0...rect.height).each do |row|
|
|
203
|
+
line = paintable_line(row + @top_line, row, scrollbar)
|
|
204
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
protected
|
|
209
|
+
|
|
210
|
+
# Rewraps the text on width changes. Wrap width depends on
|
|
211
|
+
# {#rect}`.width` and the scrollbar gutter, both of which trigger
|
|
212
|
+
# this hook.
|
|
213
|
+
# @return [void]
|
|
214
|
+
def on_width_changed
|
|
215
|
+
super
|
|
216
|
+
rewrap
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
# @return [Integer] number of visible lines.
|
|
222
|
+
def viewport_lines = rect.height
|
|
223
|
+
|
|
224
|
+
# @return [Integer] the max value of {#top_line} for scroll-key clamping.
|
|
225
|
+
def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
226
|
+
|
|
227
|
+
# Recomputes {@physical_lines} for the current text and wrap width,
|
|
228
|
+
# pre-padding every line to `wrap_width` so {#paintable_line} is just
|
|
229
|
+
# a lookup + optional scrollbar-char append at paint time (and the
|
|
230
|
+
# rendered ANSI is cached on each line via {StyledString#to_ansi}'s
|
|
231
|
+
# memoization, so re-painting on scroll is near-free). Clamps
|
|
232
|
+
# {@top_line} if the new line count puts it out of range.
|
|
233
|
+
# @return [void]
|
|
234
|
+
def rewrap
|
|
235
|
+
width = wrap_width
|
|
236
|
+
@blank_line = pad_to(StyledString::EMPTY, width)
|
|
237
|
+
@physical_lines = []
|
|
238
|
+
@hard_lines.each { |hl| append_physical_lines(hl, width) }
|
|
239
|
+
@top_line = top_line_max if @top_line > top_line_max
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Wraps `hard_line` at `width` and appends the padded physical lines
|
|
243
|
+
# to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
|
|
244
|
+
# and degenerate `width <= 0` both emit a single {@blank_line} row,
|
|
245
|
+
# matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
246
|
+
# would have produced for those cases.
|
|
247
|
+
# @param hard_line [StyledString] one hard-broken line (no embedded `"\n"`).
|
|
248
|
+
# @param width [Integer]
|
|
249
|
+
# @return [void]
|
|
250
|
+
def append_physical_lines(hard_line, width)
|
|
251
|
+
if hard_line.empty? || width <= 0
|
|
252
|
+
@physical_lines << @blank_line
|
|
253
|
+
else
|
|
254
|
+
hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
|
|
259
|
+
# default-styled `"\n"` between hard lines. Called from the {#text}
|
|
260
|
+
# reader when the cache is cold. Cost is O(total spans).
|
|
261
|
+
# @return [StyledString]
|
|
262
|
+
def build_text
|
|
263
|
+
return StyledString::EMPTY if @hard_lines.empty?
|
|
264
|
+
return @hard_lines.first if @hard_lines.size == 1
|
|
265
|
+
|
|
266
|
+
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
267
|
+
spans = []
|
|
268
|
+
@hard_lines.each_with_index do |hl, i|
|
|
269
|
+
spans << newline if i.positive?
|
|
270
|
+
spans.concat(hl.spans)
|
|
271
|
+
end
|
|
272
|
+
StyledString.new(spans)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [Size] {#content_size} computed from {@hard_lines}.
|
|
276
|
+
def compute_content_size
|
|
277
|
+
return Size::ZERO if @hard_lines.empty?
|
|
278
|
+
|
|
279
|
+
Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @return [Integer] column width available for wrapped text — viewport
|
|
283
|
+
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
284
|
+
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
285
|
+
def wrap_width
|
|
286
|
+
return 0 if rect.width <= 0
|
|
287
|
+
|
|
288
|
+
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
292
|
+
# @return [void]
|
|
293
|
+
def move_top_line_by(delta)
|
|
294
|
+
move_top_line_to(@top_line + delta)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
|
|
298
|
+
# @return [void]
|
|
299
|
+
def move_top_line_to(target)
|
|
300
|
+
clamped = target.clamp(0, top_line_max)
|
|
301
|
+
self.top_line = clamped unless @top_line == clamped
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# @return [void]
|
|
305
|
+
def update_top_line_if_auto_scroll
|
|
306
|
+
return unless @auto_scroll
|
|
307
|
+
|
|
308
|
+
target = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
309
|
+
self.top_line = target if @top_line != target
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [Boolean]
|
|
313
|
+
def scrollbar_visible?
|
|
314
|
+
return false if rect.empty?
|
|
315
|
+
|
|
316
|
+
@scrollbar_visibility == :visible
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Pads `line` with trailing default-styled spaces out to `width` display
|
|
320
|
+
# columns. Callers rely on {StyledString#wrap} having already
|
|
321
|
+
# constrained the line to `<= width`, so no truncation is performed.
|
|
322
|
+
# `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
|
|
323
|
+
# `wrap_width == 0` case (rect.width == 1 with scrollbar).
|
|
324
|
+
# @param line [StyledString]
|
|
325
|
+
# @param width [Integer]
|
|
326
|
+
# @return [StyledString]
|
|
327
|
+
def pad_to(line, width)
|
|
328
|
+
return StyledString::EMPTY if width <= 0
|
|
329
|
+
|
|
330
|
+
diff = width - line.display_width
|
|
331
|
+
return line if diff <= 0
|
|
332
|
+
|
|
333
|
+
line + StyledString.plain(" " * diff)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# @param index [Integer] 0-based index into `@physical_lines`.
|
|
337
|
+
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
338
|
+
# @param scrollbar [VerticalScrollBar, nil]
|
|
339
|
+
# @return [String] paintable ANSI-encoded line exactly `rect.width`
|
|
340
|
+
# columns wide. Body lines come pre-padded from {#rewrap}, so this
|
|
341
|
+
# reduces to a memoized {StyledString#to_ansi} read plus an
|
|
342
|
+
# ASCII-string concat of the scrollbar glyph when one is present.
|
|
343
|
+
def paintable_line(index, row_in_viewport, scrollbar)
|
|
344
|
+
line = @physical_lines[index] || @blank_line
|
|
345
|
+
return line.to_ansi unless scrollbar
|
|
346
|
+
|
|
347
|
+
line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -90,9 +90,9 @@ module Tuile
|
|
|
90
90
|
# @param value [Boolean]
|
|
91
91
|
# @return [void]
|
|
92
92
|
def scrollbar=(value)
|
|
93
|
-
unless content.
|
|
93
|
+
unless content.respond_to?(:scrollbar_visibility=)
|
|
94
94
|
raise Tuile::Error,
|
|
95
|
-
"scrollbar= requires a
|
|
95
|
+
"scrollbar= requires a content component that supports scrollbar_visibility=, got #{content.inspect}"
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
content.scrollbar_visibility = value ? :visible : :gone
|