tuile 0.6.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 +16 -0
- data/README.md +1 -1
- data/examples/sampler.rb +112 -3
- 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 +1 -13
- data/lib/tuile/component/list.rb +45 -23
- data/lib/tuile/component/log_window.rb +21 -5
- data/lib/tuile/component/picker_window.rb +8 -6
- data/lib/tuile/component/popup.rb +69 -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 +30 -10
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +30 -26
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/keys.rb +2 -6
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +109 -113
- data/lib/tuile/screen_pane.rb +81 -20
- data/lib/tuile/styled_string.rb +164 -59
- data/lib/tuile/version.rb +1 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +639 -133
- metadata +10 -4
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]
|
|
@@ -34,7 +34,7 @@ module Tuile
|
|
|
34
34
|
# @return [void]
|
|
35
35
|
def add(child)
|
|
36
36
|
if child.is_a? Enumerable
|
|
37
|
-
child.each { add(
|
|
37
|
+
child.each { add(_1) }
|
|
38
38
|
else
|
|
39
39
|
raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
|
|
40
40
|
raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
|
|
@@ -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
|