tuile 0.7.0 → 0.9.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 +26 -0
- data/README.md +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +109 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +481 -0
- data/lib/tuile/component/button.rb +3 -14
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +15 -23
- data/lib/tuile/component/layout.rb +0 -21
- data/lib/tuile/component/list.rb +10 -37
- data/lib/tuile/component/log_window.rb +6 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +85 -55
- 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 -30
- data/lib/tuile/component/window.rb +77 -112
- data/lib/tuile/component.rb +16 -71
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +98 -105
- data/lib/tuile/screen_pane.rb +82 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +653 -282
- metadata +13 -3
- data/lib/tuile/sizing.rb +0 -59
data/lib/tuile/buffer.rb
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
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.
|
|
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
|
+
#
|
|
44
|
+
# ## Future direction
|
|
45
|
+
#
|
|
46
|
+
# Components paint through this drawing surface ({#set_line} / {#set_char})
|
|
47
|
+
# without knowing whether it is the one global buffer or a private one — that
|
|
48
|
+
# indirection is deliberate, so per-component back buffers plus a z-order
|
|
49
|
+
# compositor could drop in without touching component code. It is not worth
|
|
50
|
+
# doing yet: the diff already drops unchanged cells from the wire, and an
|
|
51
|
+
# occluded component that didn't change is never repainted at all, so a
|
|
52
|
+
# compositor would only save residual `repaint` CPU. It pays off in exactly
|
|
53
|
+
# one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
|
|
54
|
+
# component on a large screen, where re-rendering the content each repeat is
|
|
55
|
+
# the dominant cost.
|
|
56
|
+
class Buffer
|
|
57
|
+
# One screen cell: a single grapheme cluster, the {StyledString::Style} it's
|
|
58
|
+
# drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
|
|
59
|
+
# tracking") — the grid rewrites cells in place. A continuation cell (right
|
|
60
|
+
# half of a wide glyph) carries an empty grapheme — see {#continuation?}.
|
|
61
|
+
class Cell
|
|
62
|
+
# Read-only: mutate content through {#set} so dirty tracking stays correct.
|
|
63
|
+
# @return [String] one grapheme cluster, `" "` for blank, or `""` for a
|
|
64
|
+
# wide-glyph continuation.
|
|
65
|
+
attr_reader :grapheme
|
|
66
|
+
|
|
67
|
+
# @return [StyledString::Style]
|
|
68
|
+
attr_reader :style
|
|
69
|
+
|
|
70
|
+
# @return [Boolean] true if this cell changed since the last {Buffer#flush}.
|
|
71
|
+
# {Buffer} flips it (off as it flushes, on via {Buffer#mark_all_dirty}).
|
|
72
|
+
attr_accessor :dirty
|
|
73
|
+
|
|
74
|
+
# @param grapheme [String]
|
|
75
|
+
# @param style [StyledString::Style]
|
|
76
|
+
def initialize(grapheme, style)
|
|
77
|
+
@grapheme = grapheme
|
|
78
|
+
@style = style
|
|
79
|
+
@dirty = false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean] true if this is the right half of a wide glyph, which
|
|
83
|
+
# {Buffer#flush} skips (the glyph to the left already moved the cursor
|
|
84
|
+
# past it).
|
|
85
|
+
def continuation? = @grapheme.empty?
|
|
86
|
+
|
|
87
|
+
# Sets the cell's content, flipping {#dirty} on when grapheme or style
|
|
88
|
+
# actually changes (an already-dirty cell stays dirty). Returns the
|
|
89
|
+
# resulting dirty flag, so callers can aggregate row/buffer dirty state in
|
|
90
|
+
# one step. The single mutation path behind {Buffer#set_char} / {#fill} /
|
|
91
|
+
# {#clear}.
|
|
92
|
+
# @param grapheme [String]
|
|
93
|
+
# @param style [StyledString::Style]
|
|
94
|
+
# @return [Boolean] {#dirty} after the write.
|
|
95
|
+
def set(grapheme, style)
|
|
96
|
+
return @dirty if @grapheme == grapheme && @style == style
|
|
97
|
+
|
|
98
|
+
@grapheme = grapheme
|
|
99
|
+
@style = style
|
|
100
|
+
@dirty = true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Content equality (grapheme + style); the dirty flag is bookkeeping and
|
|
104
|
+
# is deliberately excluded.
|
|
105
|
+
# @param other [Object]
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def ==(other)
|
|
108
|
+
other.is_a?(Cell) && @grapheme == other.grapheme && @style == other.style
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [StyledString::Style] the unstyled default.
|
|
113
|
+
DEFAULT_STYLE = StyledString::Style::DEFAULT
|
|
114
|
+
private_constant :DEFAULT_STYLE
|
|
115
|
+
|
|
116
|
+
# Memo for {.display_width}: a grapheme's display width is fixed, and a TTY
|
|
117
|
+
# paints from a small, recurring alphabet (ASCII, box-drawing rules, a few
|
|
118
|
+
# emoji), so the per-grapheme width lookup — the dominant cost of a repaint
|
|
119
|
+
# (see `benchmark/display_width.rb`) — collapses to a Hash read after the
|
|
120
|
+
# first sighting. Shared across all buffers and unbounded, but bounded in
|
|
121
|
+
# practice by the font's glyph set; safe to share because all painting runs
|
|
122
|
+
# on the single UI thread (see AGENTS.md "Threading rule").
|
|
123
|
+
# @return [Hash{String => Integer}]
|
|
124
|
+
WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g) }
|
|
125
|
+
private_constant :WIDTH_CACHE
|
|
126
|
+
|
|
127
|
+
# Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
|
|
128
|
+
# lookup instead of calling the gem directly.
|
|
129
|
+
# @param grapheme [String] one grapheme cluster.
|
|
130
|
+
# @return [Integer] its display width in columns (0 for combining marks).
|
|
131
|
+
def self.display_width(grapheme) = WIDTH_CACHE[grapheme]
|
|
132
|
+
|
|
133
|
+
# @param size [Size] grid dimensions in columns × rows.
|
|
134
|
+
def initialize(size)
|
|
135
|
+
allocate_grid(size)
|
|
136
|
+
# A fresh buffer never matches the terminal yet — the screen holds
|
|
137
|
+
# whatever was there at startup — so it begins fully dirty and the first
|
|
138
|
+
# flush paints the whole grid (gaps included). Same reasoning as {#resize}.
|
|
139
|
+
mark_all_dirty
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Size] grid dimensions.
|
|
143
|
+
def size = Size.new(@width, @height)
|
|
144
|
+
|
|
145
|
+
# @return [Integer]
|
|
146
|
+
attr_reader :width, :height
|
|
147
|
+
|
|
148
|
+
# @param x [Integer] column.
|
|
149
|
+
# @param y [Integer] row.
|
|
150
|
+
# @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
|
|
151
|
+
# {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
|
|
152
|
+
# out of bounds.
|
|
153
|
+
def cell(x, y)
|
|
154
|
+
return nil unless in_bounds?(x, y)
|
|
155
|
+
|
|
156
|
+
@cells[index(x, y)]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Boolean] true if any cell has changed since the last {#flush}.
|
|
160
|
+
def dirty? = @any_dirty
|
|
161
|
+
|
|
162
|
+
# Writes one grapheme cluster at `(x, y)`. A 2-column glyph also writes a
|
|
163
|
+
# continuation cell at `(x + 1, y)`; a wide glyph that would overflow the
|
|
164
|
+
# last column is replaced by a blank (terminals can't render a half-clipped
|
|
165
|
+
# wide glyph). Zero-width input (a lone combining mark) is ignored — it has
|
|
166
|
+
# no cell of its own. Out-of-bounds writes are dropped.
|
|
167
|
+
# @param x [Integer] column.
|
|
168
|
+
# @param y [Integer] row.
|
|
169
|
+
# @param grapheme [String] one grapheme cluster.
|
|
170
|
+
# @param style [StyledString::Style]
|
|
171
|
+
# @return [void]
|
|
172
|
+
def set_char(x, y, grapheme, style = DEFAULT_STYLE)
|
|
173
|
+
put_char(x, y, grapheme, Buffer.display_width(grapheme), style)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
177
|
+
# display width and clipping at the right edge. The workhorse that replaces
|
|
178
|
+
# the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
|
|
179
|
+
# paint. Newlines in the string are not handled — pass one physical line.
|
|
180
|
+
# @param x [Integer] starting column.
|
|
181
|
+
# @param y [Integer] row.
|
|
182
|
+
# @param styled [StyledString]
|
|
183
|
+
# @return [void]
|
|
184
|
+
def set_line(x, y, styled)
|
|
185
|
+
col = x
|
|
186
|
+
styled.spans.each do |span|
|
|
187
|
+
span.text.grapheme_clusters.each do |g|
|
|
188
|
+
w = Buffer.display_width(g)
|
|
189
|
+
next if w <= 0 # combining mark with no base in this run: skip
|
|
190
|
+
|
|
191
|
+
break if col >= @width # rest of the line is clipped
|
|
192
|
+
|
|
193
|
+
# Reuse the width we just computed: put_char skips re-measuring `g`.
|
|
194
|
+
put_char(col, y, g, w, span.style)
|
|
195
|
+
col += w
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Fills the intersection of `rect` and the buffer with blank cells in
|
|
201
|
+
# `style` — the cell-grid equivalent of clearing a background. Only `bg`
|
|
202
|
+
# shows; the grapheme is a space.
|
|
203
|
+
# @param rect [Rect]
|
|
204
|
+
# @param style [StyledString::Style]
|
|
205
|
+
# @return [void]
|
|
206
|
+
def fill(rect, style = DEFAULT_STYLE)
|
|
207
|
+
top = [rect.top, 0].max
|
|
208
|
+
bottom = [rect.top + rect.height, @height].min
|
|
209
|
+
left = [rect.left, 0].max
|
|
210
|
+
right = [rect.left + rect.width, @width].min
|
|
211
|
+
y = top
|
|
212
|
+
while y < bottom
|
|
213
|
+
x = left
|
|
214
|
+
while x < right
|
|
215
|
+
write_cell(x, y, " ", style)
|
|
216
|
+
x += 1
|
|
217
|
+
end
|
|
218
|
+
y += 1
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Blanks the entire buffer in `style`. A flat pass over every cell — no
|
|
223
|
+
# rect math or nested loops, since it covers the whole grid. Only cells
|
|
224
|
+
# that actually change are marked dirty (and their rows), so a {#flush}
|
|
225
|
+
# after clearing an already-blank buffer emits nothing.
|
|
226
|
+
# @param style [StyledString::Style]
|
|
227
|
+
# @return [void]
|
|
228
|
+
def clear(style = DEFAULT_STYLE)
|
|
229
|
+
@cells.each_with_index do |c, i|
|
|
230
|
+
next unless c.set(" ", style)
|
|
231
|
+
|
|
232
|
+
@dirty_rows[i / @width] = true
|
|
233
|
+
@any_dirty = true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Marks every cell dirty, so the next {#flush} re-emits the whole grid.
|
|
238
|
+
# Used after a resize and whenever the terminal contents become unknown
|
|
239
|
+
# (e.g. the screen was cleared underneath us).
|
|
240
|
+
# @return [void]
|
|
241
|
+
def mark_all_dirty
|
|
242
|
+
@cells.each { |c| c.dirty = true }
|
|
243
|
+
@dirty_rows.fill(true)
|
|
244
|
+
@any_dirty = true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Resizes the grid to `size`, reallocating blank cells and marking the
|
|
248
|
+
# whole buffer dirty — after a resize the terminal contents are undefined,
|
|
249
|
+
# so the next flush redraws from scratch.
|
|
250
|
+
# @param size [Size]
|
|
251
|
+
# @return [void]
|
|
252
|
+
def resize(size)
|
|
253
|
+
allocate_grid(size)
|
|
254
|
+
mark_all_dirty
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Emits the minimal escape sequence that updates a terminal — already
|
|
258
|
+
# matching this buffer as of the previous flush — to the current contents,
|
|
259
|
+
# then clears the dirty flags. Returns `""` when nothing changed.
|
|
260
|
+
#
|
|
261
|
+
# Scans only dirty rows; within a row, consecutive dirty cells form one run
|
|
262
|
+
# (one `TTY::Cursor.move_to` followed by their graphemes), with a running
|
|
263
|
+
# {StyledString::Style#sgr_to} diff so only changed attributes are sent
|
|
264
|
+
# (continuation cells emit nothing). The sequence always ends in the default
|
|
265
|
+
# style ({Ansi::RESET} when needed), the invariant the next flush relies on:
|
|
266
|
+
# the terminal's SGR state is default at flush boundaries.
|
|
267
|
+
# @return [String] the escape sequence to write to the terminal.
|
|
268
|
+
def flush
|
|
269
|
+
return "" unless @any_dirty
|
|
270
|
+
|
|
271
|
+
out = +""
|
|
272
|
+
style = DEFAULT_STYLE
|
|
273
|
+
y = 0
|
|
274
|
+
while y < @height
|
|
275
|
+
if @dirty_rows[y]
|
|
276
|
+
@dirty_rows[y] = false
|
|
277
|
+
style = flush_row(out, y, style)
|
|
278
|
+
end
|
|
279
|
+
y += 1
|
|
280
|
+
end
|
|
281
|
+
out << Ansi::RESET unless style.default?
|
|
282
|
+
@any_dirty = false
|
|
283
|
+
out
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# @param y [Integer] row.
|
|
287
|
+
# @return [String] the plain text of row `y` (continuation cells contribute
|
|
288
|
+
# nothing, so wide glyphs read as their single cluster). Intended for
|
|
289
|
+
# tests; see {FakeScreen}.
|
|
290
|
+
def row_text(y)
|
|
291
|
+
return "" unless y >= 0 && y < @height
|
|
292
|
+
|
|
293
|
+
base = y * @width
|
|
294
|
+
(0...@width).map { |x| @cells[base + x].grapheme }.join
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @param y [Integer] row.
|
|
298
|
+
# @return [String] row `y` rendered to ANSI across its full width — the
|
|
299
|
+
# minimal-SGR encoding of its cells, equivalent to what a component's
|
|
300
|
+
# `set_line` of the whole row would have printed. Intended for tests that
|
|
301
|
+
# assert on styled output (see {FakeScreen}); empty for an out-of-range row.
|
|
302
|
+
def row_ansi(y)
|
|
303
|
+
return "" unless y >= 0 && y < @height
|
|
304
|
+
|
|
305
|
+
base = y * @width
|
|
306
|
+
spans = (0...@width).map do |x|
|
|
307
|
+
c = @cells[base + x]
|
|
308
|
+
StyledString::Span.new(text: c.grapheme, style: c.style)
|
|
309
|
+
end
|
|
310
|
+
StyledString.new(spans).to_ansi
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# @param rect [Rect]
|
|
314
|
+
# @return [Array<String>] the plain text of each row within `rect`'s column
|
|
315
|
+
# range, top to bottom. The region equivalent of {#row_text}, for asserting
|
|
316
|
+
# what a component painted into its own rect. Intended for tests.
|
|
317
|
+
def region_text(rect)
|
|
318
|
+
region_cells(rect).map { |row| row.map(&:grapheme).join }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @param rect [Rect]
|
|
322
|
+
# @return [Array<String>] each row within `rect` rendered to ANSI, top to
|
|
323
|
+
# bottom — byte-identical to what a component's per-row `set_line` over
|
|
324
|
+
# that rect emitted. The region equivalent of {#row_ansi}. Intended for
|
|
325
|
+
# tests asserting styled output.
|
|
326
|
+
def region_ansi(rect)
|
|
327
|
+
region_cells(rect).map do |row|
|
|
328
|
+
StyledString.new(row.map { |c| StyledString::Span.new(text: c.grapheme, style: c.style) }).to_ansi
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
# Core of {#set_char} with the grapheme's display width already known.
|
|
335
|
+
# {#set_line} computes each width once while advancing the column and passes
|
|
336
|
+
# it straight through, so the paint hot path measures every grapheme exactly
|
|
337
|
+
# once (and that once is a {.display_width} memo read). See {#set_char} for
|
|
338
|
+
# the wide-glyph / clipping / out-of-bounds contract.
|
|
339
|
+
# @param x [Integer] column.
|
|
340
|
+
# @param y [Integer] row.
|
|
341
|
+
# @param grapheme [String] one grapheme cluster.
|
|
342
|
+
# @param w [Integer] `grapheme`'s display width (0, 1, or 2).
|
|
343
|
+
# @param style [StyledString::Style]
|
|
344
|
+
# @return [void]
|
|
345
|
+
def put_char(x, y, grapheme, w, style)
|
|
346
|
+
return unless in_bounds?(x, y)
|
|
347
|
+
return if w <= 0
|
|
348
|
+
|
|
349
|
+
if w == 2 && !in_bounds?(x + 1, y)
|
|
350
|
+
blank_left_partner(x, y)
|
|
351
|
+
return write_cell(x, y, " ", style)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Repair only the glyphs we'd leave half-overwritten on our flanks: a wide
|
|
355
|
+
# glyph whose right half sits at `x`, or one whose left half sits at the
|
|
356
|
+
# last cell we write. The cells we fully rewrite need no pre-blanking —
|
|
357
|
+
# pre-blanking the continuation only to re-empty it would churn it
|
|
358
|
+
# spuriously dirty, which misplaces the next flush onto the glyph's right
|
|
359
|
+
# half (see bug/, balloon corruption).
|
|
360
|
+
blank_left_partner(x, y)
|
|
361
|
+
blank_right_partner(x + w - 1, y)
|
|
362
|
+
write_cell(x, y, grapheme, style)
|
|
363
|
+
write_cell(x + 1, y, "", style) if w == 2
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# (Re)allocates a blank grid of `size` with clean dirty state. Callers
|
|
367
|
+
# follow with {#mark_all_dirty} when the terminal doesn't match the new
|
|
368
|
+
# grid — construction and {#resize} both do.
|
|
369
|
+
# @param size [Size]
|
|
370
|
+
# @return [void]
|
|
371
|
+
def allocate_grid(size)
|
|
372
|
+
raise TypeError, "expected Size, got #{size.inspect}" unless size.is_a?(Size)
|
|
373
|
+
|
|
374
|
+
@width = size.width
|
|
375
|
+
@height = size.height
|
|
376
|
+
@cells = Array.new(@width * @height) { Cell.new(" ", DEFAULT_STYLE) }
|
|
377
|
+
@dirty_rows = Array.new(@height, false)
|
|
378
|
+
@any_dirty = false
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Emits the dirty cells of row `y` into `out`, breaking a run at each clean
|
|
382
|
+
# cell, and returns the running style at the end of the row.
|
|
383
|
+
# @param out [String] accumulator.
|
|
384
|
+
# @param y [Integer]
|
|
385
|
+
# @param style [StyledString::Style] style the terminal currently holds.
|
|
386
|
+
# @return [StyledString::Style]
|
|
387
|
+
def flush_row(out, y, style)
|
|
388
|
+
base = y * @width
|
|
389
|
+
run_open = false
|
|
390
|
+
x = 0
|
|
391
|
+
while x < @width
|
|
392
|
+
c = @cells[base + x]
|
|
393
|
+
if c.dirty
|
|
394
|
+
c.dirty = false
|
|
395
|
+
# A continuation cell (right half of a wide glyph) renders nothing of
|
|
396
|
+
# its own and must never open a run: positioning the cursor onto its
|
|
397
|
+
# column would land the next glyph on the wide glyph's left half,
|
|
398
|
+
# corrupting it. When the wide glyph itself is dirty it is emitted from
|
|
399
|
+
# its origin cell and advances the cursor across this column; when the
|
|
400
|
+
# glyph is intact this column needs no output at all. (A continuation
|
|
401
|
+
# can be left spuriously dirty by an in-place wide-glyph repaint, so we
|
|
402
|
+
# can't assume its origin was emitted in this same run.)
|
|
403
|
+
unless c.continuation?
|
|
404
|
+
unless run_open
|
|
405
|
+
out << TTY::Cursor.move_to(x, y)
|
|
406
|
+
run_open = true
|
|
407
|
+
end
|
|
408
|
+
out << style.sgr_to(c.style) << c.grapheme
|
|
409
|
+
style = c.style
|
|
410
|
+
end
|
|
411
|
+
else
|
|
412
|
+
run_open = false
|
|
413
|
+
end
|
|
414
|
+
x += 1
|
|
415
|
+
end
|
|
416
|
+
style
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# @param rect [Rect]
|
|
420
|
+
# @return [Array<Array<Cell>>] cells within `rect`, row-major, clamped to
|
|
421
|
+
# the grid (out-of-bounds positions yield a blank cell).
|
|
422
|
+
def region_cells(rect)
|
|
423
|
+
blank = Cell.new(" ", DEFAULT_STYLE)
|
|
424
|
+
(rect.top...(rect.top + rect.height)).map do |y|
|
|
425
|
+
(rect.left...(rect.left + rect.width)).map { |x| cell(x, y) || blank }
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# @param x [Integer] column
|
|
430
|
+
# @param y [Integer] row
|
|
431
|
+
# @return [Integer] flat-array index for `(x, y)`.
|
|
432
|
+
def index(x, y) = (y * @width) + x
|
|
433
|
+
|
|
434
|
+
# @param x [Integer] column
|
|
435
|
+
# @param y [Integer] row
|
|
436
|
+
# @return [Boolean] true when `(x, y)` falls within the grid.
|
|
437
|
+
def in_bounds?(x, y) = x >= 0 && x < @width && y >= 0 && y < @height
|
|
438
|
+
|
|
439
|
+
# Rewrites the cell at `(x, y)` in place, marking it (and its row) dirty
|
|
440
|
+
# only when grapheme or style actually changes. Caller guarantees `(x, y)`
|
|
441
|
+
# is in bounds.
|
|
442
|
+
# @param x [Integer] column
|
|
443
|
+
# @param y [Integer] row
|
|
444
|
+
# @param grapheme [String] the new grapheme cluster
|
|
445
|
+
# @param style [StyledString::Style] the new style
|
|
446
|
+
# @return [void]
|
|
447
|
+
def write_cell(x, y, grapheme, style)
|
|
448
|
+
return unless @cells[index(x, y)].set(grapheme, style)
|
|
449
|
+
|
|
450
|
+
@dirty_rows[y] = true
|
|
451
|
+
@any_dirty = true
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
|
|
455
|
+
# orphaned left half at `x - 1`. Called before a write lands on `x`, so the
|
|
456
|
+
# wide glyph to the left isn't left headless.
|
|
457
|
+
# @param x [Integer] column
|
|
458
|
+
# @param y [Integer] row
|
|
459
|
+
# @return [void]
|
|
460
|
+
def blank_left_partner(x, y)
|
|
461
|
+
return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? && in_bounds?(x - 1, y)
|
|
462
|
+
|
|
463
|
+
write_cell(x - 1, y, " ", DEFAULT_STYLE)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# If the cell just right of `(x, y)` is a continuation (the right half of a
|
|
467
|
+
# wide glyph whose origin is `(x, y)`), blanks it. Called before a write
|
|
468
|
+
# lands on `x`, so overwriting a wide origin doesn't strand its continuation.
|
|
469
|
+
# A continuation can only ever belong to the wide glyph immediately to its
|
|
470
|
+
# left, so the empty-grapheme test is exact — and cheaper than re-measuring
|
|
471
|
+
# the origin's width.
|
|
472
|
+
# @param x [Integer] column
|
|
473
|
+
# @param y [Integer] row
|
|
474
|
+
# @return [void]
|
|
475
|
+
def blank_right_partner(x, y)
|
|
476
|
+
return unless in_bounds?(x + 1, y) && @cells[index(x + 1, y)].continuation?
|
|
477
|
+
|
|
478
|
+
write_cell(x + 1, y, " ", DEFAULT_STYLE)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
@@ -12,7 +12,7 @@ module Tuile
|
|
|
12
12
|
# {Component#handle_mouse}.
|
|
13
13
|
#
|
|
14
14
|
# Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
|
|
15
|
-
# show `[ caption ]
|
|
15
|
+
# show `[ caption ]` — that natural width is `caption.length + 4`.
|
|
16
16
|
class Button < Component
|
|
17
17
|
# @param caption [String] the button's label.
|
|
18
18
|
# @yield optional `on_click` callback; same as assigning {#on_click=}.
|
|
@@ -20,7 +20,6 @@ module Tuile
|
|
|
20
20
|
super()
|
|
21
21
|
@caption = caption.to_s
|
|
22
22
|
@on_click = on_click
|
|
23
|
-
self.content_size = natural_size
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
# @return [String] the button's label.
|
|
@@ -39,7 +38,6 @@ module Tuile
|
|
|
39
38
|
|
|
40
39
|
@caption = new_caption
|
|
41
40
|
invalidate
|
|
42
|
-
self.content_size = natural_size
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
def focusable? = true
|
|
@@ -49,9 +47,6 @@ module Tuile
|
|
|
49
47
|
# @param key [String]
|
|
50
48
|
# @return [Boolean]
|
|
51
49
|
def handle_key(key)
|
|
52
|
-
return false unless active?
|
|
53
|
-
return true if super
|
|
54
|
-
|
|
55
50
|
case key
|
|
56
51
|
when Keys::ENTER, " "
|
|
57
52
|
@on_click&.call
|
|
@@ -76,15 +71,9 @@ module Tuile
|
|
|
76
71
|
return if rect.empty?
|
|
77
72
|
|
|
78
73
|
label = "[ #{@caption} ]"[0, rect.width]
|
|
79
|
-
styled = active? ? screen.theme.
|
|
80
|
-
screen.
|
|
74
|
+
styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
|
|
75
|
+
screen.buffer.set_line(rect.left, rect.top, styled)
|
|
81
76
|
end
|
|
82
|
-
|
|
83
|
-
private
|
|
84
|
-
|
|
85
|
-
# Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
|
|
86
|
-
# @return [Size]
|
|
87
|
-
def natural_size = Size.new(@caption.length + 4, 1)
|
|
88
77
|
end
|
|
89
78
|
end
|
|
90
79
|
end
|
|
@@ -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)
|
|
@@ -21,9 +21,11 @@ module Tuile
|
|
|
21
21
|
# Opens the info window as a popup.
|
|
22
22
|
# @param caption [String]
|
|
23
23
|
# @param lines [Array<String>] the content, may contain formatting.
|
|
24
|
+
# @param size [Size, Fraction] the popup's size, applied top-down; the
|
|
25
|
+
# list wraps and scrolls within it. Defaults to {Fraction::HALF}.
|
|
24
26
|
# @return [Popup] the opened popup.
|
|
25
|
-
def self.open(caption, lines)
|
|
26
|
-
Popup.open(content: InfoWindow.new(caption, lines))
|
|
27
|
+
def self.open(caption, lines, size: Fraction::HALF)
|
|
28
|
+
Popup.open(content: InfoWindow.new(caption, lines), size: size)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
|
@@ -8,12 +8,17 @@ module Tuile
|
|
|
8
8
|
# embedded ANSI is honored) or a {StyledString} directly. {#text}
|
|
9
9
|
# always returns the {StyledString}.
|
|
10
10
|
class Label < Component
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# @param text [String, StyledString, nil] initial text, coerced the same
|
|
12
|
+
# way {#text=} coerces it (a `String` is parsed via
|
|
13
|
+
# {StyledString.parse}; `nil` is an empty label). Equivalent to
|
|
14
|
+
# constructing empty and assigning {#text=}.
|
|
15
|
+
def initialize(text = nil)
|
|
16
|
+
super()
|
|
13
17
|
@text = StyledString::EMPTY
|
|
14
18
|
@bg = nil
|
|
15
19
|
@clipped_lines = []
|
|
16
20
|
@blank_line = ""
|
|
21
|
+
self.text = text unless text.nil?
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
# @return [StyledString] the current text. Defaults to an empty
|
|
@@ -38,7 +43,6 @@ module Tuile
|
|
|
38
43
|
@text = new_text
|
|
39
44
|
update_clipped_lines
|
|
40
45
|
invalidate
|
|
41
|
-
self.content_size = compute_content_size
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# Sets the background color. Coerced via {Color.coerce}, so a Symbol,
|
|
@@ -70,7 +74,7 @@ module Tuile
|
|
|
70
74
|
|
|
71
75
|
(0...rect.height).each do |row|
|
|
72
76
|
line = @clipped_lines[row] || @blank_line
|
|
73
|
-
screen.
|
|
77
|
+
screen.buffer.set_line(rect.left, rect.top + row, line)
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
|
|
@@ -84,29 +88,17 @@ module Tuile
|
|
|
84
88
|
|
|
85
89
|
private
|
|
86
90
|
|
|
87
|
-
# Natural size: longest hard-line's display width × number of hard
|
|
88
|
-
# lines. Computed on the *unclipped* text — sizing is intrinsic to the
|
|
89
|
-
# content, not the viewport. Empty text yields {Size::ZERO}.
|
|
90
|
-
# @return [Size]
|
|
91
|
-
def compute_content_size
|
|
92
|
-
return Size::ZERO if @text.empty?
|
|
93
|
-
|
|
94
|
-
hard_lines = @text.lines
|
|
95
|
-
width = hard_lines.map(&:display_width).max || 0
|
|
96
|
-
Size.new(width, hard_lines.size)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
91
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
100
|
-
# Each line is ellipsized to fit
|
|
101
|
-
# the full width,
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
92
|
+
# Each line is ellipsized to fit and padded with trailing spaces out to
|
|
93
|
+
# the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
|
|
94
|
+
# row. {@blank_line} covers rows past the last text line. When {#bg} is
|
|
95
|
+
# set, every produced line (and the blank row) has the bg applied
|
|
96
|
+
# uniformly.
|
|
105
97
|
# @return [void]
|
|
106
98
|
def update_clipped_lines
|
|
107
99
|
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))
|
|
100
|
+
@blank_line = apply_bg(StyledString.plain(" " * width))
|
|
101
|
+
@clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
|
|
110
102
|
end
|
|
111
103
|
|
|
112
104
|
# @param line [StyledString]
|
|
@@ -56,15 +56,6 @@ module Tuile
|
|
|
56
56
|
on_child_removed(child)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# @return [Size]
|
|
60
|
-
def content_size
|
|
61
|
-
return Size::ZERO if @children.empty?
|
|
62
|
-
|
|
63
|
-
right = @children.map { |c| c.rect.left + c.rect.width }.max
|
|
64
|
-
bottom = @children.map { |c| c.rect.top + c.rect.height }.max
|
|
65
|
-
Size.new(right - rect.left, bottom - rect.top)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
59
|
# Dispatches the event to the child under the mouse cursor.
|
|
69
60
|
# @param event [MouseEvent]
|
|
70
61
|
# @return [void]
|
|
@@ -75,18 +66,6 @@ module Tuile
|
|
|
75
66
|
end
|
|
76
67
|
end
|
|
77
68
|
|
|
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
69
|
# @return [void]
|
|
91
70
|
def on_focus
|
|
92
71
|
super
|