tuile 0.2.0 → 0.4.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 +32 -0
- data/README.md +141 -6
- 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 +197 -82
- data/lib/tuile/component/log_window.rb +12 -6
- data/lib/tuile/component/popup.rb +5 -5
- data/lib/tuile/component/text_area.rb +40 -137
- data/lib/tuile/component/text_field.rb +31 -151
- data/lib/tuile/component/text_input.rb +213 -0
- data/lib/tuile/component/text_view.rb +456 -0
- data/lib/tuile/component/window.rb +7 -12
- data/lib/tuile/component.rb +15 -3
- data/lib/tuile/keys.rb +91 -8
- data/lib/tuile/mouse_event.rb +23 -4
- data/lib/tuile/screen.rb +154 -12
- data/lib/tuile/styled_string.rb +774 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1026 -174
- metadata +5 -2
- data/lib/tuile/truncate.rb +0 -83
|
@@ -0,0 +1,456 @@
|
|
|
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}.
|
|
16
|
+
#
|
|
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.
|
|
28
|
+
#
|
|
29
|
+
# TextView is meant to be the content of a {Window} — focus indication and
|
|
30
|
+
# keyboard-hint surfacing rely on the surrounding window chrome.
|
|
31
|
+
class TextView < Component
|
|
32
|
+
def initialize
|
|
33
|
+
super
|
|
34
|
+
# `@hard_lines` is the logical model — one entry per `\n`-delimited
|
|
35
|
+
# line of the original text, width-independent. `@physical_lines` is
|
|
36
|
+
# the rendered view — each hard line word-wrapped to `wrap_width`
|
|
37
|
+
# and padded with trailing blanks, so painting a row is a lookup.
|
|
38
|
+
# Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
|
|
39
|
+
# extends both.
|
|
40
|
+
@hard_lines = []
|
|
41
|
+
@physical_lines = []
|
|
42
|
+
@text = StyledString::EMPTY
|
|
43
|
+
@content_size = Size::ZERO
|
|
44
|
+
@blank_line = StyledString::EMPTY
|
|
45
|
+
@top_line = 0
|
|
46
|
+
@auto_scroll = false
|
|
47
|
+
@scrollbar_visibility = :gone
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [StyledString] the current text. Defaults to an empty
|
|
51
|
+
# {StyledString}. Internally the text is stored as an array of hard
|
|
52
|
+
# lines so {#append} can stay O(appended) instead of re-scanning the
|
|
53
|
+
# whole buffer; the joined {StyledString} returned here is
|
|
54
|
+
# reconstructed on first read after a mutation and cached, so
|
|
55
|
+
# repeated reads are O(1) but the first read after {#append} pays
|
|
56
|
+
# O(total spans).
|
|
57
|
+
def text
|
|
58
|
+
@text ||= build_text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Integer] index of the first visible physical line.
|
|
62
|
+
attr_reader :top_line
|
|
63
|
+
|
|
64
|
+
# @return [Symbol] `:gone` or `:visible`.
|
|
65
|
+
attr_reader :scrollbar_visibility
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] if true, mutating the text scrolls the viewport so
|
|
68
|
+
# the last line stays in view. Default `false`.
|
|
69
|
+
attr_reader :auto_scroll
|
|
70
|
+
|
|
71
|
+
# Replaces the text. Embedded `\n` characters become hard line breaks.
|
|
72
|
+
# A `String` is parsed via {StyledString.parse} (so embedded ANSI is
|
|
73
|
+
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
74
|
+
# empty {StyledString}.
|
|
75
|
+
# @param value [String, StyledString, nil]
|
|
76
|
+
# @return [void]
|
|
77
|
+
def text=(value)
|
|
78
|
+
new_text = StyledString.parse(value)
|
|
79
|
+
return if text == new_text
|
|
80
|
+
|
|
81
|
+
@text = new_text
|
|
82
|
+
@hard_lines = new_text.empty? ? [] : new_text.lines
|
|
83
|
+
@content_size = compute_content_size
|
|
84
|
+
rewrap
|
|
85
|
+
update_top_line_if_auto_scroll
|
|
86
|
+
invalidate
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Boolean] true iff {#text} is empty (no hard lines).
|
|
90
|
+
def empty? = @hard_lines.empty?
|
|
91
|
+
|
|
92
|
+
# Appends `str` verbatim. Embedded `\n` characters become hard line
|
|
93
|
+
# breaks; otherwise the text is concatenated onto the current last
|
|
94
|
+
# hard line. Designed for streaming use (e.g. an LLM chat window
|
|
95
|
+
# receiving partial messages — feed each chunk straight in). Accepts
|
|
96
|
+
# the same input forms as {#text=}; empty/`nil` input is a no-op.
|
|
97
|
+
#
|
|
98
|
+
# For the "add an entry on a new line" pattern use {#add_line}.
|
|
99
|
+
#
|
|
100
|
+
# Cost is O(appended + width-of-current-last-hard-line) — the
|
|
101
|
+
# previously last hard line is re-wrapped (because the extension may
|
|
102
|
+
# cause it to wrap differently), any additional hard lines created by
|
|
103
|
+
# embedded `\n` are wrapped fresh. The cached {#text} is invalidated
|
|
104
|
+
# and rebuilt on demand.
|
|
105
|
+
# @param str [String, StyledString, nil]
|
|
106
|
+
# @return [void]
|
|
107
|
+
def append(str)
|
|
108
|
+
screen.check_locked
|
|
109
|
+
appended = StyledString.parse(str)
|
|
110
|
+
return if appended.empty?
|
|
111
|
+
|
|
112
|
+
new_segments = appended.lines
|
|
113
|
+
width = wrap_width
|
|
114
|
+
|
|
115
|
+
if empty?
|
|
116
|
+
new_segments.each do |hl|
|
|
117
|
+
@hard_lines << hl
|
|
118
|
+
append_physical_lines(hl, width)
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
extension = new_segments.first
|
|
122
|
+
unless extension.empty?
|
|
123
|
+
old_last = @hard_lines.pop
|
|
124
|
+
drop_physical_rows_for(old_last, width)
|
|
125
|
+
extended = old_last + extension
|
|
126
|
+
@hard_lines << extended
|
|
127
|
+
append_physical_lines(extended, width)
|
|
128
|
+
end
|
|
129
|
+
new_segments[1..].each do |hl|
|
|
130
|
+
@hard_lines << hl
|
|
131
|
+
append_physical_lines(hl, width)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@text = nil
|
|
136
|
+
@content_size = compute_content_size
|
|
137
|
+
update_top_line_if_auto_scroll
|
|
138
|
+
invalidate
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
142
|
+
# @param str [String, StyledString, nil]
|
|
143
|
+
# @return [self]
|
|
144
|
+
def <<(str)
|
|
145
|
+
append(str)
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Appends `str` as a new entry: starts a fresh hard line first (when
|
|
150
|
+
# the buffer is non-empty) and then appends `str`. Equivalent to
|
|
151
|
+
# `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
|
|
152
|
+
# empty one. `nil` and `""` produce a blank entry on a non-empty
|
|
153
|
+
# buffer and a no-op on an empty buffer (matches the old `append`
|
|
154
|
+
# semantics for "log line" callers).
|
|
155
|
+
# @param str [String, StyledString, nil]
|
|
156
|
+
# @return [void]
|
|
157
|
+
def add_line(str)
|
|
158
|
+
parsed = StyledString.parse(str)
|
|
159
|
+
if empty?
|
|
160
|
+
append(parsed)
|
|
161
|
+
else
|
|
162
|
+
append(StyledString.plain("\n") + parsed)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Drops the last `n` hard lines from the buffer. The inverse of
|
|
167
|
+
# building up a tail region with {#append} / {#add_line}: a caller
|
|
168
|
+
# streaming partially-rendered content whose tail must occasionally
|
|
169
|
+
# be retracted (e.g. Markdown-to-ANSI where a new token reformats
|
|
170
|
+
# the table being built) can call `remove_last_n_lines(k)` followed
|
|
171
|
+
# by `append(new_tail)` to replace the damaged region in place.
|
|
172
|
+
#
|
|
173
|
+
# `n == 0` and the empty-buffer case are no-ops (no invalidation).
|
|
174
|
+
# `n >= hard-line count` empties the buffer.
|
|
175
|
+
#
|
|
176
|
+
# Operates on **hard lines** (the `\n`-delimited entries the
|
|
177
|
+
# buffer stores), not on wrapped physical rows — same granularity
|
|
178
|
+
# as {#add_line}. Cost is O(rendered-rows of the popped lines).
|
|
179
|
+
# @param n [Integer] number of hard lines to drop; must be >= 0.
|
|
180
|
+
# @raise [TypeError] if `n` isn't an `Integer`.
|
|
181
|
+
# @raise [ArgumentError] if `n` is negative.
|
|
182
|
+
# @return [void]
|
|
183
|
+
def remove_last_n_lines(n)
|
|
184
|
+
raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
|
|
185
|
+
raise ArgumentError, "n must not be negative, got #{n}" if n.negative?
|
|
186
|
+
|
|
187
|
+
screen.check_locked
|
|
188
|
+
return if n.zero? || empty?
|
|
189
|
+
|
|
190
|
+
width = wrap_width
|
|
191
|
+
to_drop = [n, @hard_lines.size].min
|
|
192
|
+
to_drop.times do
|
|
193
|
+
popped = @hard_lines.pop
|
|
194
|
+
drop_physical_rows_for(popped, width)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
@text = nil
|
|
198
|
+
@content_size = compute_content_size
|
|
199
|
+
@top_line = top_line_max if @top_line > top_line_max
|
|
200
|
+
update_top_line_if_auto_scroll
|
|
201
|
+
invalidate
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Clears the text. Equivalent to `text = ""`.
|
|
205
|
+
# @return [void]
|
|
206
|
+
def clear
|
|
207
|
+
self.text = StyledString::EMPTY
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @param new_top_line [Integer] 0 or greater. Not clamped against the
|
|
211
|
+
# number of lines (matches {List#top_line=}).
|
|
212
|
+
# @return [void]
|
|
213
|
+
def top_line=(new_top_line)
|
|
214
|
+
raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
|
|
215
|
+
raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
|
|
216
|
+
return if @top_line == new_top_line
|
|
217
|
+
|
|
218
|
+
@top_line = new_top_line
|
|
219
|
+
invalidate
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @param value [Symbol] `:gone` or `:visible`.
|
|
223
|
+
# @return [void]
|
|
224
|
+
def scrollbar_visibility=(value)
|
|
225
|
+
raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
|
|
226
|
+
return if @scrollbar_visibility == value
|
|
227
|
+
|
|
228
|
+
@scrollbar_visibility = value
|
|
229
|
+
rewrap
|
|
230
|
+
invalidate
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Sets `auto_scroll`. If true, immediately scrolls to the bottom.
|
|
234
|
+
# @param value [Boolean]
|
|
235
|
+
# @return [void]
|
|
236
|
+
def auto_scroll=(value)
|
|
237
|
+
@auto_scroll = value ? true : false
|
|
238
|
+
update_top_line_if_auto_scroll
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def focusable? = true
|
|
242
|
+
|
|
243
|
+
def tab_stop? = true
|
|
244
|
+
|
|
245
|
+
# @return [Size] longest hard-line's display width × number of hard
|
|
246
|
+
# lines. Reported on the *unwrapped* text — wrap-aware sizing would
|
|
247
|
+
# be circular (width depends on width). Empty text returns
|
|
248
|
+
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
249
|
+
# {#append}, so reads are O(1).
|
|
250
|
+
attr_reader :content_size
|
|
251
|
+
|
|
252
|
+
# @param key [String]
|
|
253
|
+
# @return [Boolean]
|
|
254
|
+
def handle_key(key)
|
|
255
|
+
return false unless active?
|
|
256
|
+
return true if super
|
|
257
|
+
|
|
258
|
+
case key
|
|
259
|
+
when *Keys::DOWN_ARROWS then move_top_line_by(1)
|
|
260
|
+
when *Keys::UP_ARROWS then move_top_line_by(-1)
|
|
261
|
+
when Keys::PAGE_DOWN then move_top_line_by(viewport_lines)
|
|
262
|
+
when Keys::PAGE_UP then move_top_line_by(-viewport_lines)
|
|
263
|
+
when Keys::CTRL_D then move_top_line_by(viewport_lines / 2)
|
|
264
|
+
when Keys::CTRL_U then move_top_line_by(-viewport_lines / 2)
|
|
265
|
+
when *Keys::HOMES, "g" then move_top_line_to(0)
|
|
266
|
+
when *Keys::ENDS_, "G" then move_top_line_to(top_line_max)
|
|
267
|
+
else return false
|
|
268
|
+
end
|
|
269
|
+
true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @param event [MouseEvent]
|
|
273
|
+
# @return [void]
|
|
274
|
+
def handle_mouse(event)
|
|
275
|
+
super
|
|
276
|
+
case event.button
|
|
277
|
+
when :scroll_down then move_top_line_by(4)
|
|
278
|
+
when :scroll_up then move_top_line_by(-4)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Paints the text into {#rect}.
|
|
283
|
+
#
|
|
284
|
+
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
285
|
+
# painted explicitly (with padded blanks past the last line), so the
|
|
286
|
+
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
287
|
+
# @return [void]
|
|
288
|
+
def repaint
|
|
289
|
+
return if rect.empty?
|
|
290
|
+
|
|
291
|
+
scrollbar = if scrollbar_visible?
|
|
292
|
+
VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
|
|
293
|
+
end
|
|
294
|
+
(0...rect.height).each do |row|
|
|
295
|
+
line = paintable_line(row + @top_line, row, scrollbar)
|
|
296
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
protected
|
|
301
|
+
|
|
302
|
+
# Rewraps the text on width changes. Wrap width depends on
|
|
303
|
+
# {#rect}`.width` and the scrollbar gutter, both of which trigger
|
|
304
|
+
# this hook.
|
|
305
|
+
# @return [void]
|
|
306
|
+
def on_width_changed
|
|
307
|
+
super
|
|
308
|
+
rewrap
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private
|
|
312
|
+
|
|
313
|
+
# @return [Integer] number of visible lines.
|
|
314
|
+
def viewport_lines = rect.height
|
|
315
|
+
|
|
316
|
+
# @return [Integer] the max value of {#top_line} for scroll-key clamping.
|
|
317
|
+
def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
318
|
+
|
|
319
|
+
# Recomputes {@physical_lines} for the current text and wrap width,
|
|
320
|
+
# pre-padding every line to `wrap_width` so {#paintable_line} is just
|
|
321
|
+
# a lookup + optional scrollbar-char append at paint time (and the
|
|
322
|
+
# rendered ANSI is cached on each line via {StyledString#to_ansi}'s
|
|
323
|
+
# memoization, so re-painting on scroll is near-free). Clamps
|
|
324
|
+
# {@top_line} if the new line count puts it out of range.
|
|
325
|
+
# @return [void]
|
|
326
|
+
def rewrap
|
|
327
|
+
width = wrap_width
|
|
328
|
+
@blank_line = pad_to(StyledString::EMPTY, width)
|
|
329
|
+
@physical_lines = []
|
|
330
|
+
@hard_lines.each { |hl| append_physical_lines(hl, width) }
|
|
331
|
+
@top_line = top_line_max if @top_line > top_line_max
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Wraps `hard_line` at `width` and appends the padded physical lines
|
|
335
|
+
# to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
|
|
336
|
+
# and degenerate `width <= 0` both emit a single {@blank_line} row,
|
|
337
|
+
# matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
338
|
+
# would have produced for those cases.
|
|
339
|
+
# @param hard_line [StyledString] one hard-broken line (no embedded `"\n"`).
|
|
340
|
+
# @param width [Integer]
|
|
341
|
+
# @return [void]
|
|
342
|
+
def append_physical_lines(hard_line, width)
|
|
343
|
+
if hard_line.empty? || width <= 0
|
|
344
|
+
@physical_lines << @blank_line
|
|
345
|
+
else
|
|
346
|
+
hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Pops from {@physical_lines} the rows that `hard_line` previously
|
|
351
|
+
# contributed (the inverse of {#append_physical_lines} for the same
|
|
352
|
+
# input). Used by {#append} when extending the last hard line: its
|
|
353
|
+
# old wrapped rows are dropped, then the extended hard line is
|
|
354
|
+
# re-wrapped and appended.
|
|
355
|
+
# @param hard_line [StyledString]
|
|
356
|
+
# @param width [Integer]
|
|
357
|
+
# @return [void]
|
|
358
|
+
def drop_physical_rows_for(hard_line, width)
|
|
359
|
+
count = hard_line.empty? || width <= 0 ? 1 : hard_line.wrap(width).size
|
|
360
|
+
count.times { @physical_lines.pop }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
|
|
364
|
+
# default-styled `"\n"` between hard lines. Called from the {#text}
|
|
365
|
+
# reader when the cache is cold. Cost is O(total spans).
|
|
366
|
+
# @return [StyledString]
|
|
367
|
+
def build_text
|
|
368
|
+
return StyledString::EMPTY if @hard_lines.empty?
|
|
369
|
+
return @hard_lines.first if @hard_lines.size == 1
|
|
370
|
+
|
|
371
|
+
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
372
|
+
spans = []
|
|
373
|
+
@hard_lines.each_with_index do |hl, i|
|
|
374
|
+
spans << newline if i.positive?
|
|
375
|
+
spans.concat(hl.spans)
|
|
376
|
+
end
|
|
377
|
+
StyledString.new(spans)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# @return [Size] {#content_size} computed from {@hard_lines}.
|
|
381
|
+
def compute_content_size
|
|
382
|
+
return Size::ZERO if @hard_lines.empty?
|
|
383
|
+
|
|
384
|
+
Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @return [Integer] column width available for wrapped text — viewport
|
|
388
|
+
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
389
|
+
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
390
|
+
def wrap_width
|
|
391
|
+
return 0 if rect.width <= 0
|
|
392
|
+
|
|
393
|
+
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
397
|
+
# @return [void]
|
|
398
|
+
def move_top_line_by(delta)
|
|
399
|
+
move_top_line_to(@top_line + delta)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
|
|
403
|
+
# @return [void]
|
|
404
|
+
def move_top_line_to(target)
|
|
405
|
+
clamped = target.clamp(0, top_line_max)
|
|
406
|
+
self.top_line = clamped unless @top_line == clamped
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# @return [void]
|
|
410
|
+
def update_top_line_if_auto_scroll
|
|
411
|
+
return unless @auto_scroll
|
|
412
|
+
|
|
413
|
+
target = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
414
|
+
self.top_line = target if @top_line != target
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# @return [Boolean]
|
|
418
|
+
def scrollbar_visible?
|
|
419
|
+
return false if rect.empty?
|
|
420
|
+
|
|
421
|
+
@scrollbar_visibility == :visible
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Pads `line` with trailing default-styled spaces out to `width` display
|
|
425
|
+
# columns. Callers rely on {StyledString#wrap} having already
|
|
426
|
+
# constrained the line to `<= width`, so no truncation is performed.
|
|
427
|
+
# `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
|
|
428
|
+
# `wrap_width == 0` case (rect.width == 1 with scrollbar).
|
|
429
|
+
# @param line [StyledString]
|
|
430
|
+
# @param width [Integer]
|
|
431
|
+
# @return [StyledString]
|
|
432
|
+
def pad_to(line, width)
|
|
433
|
+
return StyledString::EMPTY if width <= 0
|
|
434
|
+
|
|
435
|
+
diff = width - line.display_width
|
|
436
|
+
return line if diff <= 0
|
|
437
|
+
|
|
438
|
+
line + StyledString.plain(" " * diff)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# @param index [Integer] 0-based index into `@physical_lines`.
|
|
442
|
+
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
443
|
+
# @param scrollbar [VerticalScrollBar, nil]
|
|
444
|
+
# @return [String] paintable ANSI-encoded line exactly `rect.width`
|
|
445
|
+
# columns wide. Body lines come pre-padded from {#rewrap}, so this
|
|
446
|
+
# reduces to a memoized {StyledString#to_ansi} read plus an
|
|
447
|
+
# ASCII-string concat of the scrollbar glyph when one is present.
|
|
448
|
+
def paintable_line(index, row_in_viewport, scrollbar)
|
|
449
|
+
line = @physical_lines[index] || @blank_line
|
|
450
|
+
return line.to_ansi unless scrollbar
|
|
451
|
+
|
|
452
|
+
line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
@@ -8,8 +8,9 @@ module Tuile
|
|
|
8
8
|
#
|
|
9
9
|
# The window's `content` is unset by default; assign one via {#content=}.
|
|
10
10
|
#
|
|
11
|
-
# Window is considered invisible if {#rect} is empty
|
|
12
|
-
#
|
|
11
|
+
# Window is considered invisible if {#rect} is empty. The window won't
|
|
12
|
+
# draw when invisible. (Repaint of detached windows is short-circuited
|
|
13
|
+
# by {Component#invalidate}; subclasses don't need to re-check.)
|
|
13
14
|
class Window < Component
|
|
14
15
|
include Component::HasContent
|
|
15
16
|
|
|
@@ -90,9 +91,9 @@ module Tuile
|
|
|
90
91
|
# @param value [Boolean]
|
|
91
92
|
# @return [void]
|
|
92
93
|
def scrollbar=(value)
|
|
93
|
-
unless content.
|
|
94
|
+
unless content.respond_to?(:scrollbar_visibility=)
|
|
94
95
|
raise Tuile::Error,
|
|
95
|
-
"scrollbar= requires a
|
|
96
|
+
"scrollbar= requires a content component that supports scrollbar_visibility=, got #{content.inspect}"
|
|
96
97
|
end
|
|
97
98
|
|
|
98
99
|
content.scrollbar_visibility = value ? :visible : :gone
|
|
@@ -125,12 +126,6 @@ module Tuile
|
|
|
125
126
|
Size.new(inner_w + 2, inner_h + 2)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
|
-
# @return [Boolean] true if {#rect} is off screen and the window won't
|
|
129
|
-
# paint.
|
|
130
|
-
def visible?
|
|
131
|
-
!@rect.empty? && !@rect.top.negative? && !@rect.left.negative?
|
|
132
|
-
end
|
|
133
|
-
|
|
134
129
|
# Fully repaints the window: both frame and contents.
|
|
135
130
|
#
|
|
136
131
|
# Window deliberately paints over its entire rect (border around the
|
|
@@ -143,7 +138,7 @@ module Tuile
|
|
|
143
138
|
# cycle.
|
|
144
139
|
# @return [void]
|
|
145
140
|
def repaint
|
|
146
|
-
return
|
|
141
|
+
return if rect.empty?
|
|
147
142
|
|
|
148
143
|
super
|
|
149
144
|
repaint_border
|
|
@@ -168,7 +163,7 @@ module Tuile
|
|
|
168
163
|
# Paints the window border.
|
|
169
164
|
# @return [void]
|
|
170
165
|
def repaint_border
|
|
171
|
-
return
|
|
166
|
+
return if rect.empty?
|
|
172
167
|
|
|
173
168
|
frame = build_frame(frame_caption)
|
|
174
169
|
frame = Rainbow(frame).green if active?
|
data/lib/tuile/component.rb
CHANGED
|
@@ -4,8 +4,10 @@ module Tuile
|
|
|
4
4
|
# A UI component which is positioned on the screen and draws characters into
|
|
5
5
|
# its bounding rectangle (in {#repaint}).
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Painting is gated by attachment: a detached component (one whose {#root}
|
|
8
|
+
# isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
|
|
9
|
+
# any stale invalidation entries are filtered out at drain time. Subclasses
|
|
10
|
+
# can paint freely in {#repaint} without re-asserting attachment.
|
|
9
11
|
class Component
|
|
10
12
|
def initialize
|
|
11
13
|
@rect = Rect.new(0, 0, 0, 0)
|
|
@@ -66,9 +68,11 @@ module Tuile
|
|
|
66
68
|
# responsibility for {#rect}. Everything else should call super.
|
|
67
69
|
#
|
|
68
70
|
# A component must not draw outside of {#rect}.
|
|
71
|
+
#
|
|
72
|
+
# Only called when the component is attached.
|
|
69
73
|
# @return [void]
|
|
70
74
|
def repaint
|
|
71
|
-
return if rect.empty?
|
|
75
|
+
return if rect.empty?
|
|
72
76
|
return if children.any? && children_tile_rect?
|
|
73
77
|
|
|
74
78
|
clear_background
|
|
@@ -251,8 +255,16 @@ module Tuile
|
|
|
251
255
|
|
|
252
256
|
# Invalidates the component: {Screen} records this component as
|
|
253
257
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
258
|
+
#
|
|
259
|
+
# No-op when the component is not {#attached?} — a detached component has
|
|
260
|
+
# no place on the screen to paint to, so {Screen} must never end up
|
|
261
|
+
# repainting it. Callers don't need to guard their own `invalidate` calls;
|
|
262
|
+
# mutating a detached component (e.g. setting `lines=` on a {List} sitting
|
|
263
|
+
# inside a closed {Component::Popup}) is silent.
|
|
254
264
|
# @return [void]
|
|
255
265
|
def invalidate
|
|
266
|
+
return unless attached?
|
|
267
|
+
|
|
256
268
|
screen.invalidate(self)
|
|
257
269
|
end
|
|
258
270
|
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -42,19 +42,71 @@ module Tuile
|
|
|
42
42
|
# @return [String]
|
|
43
43
|
PAGE_DOWN = "\e[6~"
|
|
44
44
|
# @return [String]
|
|
45
|
-
BACKSPACE = "
|
|
45
|
+
BACKSPACE = "\x7f"
|
|
46
46
|
# @return [String]
|
|
47
47
|
DELETE = "\e[3~"
|
|
48
|
+
|
|
49
|
+
# Ctrl+letter sends bytes 0x01..0x1a. Note that {CTRL_H} == `"\b"`,
|
|
50
|
+
# {CTRL_I} == {TAB}, {CTRL_J} == `"\n"`, and {CTRL_M} == {ENTER} —
|
|
51
|
+
# terminals deliver these key combinations indistinguishably from the
|
|
52
|
+
# corresponding named keys.
|
|
53
|
+
# @return [String]
|
|
54
|
+
CTRL_A = "\x01"
|
|
55
|
+
# @return [String]
|
|
56
|
+
CTRL_B = "\x02"
|
|
57
|
+
# @return [String]
|
|
58
|
+
CTRL_C = "\x03"
|
|
59
|
+
# @return [String]
|
|
60
|
+
CTRL_D = "\x04"
|
|
61
|
+
# @return [String]
|
|
62
|
+
CTRL_E = "\x05"
|
|
63
|
+
# @return [String]
|
|
64
|
+
CTRL_F = "\x06"
|
|
65
|
+
# @return [String]
|
|
66
|
+
CTRL_G = "\x07"
|
|
48
67
|
# @return [String]
|
|
49
68
|
CTRL_H = "\b"
|
|
50
|
-
# @return [Array<String>]
|
|
51
|
-
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
52
69
|
# @return [String]
|
|
53
|
-
|
|
70
|
+
CTRL_I = "\t"
|
|
71
|
+
# @return [String]
|
|
72
|
+
CTRL_J = "\n"
|
|
73
|
+
# @return [String]
|
|
74
|
+
CTRL_K = "\x0b"
|
|
75
|
+
# @return [String]
|
|
76
|
+
CTRL_L = "\x0c"
|
|
77
|
+
# @return [String]
|
|
78
|
+
CTRL_M = "\r"
|
|
79
|
+
# @return [String]
|
|
80
|
+
CTRL_N = "\x0e"
|
|
81
|
+
# @return [String]
|
|
82
|
+
CTRL_O = "\x0f"
|
|
83
|
+
# @return [String]
|
|
84
|
+
CTRL_P = "\x10"
|
|
54
85
|
# @return [String]
|
|
55
|
-
|
|
86
|
+
CTRL_Q = "\x11"
|
|
56
87
|
# @return [String]
|
|
57
|
-
|
|
88
|
+
CTRL_R = "\x12"
|
|
89
|
+
# @return [String]
|
|
90
|
+
CTRL_S = "\x13"
|
|
91
|
+
# @return [String]
|
|
92
|
+
CTRL_T = "\x14"
|
|
93
|
+
# @return [String]
|
|
94
|
+
CTRL_U = "\x15"
|
|
95
|
+
# @return [String]
|
|
96
|
+
CTRL_V = "\x16"
|
|
97
|
+
# @return [String]
|
|
98
|
+
CTRL_W = "\x17"
|
|
99
|
+
# @return [String]
|
|
100
|
+
CTRL_X = "\x18"
|
|
101
|
+
# @return [String]
|
|
102
|
+
CTRL_Y = "\x19"
|
|
103
|
+
# @return [String]
|
|
104
|
+
CTRL_Z = "\x1a"
|
|
105
|
+
|
|
106
|
+
# @return [Array<String>]
|
|
107
|
+
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
108
|
+
# @return [String]
|
|
109
|
+
ENTER = "\r"
|
|
58
110
|
# @return [String]
|
|
59
111
|
TAB = "\t"
|
|
60
112
|
# The terminal sequence emitted by Shift+Tab in xterm-style terminals
|
|
@@ -62,6 +114,22 @@ module Tuile
|
|
|
62
114
|
# @return [String]
|
|
63
115
|
SHIFT_TAB = "\e[Z"
|
|
64
116
|
|
|
117
|
+
# True iff `key` is a single printable character — a one-character string
|
|
118
|
+
# whose codepoint is not in Unicode's C (Other) category. Rejects multi-
|
|
119
|
+
# character escape sequences ({UP_ARROW}, mouse events, …), control bytes
|
|
120
|
+
# ({TAB}, {ENTER}, {ESC}, {CTRL_A}..{CTRL_Z}, {BACKSPACE}), and the empty
|
|
121
|
+
# string; accepts ASCII letters/digits/punctuation/space *and* non-ASCII
|
|
122
|
+
# printables like "é".
|
|
123
|
+
#
|
|
124
|
+
# Used by {Screen#register_global_shortcut} to reject keys that would
|
|
125
|
+
# collide with typing, and by {Tuile::Component::TextField} to decide
|
|
126
|
+
# whether to insert a key at the caret.
|
|
127
|
+
# @param key [String]
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
def self.printable?(key)
|
|
130
|
+
key.length == 1 && !key.match?(/\p{C}/)
|
|
131
|
+
end
|
|
132
|
+
|
|
65
133
|
# Grabs a key from stdin and returns it. Blocks until the key is obtained.
|
|
66
134
|
# Reads a full ESC key sequence; see constants above for some values returned
|
|
67
135
|
# by this function.
|
|
@@ -72,11 +140,26 @@ module Tuile
|
|
|
72
140
|
|
|
73
141
|
# Escape sequence. Try to read more data.
|
|
74
142
|
begin
|
|
75
|
-
# Read
|
|
76
|
-
|
|
143
|
+
# Read up to 5 bytes: that's the maximum tail length of any escape
|
|
144
|
+
# sequence Tuile recognizes after the initial \e (X10 mouse `[Mbxy`,
|
|
145
|
+
# CTRL+arrow `[1;5D`, etc.). Reading 6 here would over-read into the
|
|
146
|
+
# next sequence on tight mouse-event bursts — we'd silently steal
|
|
147
|
+
# the next event's leading \e and the rest of it would surface as
|
|
148
|
+
# individual printable keypresses in focused inputs.
|
|
149
|
+
char += $stdin.read_nonblock(5)
|
|
77
150
|
rescue IO::EAGAINWaitReadable
|
|
78
151
|
# The "ESC" key pressed => only the \e char is emitted.
|
|
152
|
+
return char
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# If `read_nonblock` returned a partial X10 mouse-report prefix (the
|
|
156
|
+
# sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
|
|
157
|
+
# with a blocking read so the parser downstream sees a complete event
|
|
158
|
+
# instead of leaking tail bytes as keypresses.
|
|
159
|
+
if char.start_with?("\e[M") && char.bytesize < 6
|
|
160
|
+
char += $stdin.read(6 - char.bytesize)
|
|
79
161
|
end
|
|
162
|
+
|
|
80
163
|
char
|
|
81
164
|
end
|
|
82
165
|
end
|