tuile 0.7.0 → 0.8.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/examples/sampler.rb +109 -0
- data/ideas/back-buffer.md +217 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +412 -0
- data/lib/tuile/component/button.rb +2 -5
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/label.rb +8 -8
- data/lib/tuile/component/layout.rb +0 -12
- data/lib/tuile/component/list.rb +10 -11
- data/lib/tuile/component/log_window.rb +20 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +48 -13
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +6 -7
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +29 -25
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/screen.rb +90 -100
- data/lib/tuile/screen_pane.rb +80 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +511 -112
- metadata +4 -2
data/lib/tuile/buffer.rb
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# An in-memory grid of styled cells mirroring the terminal screen. This is
|
|
5
|
+
# the back buffer behind flicker-free rendering: components paint into it
|
|
6
|
+
# (via {#set_line} / {#set_char} / {#fill}) instead of writing escape
|
|
7
|
+
# sequences straight to the terminal, and {#flush} emits the minimal escape
|
|
8
|
+
# string needed to bring a terminal — one that already matches the buffer's
|
|
9
|
+
# state as of the previous flush — up to date. Only cells that actually
|
|
10
|
+
# changed are emitted, so nothing flickers regardless of terminal/multiplexer
|
|
11
|
+
# synchronized-output support. See `ideas/back-buffer.md`.
|
|
12
|
+
#
|
|
13
|
+
# Coordinates are 0-based `(x, y)` = `(column, row)`, matching
|
|
14
|
+
# {Component#rect} and `TTY::Cursor.move_to`.
|
|
15
|
+
#
|
|
16
|
+
# ## Dirty tracking
|
|
17
|
+
#
|
|
18
|
+
# Every mutator compares the incoming grapheme+style against what's already
|
|
19
|
+
# there and records the cell dirty only when it differs — so both mutation
|
|
20
|
+
# and {#flush} cost scale with what actually changed, never with the buffer
|
|
21
|
+
# size. There is deliberately no per-frame whole-buffer clear or copy;
|
|
22
|
+
# un-touched cells retain the previous frame's value.
|
|
23
|
+
#
|
|
24
|
+
# The bookkeeping avoids hashing and full-grid scans: a dirty flag **on each
|
|
25
|
+
# cell** (O(1) set, no `Set` bucket math, no separate array), a per-row
|
|
26
|
+
# boolean so {#flush} scans only the rows that changed, and one global flag
|
|
27
|
+
# so {#dirty?} and the "nothing changed" early-out are O(1). {#flush} clears
|
|
28
|
+
# every flag it consumes.
|
|
29
|
+
#
|
|
30
|
+
# Cells are **mutable and pre-allocated**: the grid builds its {Cell}s once
|
|
31
|
+
# (at construction and {#resize}) and rewrites them in place, so a normal
|
|
32
|
+
# paint allocates nothing per cell. That is why {Cell} is a plain mutable
|
|
33
|
+
# object rather than a frozen value type. The empty state of a cell is a
|
|
34
|
+
# space in the default style.
|
|
35
|
+
#
|
|
36
|
+
# ## Wide characters
|
|
37
|
+
#
|
|
38
|
+
# A 2-column glyph (fullwidth CJK, most emoji) occupies its origin cell plus a
|
|
39
|
+
# **continuation** cell to its right (an empty-grapheme {Cell} the flush emits
|
|
40
|
+
# nothing for, since the glyph itself advances the cursor two columns).
|
|
41
|
+
# Overwriting either half of a wide glyph blanks the orphaned half, so the
|
|
42
|
+
# grid never holds a dangling continuation or a headless one.
|
|
43
|
+
class Buffer
|
|
44
|
+
# One screen cell: a single grapheme cluster, the {StyledString::Style} it's
|
|
45
|
+
# drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
|
|
46
|
+
# tracking") — the grid rewrites cells in place. A continuation cell (right
|
|
47
|
+
# half of a wide glyph) carries an empty grapheme — see {#continuation?}.
|
|
48
|
+
class Cell
|
|
49
|
+
# Read-only: mutate content through {#set} so dirty tracking stays correct.
|
|
50
|
+
# @return [String] one grapheme cluster, `" "` for blank, or `""` for a
|
|
51
|
+
# wide-glyph continuation.
|
|
52
|
+
attr_reader :grapheme
|
|
53
|
+
|
|
54
|
+
# @return [StyledString::Style]
|
|
55
|
+
attr_reader :style
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true if this cell changed since the last {Buffer#flush}.
|
|
58
|
+
# {Buffer} flips it (off as it flushes, on via {Buffer#mark_all_dirty}).
|
|
59
|
+
attr_accessor :dirty
|
|
60
|
+
|
|
61
|
+
# @param grapheme [String]
|
|
62
|
+
# @param style [StyledString::Style]
|
|
63
|
+
def initialize(grapheme, style)
|
|
64
|
+
@grapheme = grapheme
|
|
65
|
+
@style = style
|
|
66
|
+
@dirty = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] true if this is the right half of a wide glyph, which
|
|
70
|
+
# {Buffer#flush} skips (the glyph to the left already moved the cursor
|
|
71
|
+
# past it).
|
|
72
|
+
def continuation? = @grapheme.empty?
|
|
73
|
+
|
|
74
|
+
# Sets the cell's content, flipping {#dirty} on when grapheme or style
|
|
75
|
+
# actually changes (an already-dirty cell stays dirty). Returns the
|
|
76
|
+
# resulting dirty flag, so callers can aggregate row/buffer dirty state in
|
|
77
|
+
# one step. The single mutation path behind {Buffer#set_char} / {#fill} /
|
|
78
|
+
# {#clear}.
|
|
79
|
+
# @param grapheme [String]
|
|
80
|
+
# @param style [StyledString::Style]
|
|
81
|
+
# @return [Boolean] {#dirty} after the write.
|
|
82
|
+
def set(grapheme, style)
|
|
83
|
+
return @dirty if @grapheme == grapheme && @style == style
|
|
84
|
+
|
|
85
|
+
@grapheme = grapheme
|
|
86
|
+
@style = style
|
|
87
|
+
@dirty = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Content equality (grapheme + style); the dirty flag is bookkeeping and
|
|
91
|
+
# is deliberately excluded.
|
|
92
|
+
# @param other [Object]
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def ==(other)
|
|
95
|
+
other.is_a?(Cell) && @grapheme == other.grapheme && @style == other.style
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [StyledString::Style] the unstyled default.
|
|
100
|
+
DEFAULT_STYLE = StyledString::Style::DEFAULT
|
|
101
|
+
private_constant :DEFAULT_STYLE
|
|
102
|
+
|
|
103
|
+
# @param size [Size] grid dimensions in columns × rows.
|
|
104
|
+
def initialize(size)
|
|
105
|
+
allocate_grid(size)
|
|
106
|
+
# A fresh buffer never matches the terminal yet — the screen holds
|
|
107
|
+
# whatever was there at startup — so it begins fully dirty and the first
|
|
108
|
+
# flush paints the whole grid (gaps included). Same reasoning as {#resize}.
|
|
109
|
+
mark_all_dirty
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Size] grid dimensions.
|
|
113
|
+
def size = Size.new(@width, @height)
|
|
114
|
+
|
|
115
|
+
# @return [Integer]
|
|
116
|
+
attr_reader :width, :height
|
|
117
|
+
|
|
118
|
+
# @param x [Integer] column.
|
|
119
|
+
# @param y [Integer] row.
|
|
120
|
+
# @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
|
|
121
|
+
# {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
|
|
122
|
+
# out of bounds.
|
|
123
|
+
def cell(x, y)
|
|
124
|
+
return nil unless in_bounds?(x, y)
|
|
125
|
+
|
|
126
|
+
@cells[index(x, y)]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Boolean] true if any cell has changed since the last {#flush}.
|
|
130
|
+
def dirty? = @any_dirty
|
|
131
|
+
|
|
132
|
+
# Writes one grapheme cluster at `(x, y)`. A 2-column glyph also writes a
|
|
133
|
+
# continuation cell at `(x + 1, y)`; a wide glyph that would overflow the
|
|
134
|
+
# last column is replaced by a blank (terminals can't render a half-clipped
|
|
135
|
+
# wide glyph). Zero-width input (a lone combining mark) is ignored — it has
|
|
136
|
+
# no cell of its own. Out-of-bounds writes are dropped.
|
|
137
|
+
# @param x [Integer] column.
|
|
138
|
+
# @param y [Integer] row.
|
|
139
|
+
# @param grapheme [String] one grapheme cluster.
|
|
140
|
+
# @param style [StyledString::Style]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def set_char(x, y, grapheme, style = DEFAULT_STYLE)
|
|
143
|
+
return unless in_bounds?(x, y)
|
|
144
|
+
|
|
145
|
+
w = Unicode::DisplayWidth.of(grapheme)
|
|
146
|
+
return if w <= 0
|
|
147
|
+
|
|
148
|
+
if w == 2 && !in_bounds?(x + 1, y)
|
|
149
|
+
repair_orphans(x, y)
|
|
150
|
+
return write_cell(x, y, " ", style)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
repair_orphans(x, y)
|
|
154
|
+
repair_orphans(x + 1, y) if w == 2
|
|
155
|
+
write_cell(x, y, grapheme, style)
|
|
156
|
+
write_cell(x + 1, y, "", style) if w == 2
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
160
|
+
# display width and clipping at the right edge. The workhorse that replaces
|
|
161
|
+
# the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
|
|
162
|
+
# paint. Newlines in the string are not handled — pass one physical line.
|
|
163
|
+
# @param x [Integer] starting column.
|
|
164
|
+
# @param y [Integer] row.
|
|
165
|
+
# @param styled [StyledString]
|
|
166
|
+
# @return [void]
|
|
167
|
+
def set_line(x, y, styled)
|
|
168
|
+
col = x
|
|
169
|
+
styled.spans.each do |span|
|
|
170
|
+
span.text.grapheme_clusters.each do |g|
|
|
171
|
+
w = Unicode::DisplayWidth.of(g)
|
|
172
|
+
next if w <= 0 # combining mark with no base in this run: skip
|
|
173
|
+
|
|
174
|
+
break if col >= @width # rest of the line is clipped
|
|
175
|
+
|
|
176
|
+
set_char(col, y, g, span.style)
|
|
177
|
+
col += w
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Fills the intersection of `rect` and the buffer with blank cells in
|
|
183
|
+
# `style` — the cell-grid equivalent of clearing a background. Only `bg`
|
|
184
|
+
# shows; the grapheme is a space.
|
|
185
|
+
# @param rect [Rect]
|
|
186
|
+
# @param style [StyledString::Style]
|
|
187
|
+
# @return [void]
|
|
188
|
+
def fill(rect, style = DEFAULT_STYLE)
|
|
189
|
+
top = [rect.top, 0].max
|
|
190
|
+
bottom = [rect.top + rect.height, @height].min
|
|
191
|
+
left = [rect.left, 0].max
|
|
192
|
+
right = [rect.left + rect.width, @width].min
|
|
193
|
+
y = top
|
|
194
|
+
while y < bottom
|
|
195
|
+
x = left
|
|
196
|
+
while x < right
|
|
197
|
+
write_cell(x, y, " ", style)
|
|
198
|
+
x += 1
|
|
199
|
+
end
|
|
200
|
+
y += 1
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Blanks the entire buffer in `style`. A flat pass over every cell — no
|
|
205
|
+
# rect math or nested loops, since it covers the whole grid. Only cells
|
|
206
|
+
# that actually change are marked dirty (and their rows), so a {#flush}
|
|
207
|
+
# after clearing an already-blank buffer emits nothing.
|
|
208
|
+
# @param style [StyledString::Style]
|
|
209
|
+
# @return [void]
|
|
210
|
+
def clear(style = DEFAULT_STYLE)
|
|
211
|
+
@cells.each_with_index do |c, i|
|
|
212
|
+
next unless c.set(" ", style)
|
|
213
|
+
|
|
214
|
+
@dirty_rows[i / @width] = true
|
|
215
|
+
@any_dirty = true
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Marks every cell dirty, so the next {#flush} re-emits the whole grid.
|
|
220
|
+
# Used after a resize and whenever the terminal contents become unknown
|
|
221
|
+
# (e.g. the screen was cleared underneath us).
|
|
222
|
+
# @return [void]
|
|
223
|
+
def mark_all_dirty
|
|
224
|
+
@cells.each { |c| c.dirty = true }
|
|
225
|
+
@dirty_rows.fill(true)
|
|
226
|
+
@any_dirty = true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Resizes the grid to `size`, reallocating blank cells and marking the
|
|
230
|
+
# whole buffer dirty — after a resize the terminal contents are undefined,
|
|
231
|
+
# so the next flush redraws from scratch.
|
|
232
|
+
# @param size [Size]
|
|
233
|
+
# @return [void]
|
|
234
|
+
def resize(size)
|
|
235
|
+
allocate_grid(size)
|
|
236
|
+
mark_all_dirty
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Emits the minimal escape sequence that updates a terminal — already
|
|
240
|
+
# matching this buffer as of the previous flush — to the current contents,
|
|
241
|
+
# then clears the dirty flags. Returns `""` when nothing changed.
|
|
242
|
+
#
|
|
243
|
+
# Scans only dirty rows; within a row, consecutive dirty cells form one run
|
|
244
|
+
# (one `TTY::Cursor.move_to` followed by their graphemes), with a running
|
|
245
|
+
# {StyledString::Style#sgr_to} diff so only changed attributes are sent
|
|
246
|
+
# (continuation cells emit nothing). The sequence always ends in the default
|
|
247
|
+
# style ({Ansi::RESET} when needed), the invariant the next flush relies on:
|
|
248
|
+
# the terminal's SGR state is default at flush boundaries.
|
|
249
|
+
# @return [String] the escape sequence to write to the terminal.
|
|
250
|
+
def flush
|
|
251
|
+
return "" unless @any_dirty
|
|
252
|
+
|
|
253
|
+
out = +""
|
|
254
|
+
style = DEFAULT_STYLE
|
|
255
|
+
y = 0
|
|
256
|
+
while y < @height
|
|
257
|
+
if @dirty_rows[y]
|
|
258
|
+
@dirty_rows[y] = false
|
|
259
|
+
style = flush_row(out, y, style)
|
|
260
|
+
end
|
|
261
|
+
y += 1
|
|
262
|
+
end
|
|
263
|
+
out << Ansi::RESET unless style.default?
|
|
264
|
+
@any_dirty = false
|
|
265
|
+
out
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param y [Integer] row.
|
|
269
|
+
# @return [String] the plain text of row `y` (continuation cells contribute
|
|
270
|
+
# nothing, so wide glyphs read as their single cluster). Intended for
|
|
271
|
+
# tests; see {FakeScreen}.
|
|
272
|
+
def row_text(y)
|
|
273
|
+
return "" unless y >= 0 && y < @height
|
|
274
|
+
|
|
275
|
+
base = y * @width
|
|
276
|
+
(0...@width).map { |x| @cells[base + x].grapheme }.join
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @param y [Integer] row.
|
|
280
|
+
# @return [String] row `y` rendered to ANSI across its full width — the
|
|
281
|
+
# minimal-SGR encoding of its cells, equivalent to what a component's
|
|
282
|
+
# `set_line` of the whole row would have printed. Intended for tests that
|
|
283
|
+
# assert on styled output (see {FakeScreen}); empty for an out-of-range row.
|
|
284
|
+
def row_ansi(y)
|
|
285
|
+
return "" unless y >= 0 && y < @height
|
|
286
|
+
|
|
287
|
+
base = y * @width
|
|
288
|
+
spans = (0...@width).map do |x|
|
|
289
|
+
c = @cells[base + x]
|
|
290
|
+
StyledString::Span.new(text: c.grapheme, style: c.style)
|
|
291
|
+
end
|
|
292
|
+
StyledString.new(spans).to_ansi
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @param rect [Rect]
|
|
296
|
+
# @return [Array<String>] the plain text of each row within `rect`'s column
|
|
297
|
+
# range, top to bottom. The region equivalent of {#row_text}, for asserting
|
|
298
|
+
# what a component painted into its own rect. Intended for tests.
|
|
299
|
+
def region_text(rect)
|
|
300
|
+
region_cells(rect).map { |row| row.map(&:grapheme).join }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @param rect [Rect]
|
|
304
|
+
# @return [Array<String>] each row within `rect` rendered to ANSI, top to
|
|
305
|
+
# bottom — byte-identical to what a component's per-row `set_line` over
|
|
306
|
+
# that rect emitted. The region equivalent of {#row_ansi}. Intended for
|
|
307
|
+
# tests asserting styled output.
|
|
308
|
+
def region_ansi(rect)
|
|
309
|
+
region_cells(rect).map do |row|
|
|
310
|
+
StyledString.new(row.map { |c| StyledString::Span.new(text: c.grapheme, style: c.style) }).to_ansi
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# (Re)allocates a blank grid of `size` with clean dirty state. Callers
|
|
317
|
+
# follow with {#mark_all_dirty} when the terminal doesn't match the new
|
|
318
|
+
# grid — construction and {#resize} both do.
|
|
319
|
+
# @param size [Size]
|
|
320
|
+
# @return [void]
|
|
321
|
+
def allocate_grid(size)
|
|
322
|
+
raise TypeError, "expected Size, got #{size.inspect}" unless size.is_a?(Size)
|
|
323
|
+
|
|
324
|
+
@width = size.width
|
|
325
|
+
@height = size.height
|
|
326
|
+
@cells = Array.new(@width * @height) { Cell.new(" ", DEFAULT_STYLE) }
|
|
327
|
+
@dirty_rows = Array.new(@height, false)
|
|
328
|
+
@any_dirty = false
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Emits the dirty cells of row `y` into `out`, breaking a run at each clean
|
|
332
|
+
# cell, and returns the running style at the end of the row.
|
|
333
|
+
# @param out [String] accumulator.
|
|
334
|
+
# @param y [Integer]
|
|
335
|
+
# @param style [StyledString::Style] style the terminal currently holds.
|
|
336
|
+
# @return [StyledString::Style]
|
|
337
|
+
def flush_row(out, y, style)
|
|
338
|
+
base = y * @width
|
|
339
|
+
run_open = false
|
|
340
|
+
x = 0
|
|
341
|
+
while x < @width
|
|
342
|
+
c = @cells[base + x]
|
|
343
|
+
if c.dirty
|
|
344
|
+
c.dirty = false
|
|
345
|
+
unless run_open
|
|
346
|
+
out << TTY::Cursor.move_to(x, y)
|
|
347
|
+
run_open = true
|
|
348
|
+
end
|
|
349
|
+
unless c.continuation?
|
|
350
|
+
out << style.sgr_to(c.style) << c.grapheme
|
|
351
|
+
style = c.style
|
|
352
|
+
end
|
|
353
|
+
else
|
|
354
|
+
run_open = false
|
|
355
|
+
end
|
|
356
|
+
x += 1
|
|
357
|
+
end
|
|
358
|
+
style
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @param rect [Rect]
|
|
362
|
+
# @return [Array<Array<Cell>>] cells within `rect`, row-major, clamped to
|
|
363
|
+
# the grid (out-of-bounds positions yield a blank cell).
|
|
364
|
+
def region_cells(rect)
|
|
365
|
+
blank = Cell.new(" ", DEFAULT_STYLE)
|
|
366
|
+
(rect.top...(rect.top + rect.height)).map do |y|
|
|
367
|
+
(rect.left...(rect.left + rect.width)).map { |x| cell(x, y) || blank }
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# @param x [Integer] column
|
|
372
|
+
# @param y [Integer] row
|
|
373
|
+
# @return [Integer] flat-array index for `(x, y)`.
|
|
374
|
+
def index(x, y) = (y * @width) + x
|
|
375
|
+
|
|
376
|
+
# @param x [Integer] column
|
|
377
|
+
# @param y [Integer] row
|
|
378
|
+
# @return [Boolean] true when `(x, y)` falls within the grid.
|
|
379
|
+
def in_bounds?(x, y) = x >= 0 && x < @width && y >= 0 && y < @height
|
|
380
|
+
|
|
381
|
+
# Rewrites the cell at `(x, y)` in place, marking it (and its row) dirty
|
|
382
|
+
# only when grapheme or style actually changes. Caller guarantees `(x, y)`
|
|
383
|
+
# is in bounds.
|
|
384
|
+
# @param x [Integer] column
|
|
385
|
+
# @param y [Integer] row
|
|
386
|
+
# @param grapheme [String] the new grapheme cluster
|
|
387
|
+
# @param style [StyledString::Style] the new style
|
|
388
|
+
# @return [void]
|
|
389
|
+
def write_cell(x, y, grapheme, style)
|
|
390
|
+
return unless @cells[index(x, y)].set(grapheme, style)
|
|
391
|
+
|
|
392
|
+
@dirty_rows[y] = true
|
|
393
|
+
@any_dirty = true
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# If `(x, y)` is half of a wide glyph, blanks the *other* half, so a write
|
|
397
|
+
# that lands on either half doesn't strand the remaining one.
|
|
398
|
+
# @param x [Integer] column
|
|
399
|
+
# @param y [Integer] row
|
|
400
|
+
# @return [void]
|
|
401
|
+
def repair_orphans(x, y)
|
|
402
|
+
return unless in_bounds?(x, y)
|
|
403
|
+
|
|
404
|
+
c = @cells[index(x, y)]
|
|
405
|
+
if c.continuation?
|
|
406
|
+
write_cell(x - 1, y, " ", DEFAULT_STYLE) if in_bounds?(x - 1, y)
|
|
407
|
+
elsif Unicode::DisplayWidth.of(c.grapheme) == 2 && in_bounds?(x + 1, y)
|
|
408
|
+
write_cell(x + 1, y, " ", DEFAULT_STYLE)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -49,9 +49,6 @@ module Tuile
|
|
|
49
49
|
# @param key [String]
|
|
50
50
|
# @return [Boolean]
|
|
51
51
|
def handle_key(key)
|
|
52
|
-
return false unless active?
|
|
53
|
-
return true if super
|
|
54
|
-
|
|
55
52
|
case key
|
|
56
53
|
when Keys::ENTER, " "
|
|
57
54
|
@on_click&.call
|
|
@@ -76,8 +73,8 @@ module Tuile
|
|
|
76
73
|
return if rect.empty?
|
|
77
74
|
|
|
78
75
|
label = "[ #{@caption} ]"[0, rect.width]
|
|
79
|
-
styled = active? ? screen.theme.
|
|
80
|
-
screen.
|
|
76
|
+
styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
|
|
77
|
+
screen.buffer.set_line(rect.left, rect.top, styled)
|
|
81
78
|
end
|
|
82
79
|
|
|
83
80
|
private
|
|
@@ -9,12 +9,6 @@ module Tuile
|
|
|
9
9
|
# @return [Component, nil] the current content component.
|
|
10
10
|
attr_reader :content
|
|
11
11
|
|
|
12
|
-
# @param key [String] a key.
|
|
13
|
-
# @return [Boolean] true if the key was handled, false if not.
|
|
14
|
-
def handle_key(key)
|
|
15
|
-
content.nil? || !content.active? ? false : content.handle_key(key)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
12
|
# @param event [MouseEvent]
|
|
19
13
|
# @return [void]
|
|
20
14
|
def handle_mouse(event)
|
|
@@ -70,7 +70,7 @@ module Tuile
|
|
|
70
70
|
|
|
71
71
|
(0...rect.height).each do |row|
|
|
72
72
|
line = @clipped_lines[row] || @blank_line
|
|
73
|
-
screen.
|
|
73
|
+
screen.buffer.set_line(rect.left, rect.top + row, line)
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
@@ -97,16 +97,16 @@ module Tuile
|
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
100
|
-
# Each line is ellipsized to fit
|
|
101
|
-
# the full width,
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
100
|
+
# Each line is ellipsized to fit and padded with trailing spaces out to
|
|
101
|
+
# the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
|
|
102
|
+
# row. {@blank_line} covers rows past the last text line. When {#bg} is
|
|
103
|
+
# set, every produced line (and the blank row) has the bg applied
|
|
104
|
+
# uniformly.
|
|
105
105
|
# @return [void]
|
|
106
106
|
def update_clipped_lines
|
|
107
107
|
width = rect.width.clamp(0, nil)
|
|
108
|
-
@blank_line = apply_bg(StyledString.plain(" " * width))
|
|
109
|
-
@clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width))
|
|
108
|
+
@blank_line = apply_bg(StyledString.plain(" " * width))
|
|
109
|
+
@clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
# @param line [StyledString]
|
|
@@ -75,18 +75,6 @@ module Tuile
|
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
# Called when a character is pressed on the keyboard.
|
|
79
|
-
# @param key [String] a key.
|
|
80
|
-
# @return [Boolean] true if the key was handled, false if not.
|
|
81
|
-
def handle_key(key)
|
|
82
|
-
return true if super
|
|
83
|
-
|
|
84
|
-
sc = @children.find(&:active?)
|
|
85
|
-
return false if sc.nil?
|
|
86
|
-
|
|
87
|
-
sc.handle_key(key)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
78
|
# @return [void]
|
|
91
79
|
def on_focus
|
|
92
80
|
super
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -204,11 +204,7 @@ module Tuile
|
|
|
204
204
|
# @param key [String] a key.
|
|
205
205
|
# @return [Boolean] true if the key was handled.
|
|
206
206
|
def handle_key(key)
|
|
207
|
-
if
|
|
208
|
-
false
|
|
209
|
-
elsif super
|
|
210
|
-
true
|
|
211
|
-
elsif key == Keys::PAGE_UP
|
|
207
|
+
if key == Keys::PAGE_UP
|
|
212
208
|
move_top_line_by(-viewport_lines)
|
|
213
209
|
true
|
|
214
210
|
elsif key == Keys::PAGE_DOWN
|
|
@@ -288,7 +284,7 @@ module Tuile
|
|
|
288
284
|
end
|
|
289
285
|
(0...rect.height).each do |row|
|
|
290
286
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
291
|
-
screen.
|
|
287
|
+
screen.buffer.set_line(rect.left, row + rect.top, line)
|
|
292
288
|
end
|
|
293
289
|
end
|
|
294
290
|
|
|
@@ -296,6 +292,8 @@ module Tuile
|
|
|
296
292
|
class Cursor
|
|
297
293
|
# @param position [Integer] the initial cursor position.
|
|
298
294
|
def initialize(position: 0)
|
|
295
|
+
raise "invalid position #{position}" unless position.is_a? Integer
|
|
296
|
+
|
|
299
297
|
@position = position
|
|
300
298
|
end
|
|
301
299
|
|
|
@@ -430,6 +428,8 @@ module Tuile
|
|
|
430
428
|
# empty.
|
|
431
429
|
# @param position [Integer] initial position.
|
|
432
430
|
def initialize(positions, position: positions[0])
|
|
431
|
+
raise "positions are empty" if positions.empty?
|
|
432
|
+
|
|
433
433
|
@positions = positions.sort
|
|
434
434
|
position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
|
|
435
435
|
super(position: position)
|
|
@@ -759,15 +759,14 @@ module Tuile
|
|
|
759
759
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
760
760
|
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
|
|
761
761
|
# if not shown.
|
|
762
|
-
# @return [
|
|
763
|
-
#
|
|
762
|
+
# @return [StyledString] paintable line exactly `rect.width` columns wide;
|
|
763
|
+
# highlighted if cursor is here.
|
|
764
764
|
def paintable_line(index, row_in_viewport, scrollbar)
|
|
765
765
|
base = index < @lines.size ? @padded_lines[index] : @blank_padded
|
|
766
766
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
767
767
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
out
|
|
768
|
+
styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
|
|
769
|
+
styled
|
|
771
770
|
end
|
|
772
771
|
end
|
|
773
772
|
end
|
|
@@ -15,14 +15,29 @@ module Tuile
|
|
|
15
15
|
# @param caption [String]
|
|
16
16
|
def initialize(caption = "Log")
|
|
17
17
|
super
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
view = Component::TextView.new
|
|
19
|
+
# Word-wrap long lines (stacktraces, wide log records) rather than
|
|
20
|
+
# ellipsizing them as a {List} would — a truncated log line hides the
|
|
21
|
+
# very detail you opened the log to read.
|
|
22
|
+
view.auto_scroll = true
|
|
23
|
+
self.content = view
|
|
23
24
|
self.scrollbar = true
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# Keep the log pane at least half the screen tall even when only a few
|
|
28
|
+
# lines have been logged: a {Component::Popup} sizes to its content, which
|
|
29
|
+
# would collapse a near-empty log to two or three rows. Advice consulted
|
|
30
|
+
# by {Component::Popup#min_height} when this window is a popup's content.
|
|
31
|
+
# @return [Integer]
|
|
32
|
+
def popup_min_height = screen.size.height / 2
|
|
33
|
+
|
|
34
|
+
# Let a busy log grow past the popup's base 12-row cap (up to the
|
|
35
|
+
# 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
|
|
36
|
+
# diagnostic stream stays scrollable in a tall window. Advice consulted
|
|
37
|
+
# by {Component::Popup#max_height} when this window is a popup's content.
|
|
38
|
+
# @return [Integer]
|
|
39
|
+
def popup_max_height = screen.size.height
|
|
40
|
+
|
|
26
41
|
# Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
|
|
27
42
|
# @param string [String, nil] the line (or multiple lines) to log.
|
|
28
43
|
# @return [void]
|
|
@@ -50,11 +50,13 @@ module Tuile
|
|
|
50
50
|
# @return [Proc, nil]
|
|
51
51
|
attr_accessor :on_pick
|
|
52
52
|
|
|
53
|
+
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
54
|
+
# (the focused component) sees the key first and handles cursor/Enter
|
|
55
|
+
# picks; anything it declines bubbles up here, where a key matching an
|
|
56
|
+
# option's `key` picks that option.
|
|
53
57
|
# @param key [String]
|
|
54
58
|
# @return [Boolean]
|
|
55
59
|
def handle_key(key)
|
|
56
|
-
return true if super
|
|
57
|
-
|
|
58
60
|
if @options.any? { _1.key == key }
|
|
59
61
|
select_option(key)
|
|
60
62
|
true
|