tuile 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +10 -10
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +320 -0
- data/lib/tuile/ansi.rb +14 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +64 -26
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +192 -63
- data/lib/tuile/component/text_area.rb +376 -0
- data/lib/tuile/component/text_field.rb +46 -4
- data/lib/tuile/component/text_view.rb +351 -0
- data/lib/tuile/component/window.rb +13 -5
- data/lib/tuile/component.rb +53 -5
- data/lib/tuile/event_queue.rb +14 -1
- data/lib/tuile/keys.rb +24 -4
- data/lib/tuile/screen.rb +127 -39
- data/lib/tuile/screen_pane.rb +29 -7
- data/lib/tuile/styled_string.rb +761 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +958 -53
- metadata +9 -17
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A read-only viewer for prose: chunks of formatted text that scroll
|
|
6
|
+
# vertically. Shape-wise a hybrid between {Label} (string-shaped content
|
|
7
|
+
# via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
|
|
8
|
+
#
|
|
9
|
+
# Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
|
|
10
|
+
# lines wider than the viewport are word-wrapped via {StyledString#wrap}
|
|
11
|
+
# (style spans are preserved across wrap boundaries — unlike the older
|
|
12
|
+
# ANSI-as-bytes wrapping, color does *not* get dropped on continuation
|
|
13
|
+
# rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
|
|
14
|
+
# so embedded ANSI is honored) or a {StyledString} directly; {#text}
|
|
15
|
+
# always returns the {StyledString}. Use {#append} for incremental "log
|
|
16
|
+
# line" style updates; turn on {#auto_scroll} to keep the latest content
|
|
17
|
+
# in view.
|
|
18
|
+
#
|
|
19
|
+
# TextView is meant to be the content of a {Window} — focus indication and
|
|
20
|
+
# keyboard-hint surfacing rely on the surrounding window chrome.
|
|
21
|
+
class TextView < Component
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
# `@hard_lines` is the logical model — one entry per `\n`-delimited
|
|
25
|
+
# line of the original text, width-independent. `@physical_lines` is
|
|
26
|
+
# the rendered view — each hard line word-wrapped to `wrap_width`
|
|
27
|
+
# and padded with trailing blanks, so painting a row is a lookup.
|
|
28
|
+
# Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
|
|
29
|
+
# extends both.
|
|
30
|
+
@hard_lines = []
|
|
31
|
+
@physical_lines = []
|
|
32
|
+
@text = StyledString::EMPTY
|
|
33
|
+
@content_size = Size::ZERO
|
|
34
|
+
@blank_line = StyledString::EMPTY
|
|
35
|
+
@top_line = 0
|
|
36
|
+
@auto_scroll = false
|
|
37
|
+
@scrollbar_visibility = :gone
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [StyledString] the current text. Defaults to an empty
|
|
41
|
+
# {StyledString}. Internally the text is stored as an array of hard
|
|
42
|
+
# lines so {#append} can stay O(appended) instead of re-scanning the
|
|
43
|
+
# whole buffer; the joined {StyledString} returned here is
|
|
44
|
+
# reconstructed on first read after a mutation and cached, so
|
|
45
|
+
# repeated reads are O(1) but the first read after {#append} pays
|
|
46
|
+
# O(total spans).
|
|
47
|
+
def text
|
|
48
|
+
@text ||= build_text
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Integer] index of the first visible physical line.
|
|
52
|
+
attr_reader :top_line
|
|
53
|
+
|
|
54
|
+
# @return [Symbol] `:gone` or `:visible`.
|
|
55
|
+
attr_reader :scrollbar_visibility
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] if true, mutating the text scrolls the viewport so
|
|
58
|
+
# the last line stays in view. Default `false`.
|
|
59
|
+
attr_reader :auto_scroll
|
|
60
|
+
|
|
61
|
+
# Replaces the text. Embedded `\n` characters become hard line breaks.
|
|
62
|
+
# A `String` is parsed via {StyledString.parse} (so embedded ANSI is
|
|
63
|
+
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
64
|
+
# empty {StyledString}.
|
|
65
|
+
# @param value [String, StyledString, nil]
|
|
66
|
+
# @return [void]
|
|
67
|
+
def text=(value)
|
|
68
|
+
new_text = StyledString.parse(value)
|
|
69
|
+
return if text == new_text
|
|
70
|
+
|
|
71
|
+
@text = new_text
|
|
72
|
+
@hard_lines = new_text.empty? ? [] : new_text.lines
|
|
73
|
+
@content_size = compute_content_size
|
|
74
|
+
rewrap
|
|
75
|
+
update_top_line_if_auto_scroll
|
|
76
|
+
invalidate
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Appends `str` as a new physical line. If the current text is empty,
|
|
80
|
+
# behaves like `text = str`; otherwise prepends a newline so the new
|
|
81
|
+
# content lands on a fresh line. Accepts the same input forms as
|
|
82
|
+
# {#text=}.
|
|
83
|
+
#
|
|
84
|
+
# Cost is O(appended) rather than O(total) — the existing wrapped
|
|
85
|
+
# buffer is reused, only the new hard line(s) are wrapped and padded,
|
|
86
|
+
# and `@content_size` is updated incrementally. The cached
|
|
87
|
+
# {#text} is invalidated and rebuilt on demand.
|
|
88
|
+
# @param str [String, StyledString, nil]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def append(str)
|
|
91
|
+
screen.check_locked
|
|
92
|
+
appended = StyledString.parse(str)
|
|
93
|
+
if @hard_lines.empty?
|
|
94
|
+
self.text = appended
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
new_hard_lines = appended.lines
|
|
99
|
+
@text = nil
|
|
100
|
+
@hard_lines.concat(new_hard_lines)
|
|
101
|
+
new_width = new_hard_lines.map(&:display_width).max || 0
|
|
102
|
+
@content_size = Size.new(
|
|
103
|
+
[@content_size.width, new_width].max,
|
|
104
|
+
@content_size.height + new_hard_lines.size
|
|
105
|
+
)
|
|
106
|
+
width = wrap_width
|
|
107
|
+
new_hard_lines.each { |hl| append_physical_lines(hl, width) }
|
|
108
|
+
update_top_line_if_auto_scroll
|
|
109
|
+
invalidate
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Clears the text. Equivalent to `text = ""`.
|
|
113
|
+
# @return [void]
|
|
114
|
+
def clear
|
|
115
|
+
self.text = StyledString::EMPTY
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param new_top_line [Integer] 0 or greater. Not clamped against the
|
|
119
|
+
# number of lines (matches {List#top_line=}).
|
|
120
|
+
# @return [void]
|
|
121
|
+
def top_line=(new_top_line)
|
|
122
|
+
raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
|
|
123
|
+
raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
|
|
124
|
+
return if @top_line == new_top_line
|
|
125
|
+
|
|
126
|
+
@top_line = new_top_line
|
|
127
|
+
invalidate
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param value [Symbol] `:gone` or `:visible`.
|
|
131
|
+
# @return [void]
|
|
132
|
+
def scrollbar_visibility=(value)
|
|
133
|
+
raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
|
|
134
|
+
return if @scrollbar_visibility == value
|
|
135
|
+
|
|
136
|
+
@scrollbar_visibility = value
|
|
137
|
+
rewrap
|
|
138
|
+
invalidate
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Sets `auto_scroll`. If true, immediately scrolls to the bottom.
|
|
142
|
+
# @param value [Boolean]
|
|
143
|
+
# @return [void]
|
|
144
|
+
def auto_scroll=(value)
|
|
145
|
+
@auto_scroll = value ? true : false
|
|
146
|
+
update_top_line_if_auto_scroll
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def focusable? = true
|
|
150
|
+
|
|
151
|
+
def tab_stop? = true
|
|
152
|
+
|
|
153
|
+
# @return [Size] longest hard-line's display width × number of hard
|
|
154
|
+
# lines. Reported on the *unwrapped* text — wrap-aware sizing would
|
|
155
|
+
# be circular (width depends on width). Empty text returns
|
|
156
|
+
# `Size.new(0, 0)`. Maintained incrementally by {#text=} and
|
|
157
|
+
# {#append}, so reads are O(1).
|
|
158
|
+
attr_reader :content_size
|
|
159
|
+
|
|
160
|
+
# @param key [String]
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def handle_key(key)
|
|
163
|
+
return false unless active?
|
|
164
|
+
return true if super
|
|
165
|
+
|
|
166
|
+
case key
|
|
167
|
+
when *Keys::DOWN_ARROWS then move_top_line_by(1)
|
|
168
|
+
when *Keys::UP_ARROWS then move_top_line_by(-1)
|
|
169
|
+
when Keys::PAGE_DOWN then move_top_line_by(viewport_lines)
|
|
170
|
+
when Keys::PAGE_UP then move_top_line_by(-viewport_lines)
|
|
171
|
+
when Keys::CTRL_D then move_top_line_by(viewport_lines / 2)
|
|
172
|
+
when Keys::CTRL_U then move_top_line_by(-viewport_lines / 2)
|
|
173
|
+
when *Keys::HOMES, "g" then move_top_line_to(0)
|
|
174
|
+
when *Keys::ENDS_, "G" then move_top_line_to(top_line_max)
|
|
175
|
+
else return false
|
|
176
|
+
end
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param event [MouseEvent]
|
|
181
|
+
# @return [void]
|
|
182
|
+
def handle_mouse(event)
|
|
183
|
+
super
|
|
184
|
+
case event.button
|
|
185
|
+
when :scroll_down then move_top_line_by(4)
|
|
186
|
+
when :scroll_up then move_top_line_by(-4)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Paints the text into {#rect}.
|
|
191
|
+
#
|
|
192
|
+
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
193
|
+
# painted explicitly (with padded blanks past the last line), so the
|
|
194
|
+
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
195
|
+
# @return [void]
|
|
196
|
+
def repaint
|
|
197
|
+
return if rect.empty?
|
|
198
|
+
|
|
199
|
+
scrollbar = if scrollbar_visible?
|
|
200
|
+
VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
|
|
201
|
+
end
|
|
202
|
+
(0...rect.height).each do |row|
|
|
203
|
+
line = paintable_line(row + @top_line, row, scrollbar)
|
|
204
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
protected
|
|
209
|
+
|
|
210
|
+
# Rewraps the text on width changes. Wrap width depends on
|
|
211
|
+
# {#rect}`.width` and the scrollbar gutter, both of which trigger
|
|
212
|
+
# this hook.
|
|
213
|
+
# @return [void]
|
|
214
|
+
def on_width_changed
|
|
215
|
+
super
|
|
216
|
+
rewrap
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
# @return [Integer] number of visible lines.
|
|
222
|
+
def viewport_lines = rect.height
|
|
223
|
+
|
|
224
|
+
# @return [Integer] the max value of {#top_line} for scroll-key clamping.
|
|
225
|
+
def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
226
|
+
|
|
227
|
+
# Recomputes {@physical_lines} for the current text and wrap width,
|
|
228
|
+
# pre-padding every line to `wrap_width` so {#paintable_line} is just
|
|
229
|
+
# a lookup + optional scrollbar-char append at paint time (and the
|
|
230
|
+
# rendered ANSI is cached on each line via {StyledString#to_ansi}'s
|
|
231
|
+
# memoization, so re-painting on scroll is near-free). Clamps
|
|
232
|
+
# {@top_line} if the new line count puts it out of range.
|
|
233
|
+
# @return [void]
|
|
234
|
+
def rewrap
|
|
235
|
+
width = wrap_width
|
|
236
|
+
@blank_line = pad_to(StyledString::EMPTY, width)
|
|
237
|
+
@physical_lines = []
|
|
238
|
+
@hard_lines.each { |hl| append_physical_lines(hl, width) }
|
|
239
|
+
@top_line = top_line_max if @top_line > top_line_max
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Wraps `hard_line` at `width` and appends the padded physical lines
|
|
243
|
+
# to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
|
|
244
|
+
# and degenerate `width <= 0` both emit a single {@blank_line} row,
|
|
245
|
+
# matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
246
|
+
# would have produced for those cases.
|
|
247
|
+
# @param hard_line [StyledString] one hard-broken line (no embedded `"\n"`).
|
|
248
|
+
# @param width [Integer]
|
|
249
|
+
# @return [void]
|
|
250
|
+
def append_physical_lines(hard_line, width)
|
|
251
|
+
if hard_line.empty? || width <= 0
|
|
252
|
+
@physical_lines << @blank_line
|
|
253
|
+
else
|
|
254
|
+
hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
|
|
259
|
+
# default-styled `"\n"` between hard lines. Called from the {#text}
|
|
260
|
+
# reader when the cache is cold. Cost is O(total spans).
|
|
261
|
+
# @return [StyledString]
|
|
262
|
+
def build_text
|
|
263
|
+
return StyledString::EMPTY if @hard_lines.empty?
|
|
264
|
+
return @hard_lines.first if @hard_lines.size == 1
|
|
265
|
+
|
|
266
|
+
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
267
|
+
spans = []
|
|
268
|
+
@hard_lines.each_with_index do |hl, i|
|
|
269
|
+
spans << newline if i.positive?
|
|
270
|
+
spans.concat(hl.spans)
|
|
271
|
+
end
|
|
272
|
+
StyledString.new(spans)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [Size] {#content_size} computed from {@hard_lines}.
|
|
276
|
+
def compute_content_size
|
|
277
|
+
return Size::ZERO if @hard_lines.empty?
|
|
278
|
+
|
|
279
|
+
Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @return [Integer] column width available for wrapped text — viewport
|
|
283
|
+
# width minus the scrollbar gutter (when visible). `0` when {#rect}'s
|
|
284
|
+
# width is non-positive, which yields a degenerate "no wrap" result.
|
|
285
|
+
def wrap_width
|
|
286
|
+
return 0 if rect.width <= 0
|
|
287
|
+
|
|
288
|
+
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
292
|
+
# @return [void]
|
|
293
|
+
def move_top_line_by(delta)
|
|
294
|
+
move_top_line_to(@top_line + delta)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
|
|
298
|
+
# @return [void]
|
|
299
|
+
def move_top_line_to(target)
|
|
300
|
+
clamped = target.clamp(0, top_line_max)
|
|
301
|
+
self.top_line = clamped unless @top_line == clamped
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# @return [void]
|
|
305
|
+
def update_top_line_if_auto_scroll
|
|
306
|
+
return unless @auto_scroll
|
|
307
|
+
|
|
308
|
+
target = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
309
|
+
self.top_line = target if @top_line != target
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [Boolean]
|
|
313
|
+
def scrollbar_visible?
|
|
314
|
+
return false if rect.empty?
|
|
315
|
+
|
|
316
|
+
@scrollbar_visibility == :visible
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Pads `line` with trailing default-styled spaces out to `width` display
|
|
320
|
+
# columns. Callers rely on {StyledString#wrap} having already
|
|
321
|
+
# constrained the line to `<= width`, so no truncation is performed.
|
|
322
|
+
# `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
|
|
323
|
+
# `wrap_width == 0` case (rect.width == 1 with scrollbar).
|
|
324
|
+
# @param line [StyledString]
|
|
325
|
+
# @param width [Integer]
|
|
326
|
+
# @return [StyledString]
|
|
327
|
+
def pad_to(line, width)
|
|
328
|
+
return StyledString::EMPTY if width <= 0
|
|
329
|
+
|
|
330
|
+
diff = width - line.display_width
|
|
331
|
+
return line if diff <= 0
|
|
332
|
+
|
|
333
|
+
line + StyledString.plain(" " * diff)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# @param index [Integer] 0-based index into `@physical_lines`.
|
|
337
|
+
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
338
|
+
# @param scrollbar [VerticalScrollBar, nil]
|
|
339
|
+
# @return [String] paintable ANSI-encoded line exactly `rect.width`
|
|
340
|
+
# columns wide. Body lines come pre-padded from {#rewrap}, so this
|
|
341
|
+
# reduces to a memoized {StyledString#to_ansi} read plus an
|
|
342
|
+
# ASCII-string concat of the scrollbar glyph when one is present.
|
|
343
|
+
def paintable_line(index, row_in_viewport, scrollbar)
|
|
344
|
+
line = @physical_lines[index] || @blank_line
|
|
345
|
+
return line.to_ansi unless scrollbar
|
|
346
|
+
|
|
347
|
+
line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -90,9 +90,9 @@ module Tuile
|
|
|
90
90
|
# @param value [Boolean]
|
|
91
91
|
# @return [void]
|
|
92
92
|
def scrollbar=(value)
|
|
93
|
-
unless content.
|
|
93
|
+
unless content.respond_to?(:scrollbar_visibility=)
|
|
94
94
|
raise Tuile::Error,
|
|
95
|
-
"scrollbar= requires a
|
|
95
|
+
"scrollbar= requires a content component that supports scrollbar_visibility=, got #{content.inspect}"
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
content.scrollbar_visibility = value ? :visible : :gone
|
|
@@ -132,13 +132,21 @@ module Tuile
|
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
# Fully repaints the window: both frame and contents.
|
|
135
|
+
#
|
|
136
|
+
# Window deliberately paints over its entire rect (border around the
|
|
137
|
+
# edge, content/footer over the interior), so we don't need the
|
|
138
|
+
# {Component#repaint} default's auto-clear — but we do still want its
|
|
139
|
+
# "re-invalidate children" effect, since the border overpaints
|
|
140
|
+
# whatever the content/footer drew on the perimeter. Calling super
|
|
141
|
+
# handles both: the auto-clear is harmless (we re-paint over it), and
|
|
142
|
+
# the invalidation queues content + footer for repaint in the same
|
|
143
|
+
# cycle.
|
|
135
144
|
# @return [void]
|
|
136
145
|
def repaint
|
|
146
|
+
return unless visible?
|
|
147
|
+
|
|
137
148
|
super
|
|
138
149
|
repaint_border
|
|
139
|
-
# Border paints over content: invalidate the content to have it
|
|
140
|
-
# repainted.
|
|
141
|
-
content&.invalidate
|
|
142
150
|
end
|
|
143
151
|
|
|
144
152
|
# @param key [String, nil]
|
data/lib/tuile/component.rb
CHANGED
|
@@ -44,14 +44,36 @@ module Tuile
|
|
|
44
44
|
screen.focused = self
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Repaints the component.
|
|
47
|
+
# Repaints the component.
|
|
48
48
|
#
|
|
49
|
-
# The
|
|
50
|
-
#
|
|
49
|
+
# The default does the bookkeeping that almost every component would
|
|
50
|
+
# otherwise have to remember: it clears the background and re-invalidates
|
|
51
|
+
# any direct children whose rects leave gaps in {#rect}. Concretely:
|
|
51
52
|
#
|
|
52
|
-
#
|
|
53
|
+
# - Leaf (no children): always clears, so subclasses can paint their
|
|
54
|
+
# content directly without an explicit `clear_background` call.
|
|
55
|
+
# - Container with children that fully tile {#rect}: skipped — the
|
|
56
|
+
# children themselves will repaint and cover everything.
|
|
57
|
+
# - Container with gappy children (e.g. a form layout where widgets
|
|
58
|
+
# don't tile): clears, then invalidates the children so they re-paint
|
|
59
|
+
# on top of the cleared background. This is what makes mixed
|
|
60
|
+
# field/button forms safe without each container learning a custom
|
|
61
|
+
# damage-tracking pass.
|
|
62
|
+
#
|
|
63
|
+
# Subclasses that paint their entire rect themselves (e.g. {Window}'s
|
|
64
|
+
# border draws over the area the default would clear; {Component::List}
|
|
65
|
+
# explicitly paints every row) may skip super and take full
|
|
66
|
+
# responsibility for {#rect}. Everything else should call super.
|
|
67
|
+
#
|
|
68
|
+
# A component must not draw outside of {#rect}.
|
|
53
69
|
# @return [void]
|
|
54
|
-
def repaint
|
|
70
|
+
def repaint
|
|
71
|
+
return if rect.empty? || rect.left.negative? || rect.top.negative?
|
|
72
|
+
return if children.any? && children_tile_rect?
|
|
73
|
+
|
|
74
|
+
clear_background
|
|
75
|
+
children.each { |c| screen.invalidate(c) }
|
|
76
|
+
end
|
|
55
77
|
|
|
56
78
|
# Called when a character is pressed on the keyboard.
|
|
57
79
|
#
|
|
@@ -123,9 +145,22 @@ module Tuile
|
|
|
123
145
|
# Independent from {#active?}: every component carries the active flag, but
|
|
124
146
|
# only focusable ones can become a focus target that puts themselves and
|
|
125
147
|
# their ancestors on the active chain.
|
|
148
|
+
#
|
|
149
|
+
# See also {#tab_stop?}: focusable controls _can_ receive focus (via click
|
|
150
|
+
# or programmatic assignment), but only tab stops participate in Tab /
|
|
151
|
+
# Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
|
|
152
|
+
# (so a click on chrome lands focus) but are not tab stops.
|
|
126
153
|
# @return [Boolean] true if this component can be focused.
|
|
127
154
|
def focusable? = false
|
|
128
155
|
|
|
156
|
+
# Whether this component participates in Tab / Shift+Tab focus cycling.
|
|
157
|
+
# `false` by default. Only true on components that accept direct user
|
|
158
|
+
# input (e.g. {TextField}, {List}, {Component::Button}). Implies
|
|
159
|
+
# {#focusable?} — Screen will skip non-focusable tab stops, but in
|
|
160
|
+
# practice every override should keep the two consistent.
|
|
161
|
+
# @return [Boolean] true if Tab / Shift+Tab should land on this component.
|
|
162
|
+
def tab_stop? = false
|
|
163
|
+
|
|
129
164
|
# @return [Component, nil] the parent component or nil if the component has
|
|
130
165
|
# no parent.
|
|
131
166
|
attr_reader :parent
|
|
@@ -221,6 +256,19 @@ module Tuile
|
|
|
221
256
|
screen.invalidate(self)
|
|
222
257
|
end
|
|
223
258
|
|
|
259
|
+
# Whether direct children fully tile {#rect}. Used by the default
|
|
260
|
+
# {#repaint} to decide whether the framework needs to wipe gaps.
|
|
261
|
+
#
|
|
262
|
+
# Approximated by area: sum of (non-empty) child areas vs the parent's
|
|
263
|
+
# area. Cheap, and correct as long as siblings don't overlap each other
|
|
264
|
+
# — which Tuile already requires (no clipping in the tiled tree).
|
|
265
|
+
# Children with empty rects contribute zero, since they paint nothing.
|
|
266
|
+
# @return [Boolean]
|
|
267
|
+
def children_tile_rect?
|
|
268
|
+
total = children.sum { |c| c.rect.empty? ? 0 : c.rect.width * c.rect.height }
|
|
269
|
+
total >= rect.width * rect.height
|
|
270
|
+
end
|
|
271
|
+
|
|
224
272
|
# Clears the background: prints spaces into all characters occupied by the
|
|
225
273
|
# component's rect.
|
|
226
274
|
# @return [void]
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -70,7 +70,20 @@ module Tuile
|
|
|
70
70
|
event_loop(&)
|
|
71
71
|
ensure
|
|
72
72
|
Signal.trap("WINCH", "SYSTEM_DEFAULT")
|
|
73
|
-
@key_thread
|
|
73
|
+
if @key_thread
|
|
74
|
+
# Kill returns immediately, but the key thread is typically
|
|
75
|
+
# blocked inside $stdin.getch with a termios snapshot saved in
|
|
76
|
+
# io-console's C-level ensure. If we let it run to completion
|
|
77
|
+
# *after* the outer $stdin.raw block has exited (e.g. when an
|
|
78
|
+
# exception is escaping run_event_loop), the late tcsetattr
|
|
79
|
+
# restores raw mode and leaves the terminal with ONLCR off —
|
|
80
|
+
# the stack trace then prints as one un-wrapped soft line.
|
|
81
|
+
# Joining here forces the restore to happen while we're still
|
|
82
|
+
# nested inside $stdin.raw, so raw's own restoration is the
|
|
83
|
+
# final write and the terminal lands in cooked mode.
|
|
84
|
+
@key_thread.kill
|
|
85
|
+
@key_thread.join
|
|
86
|
+
end
|
|
74
87
|
@queue.clear
|
|
75
88
|
end
|
|
76
89
|
end
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -18,17 +18,31 @@ module Tuile
|
|
|
18
18
|
# @return [String]
|
|
19
19
|
RIGHT_ARROW = "\e[C"
|
|
20
20
|
# @return [String]
|
|
21
|
+
CTRL_LEFT_ARROW = "\e[1;5D"
|
|
22
|
+
# @return [String]
|
|
23
|
+
CTRL_RIGHT_ARROW = "\e[1;5C"
|
|
24
|
+
# @return [String]
|
|
21
25
|
ESC = "\e"
|
|
22
26
|
# @return [String]
|
|
23
27
|
HOME = "\e[H"
|
|
24
28
|
# @return [String]
|
|
25
29
|
END_ = "\e[F"
|
|
30
|
+
# Home-key sequences. xterm-style (`HOME`) is the modern default, but the
|
|
31
|
+
# Linux console, rxvt, and tmux/screen in their default configuration emit
|
|
32
|
+
# the VT220-style `\e[1~` instead. Components that handle Home should
|
|
33
|
+
# match against this array so users see consistent behavior regardless of
|
|
34
|
+
# which sequence their terminal emits.
|
|
35
|
+
# @return [Array<String>]
|
|
36
|
+
HOMES = [HOME, "\e[1~"].freeze
|
|
37
|
+
# End-key sequences. See {HOMES} for why two are recognized.
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
ENDS_ = [END_, "\e[4~"].freeze
|
|
26
40
|
# @return [String]
|
|
27
41
|
PAGE_UP = "\e[5~"
|
|
28
42
|
# @return [String]
|
|
29
43
|
PAGE_DOWN = "\e[6~"
|
|
30
44
|
# @return [String]
|
|
31
|
-
BACKSPACE = "
|
|
45
|
+
BACKSPACE = ""
|
|
32
46
|
# @return [String]
|
|
33
47
|
DELETE = "\e[3~"
|
|
34
48
|
# @return [String]
|
|
@@ -36,11 +50,17 @@ module Tuile
|
|
|
36
50
|
# @return [Array<String>]
|
|
37
51
|
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
38
52
|
# @return [String]
|
|
39
|
-
CTRL_U = "
|
|
53
|
+
CTRL_U = ""
|
|
54
|
+
# @return [String]
|
|
55
|
+
CTRL_D = ""
|
|
56
|
+
# @return [String]
|
|
57
|
+
ENTER = "
|
|
40
58
|
# @return [String]
|
|
41
|
-
|
|
59
|
+
TAB = "\t"
|
|
60
|
+
# The terminal sequence emitted by Shift+Tab in xterm-style terminals
|
|
61
|
+
# (CSI Z). Used by {Screen} for reverse focus traversal.
|
|
42
62
|
# @return [String]
|
|
43
|
-
|
|
63
|
+
SHIFT_TAB = "\e[Z"
|
|
44
64
|
|
|
45
65
|
# Grabs a key from stdin and returns it. Blocks until the key is obtained.
|
|
46
66
|
# Reads a full ESC key sequence; see constants above for some values returned
|