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
data/lib/tuile/screen.rb
CHANGED
|
@@ -186,6 +186,17 @@ module Tuile
|
|
|
186
186
|
$stdin.echo = true
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Advances focus to the next {Component#tab_stop?} in tree order, wrapping
|
|
190
|
+
# around. Scope is the topmost popup if one is open, otherwise {#content}
|
|
191
|
+
# — this keeps Tab confined inside a modal popup. No-op (returns false) if
|
|
192
|
+
# the modal scope has no tab stops or no content at all.
|
|
193
|
+
# @return [Boolean] true if focus moved.
|
|
194
|
+
def focus_next = cycle_focus(forward: true)
|
|
195
|
+
|
|
196
|
+
# Mirror of {#focus_next} that walks backwards through the tab order.
|
|
197
|
+
# @return [Boolean] true if focus moved.
|
|
198
|
+
def focus_previous = cycle_focus(forward: false)
|
|
199
|
+
|
|
189
200
|
# @return [Component, nil] current active tiled component.
|
|
190
201
|
def active_window
|
|
191
202
|
check_locked
|
|
@@ -235,11 +246,31 @@ module Tuile
|
|
|
235
246
|
@@instance&.close
|
|
236
247
|
end
|
|
237
248
|
|
|
238
|
-
# Prints given strings.
|
|
249
|
+
# Prints given strings. While {#repaint} is running, writes are
|
|
250
|
+
# accumulated into a frame buffer and flushed to the terminal as a
|
|
251
|
+
# single `$stdout.write` at the end of the cycle. This stops the
|
|
252
|
+
# emulator from rendering half-finished frames (e.g. a layout's
|
|
253
|
+
# clear-background pass before its children have re-painted), which
|
|
254
|
+
# was visible as a brief flicker when the auto-clear path triggers.
|
|
255
|
+
#
|
|
256
|
+
# Outside repaint, writes go straight to stdout. We deliberately
|
|
257
|
+
# don't raise on a "print outside repaint" — that would be a useful
|
|
258
|
+
# guardrail against components painting outside the repaint loop,
|
|
259
|
+
# but it'd force terminal-housekeeping writes (`Screen#clear`,
|
|
260
|
+
# mouse-tracking start/stop, cursor-show on teardown) to bypass
|
|
261
|
+
# this method entirely and write directly to `$stdout`. {FakeScreen}
|
|
262
|
+
# overrides `print` to capture every byte into its `@prints` array,
|
|
263
|
+
# and tests that exercise `run_event_loop` against a real {Screen}
|
|
264
|
+
# would otherwise leak escape sequences to the test runner's stdout.
|
|
265
|
+
# Keeping `print` as the single sink preserves that override seam.
|
|
239
266
|
# @param args [String] stuff to print.
|
|
240
267
|
# @return [void]
|
|
241
268
|
def print(*args)
|
|
242
|
-
|
|
269
|
+
if @frame_buffer
|
|
270
|
+
args.each { |s| @frame_buffer << s.to_s }
|
|
271
|
+
else
|
|
272
|
+
Kernel.print(*args)
|
|
273
|
+
end
|
|
243
274
|
end
|
|
244
275
|
|
|
245
276
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
@@ -255,43 +286,56 @@ module Tuile
|
|
|
255
286
|
# simple and very fast in common cases.
|
|
256
287
|
|
|
257
288
|
did_paint = false
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
289
|
+
@frame_buffer = +""
|
|
290
|
+
begin
|
|
291
|
+
until @invalidated.empty?
|
|
292
|
+
did_paint = true
|
|
293
|
+
popups = @pane.popups
|
|
294
|
+
|
|
295
|
+
# Partition invalidated components into tiled vs popup-tree. Sorting
|
|
296
|
+
# by depth across the whole tree would interleave them: a tiled
|
|
297
|
+
# grandchild (depth 3) sorts after a popup's content (depth 2) and
|
|
298
|
+
# overdraws it.
|
|
299
|
+
popup_tree = Set.new
|
|
300
|
+
popups.each { |p| p.on_tree { popup_tree << it } }
|
|
301
|
+
tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
|
|
302
|
+
|
|
303
|
+
# Within the tiled tree, paint parents before children.
|
|
304
|
+
tiled.sort_by!(&:depth)
|
|
305
|
+
|
|
306
|
+
repaint = if tiled.empty?
|
|
307
|
+
# Only popups need repaint — paint just their invalidated
|
|
308
|
+
# components in depth order.
|
|
309
|
+
popup_invalidated.sort_by(&:depth)
|
|
310
|
+
else
|
|
311
|
+
# Tiled components may overdraw popups; repaint each open
|
|
312
|
+
# popup's full subtree on top, in stacking order
|
|
313
|
+
# (parent-before-child within each popup).
|
|
314
|
+
tiled + popups.flat_map { |p| collect_subtree(p) }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
@repainting = repaint.to_set
|
|
318
|
+
@invalidated.clear
|
|
319
|
+
|
|
320
|
+
# Don't call {#clear} before repaint — causes flickering, and only
|
|
321
|
+
# needed when @content doesn't cover the entire screen.
|
|
322
|
+
repaint.each(&:repaint)
|
|
323
|
+
|
|
324
|
+
# Repaint done, mark all components as up-to-date.
|
|
325
|
+
@repainting.clear
|
|
326
|
+
end
|
|
327
|
+
position_cursor if did_paint
|
|
328
|
+
unless @frame_buffer.empty?
|
|
329
|
+
$stdout.write(@frame_buffer)
|
|
330
|
+
$stdout.flush
|
|
331
|
+
end
|
|
332
|
+
ensure
|
|
333
|
+
# Always release the frame buffer, even on exception, so any
|
|
334
|
+
# subsequent {#print} call (e.g. teardown emits during crash unwind)
|
|
335
|
+
# reaches stdout instead of being swallowed by a stranded buffer.
|
|
336
|
+
# The partial frame we hold here is incoherent — discard it.
|
|
337
|
+
@frame_buffer = nil
|
|
293
338
|
end
|
|
294
|
-
position_cursor if did_paint
|
|
295
339
|
end
|
|
296
340
|
|
|
297
341
|
# Returns the absolute screen coordinates where the hardware cursor should
|
|
@@ -303,6 +347,34 @@ module Tuile
|
|
|
303
347
|
|
|
304
348
|
private
|
|
305
349
|
|
|
350
|
+
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
351
|
+
# advances focus by one (wrapping). When the focused component isn't in
|
|
352
|
+
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
353
|
+
# interactable widgets), Tab goes to the first stop and Shift+Tab to the
|
|
354
|
+
# last.
|
|
355
|
+
# @param forward [Boolean]
|
|
356
|
+
# @return [Boolean] true if focus moved.
|
|
357
|
+
def cycle_focus(forward:)
|
|
358
|
+
check_locked
|
|
359
|
+
scope = @pane.popups.last || @pane.content
|
|
360
|
+
return false if scope.nil?
|
|
361
|
+
|
|
362
|
+
stops = []
|
|
363
|
+
scope.on_tree { |c| stops << c if c.tab_stop? }
|
|
364
|
+
return false if stops.empty?
|
|
365
|
+
|
|
366
|
+
idx = @focused.nil? ? nil : stops.index(@focused)
|
|
367
|
+
target = if idx.nil?
|
|
368
|
+
forward ? stops.first : stops.last
|
|
369
|
+
else
|
|
370
|
+
stops[(idx + (forward ? 1 : -1)) % stops.size]
|
|
371
|
+
end
|
|
372
|
+
return false if target.equal?(@focused)
|
|
373
|
+
|
|
374
|
+
self.focused = target
|
|
375
|
+
true
|
|
376
|
+
end
|
|
377
|
+
|
|
306
378
|
# Collects a component and all its descendants in tree order
|
|
307
379
|
# (parent before children).
|
|
308
380
|
# @param component [Component]
|
|
@@ -344,9 +416,25 @@ module Tuile
|
|
|
344
416
|
|
|
345
417
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
346
418
|
# window.
|
|
419
|
+
#
|
|
420
|
+
# Tab / Shift+Tab are reserved navigation keys: intercepted here before
|
|
421
|
+
# the pane sees them, so a focused {Component::TextField} (which would
|
|
422
|
+
# otherwise swallow printable keys via the standard cursor-owner
|
|
423
|
+
# suppression) doesn't trap them.
|
|
347
424
|
# @param key [String]
|
|
348
425
|
# @return [Boolean] true if the key was handled by some window.
|
|
349
|
-
def handle_key(key)
|
|
426
|
+
def handle_key(key)
|
|
427
|
+
case key
|
|
428
|
+
when Keys::TAB
|
|
429
|
+
focus_next
|
|
430
|
+
true
|
|
431
|
+
when Keys::SHIFT_TAB
|
|
432
|
+
focus_previous
|
|
433
|
+
true
|
|
434
|
+
else
|
|
435
|
+
@pane.handle_key(key)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
350
438
|
|
|
351
439
|
# Finds target window and calls {Component::Window#handle_mouse}.
|
|
352
440
|
# @param event [MouseEvent]
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -140,16 +140,22 @@ module Tuile
|
|
|
140
140
|
# @param event [MouseEvent]
|
|
141
141
|
# @return [void]
|
|
142
142
|
def handle_mouse(event)
|
|
143
|
-
clicked = @popups.
|
|
143
|
+
clicked = @popups.reverse_each.find { it.rect.contains?(event.point) }
|
|
144
144
|
clicked = @content if clicked.nil? && @popups.empty?
|
|
145
145
|
clicked&.handle_mouse(event)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# Focus repair when a child detaches. Default {Component#on_child_removed}
|
|
149
149
|
# would refocus to `self` (the pane), which isn't a useful focus target.
|
|
150
|
-
# Instead, route focus to the
|
|
151
|
-
# snapshotted when this popup was opened
|
|
152
|
-
#
|
|
150
|
+
# Instead, route focus to the first interactable widget in the now-topmost
|
|
151
|
+
# popup; falling back to the focus snapshotted when this popup was opened
|
|
152
|
+
# (if still attached and still focusable); then to the first interactable
|
|
153
|
+
# widget in {#content}; then to {#content} itself; then nil.
|
|
154
|
+
#
|
|
155
|
+
# "First interactable widget" = first {Component#tab_stop?} in pre-order;
|
|
156
|
+
# if a scope has no tab stops at all (a borderless ESC-to-close popup, or
|
|
157
|
+
# tiled content made entirely of {Label}s), we focus the scope's root so
|
|
158
|
+
# `q`/ESC still has somewhere to dispatch from.
|
|
153
159
|
# @param child [Component]
|
|
154
160
|
# @return [void]
|
|
155
161
|
def on_child_removed(child)
|
|
@@ -161,14 +167,30 @@ module Tuile
|
|
|
161
167
|
cursor = f
|
|
162
168
|
while cursor
|
|
163
169
|
if cursor == child
|
|
164
|
-
fallback = @popups.last
|
|
165
|
-
fallback
|
|
166
|
-
|
|
170
|
+
fallback = first_tab_stop_or_root(@popups.last)
|
|
171
|
+
if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable?
|
|
172
|
+
fallback = @removing_popup_prior
|
|
173
|
+
end
|
|
174
|
+
fallback ||= first_tab_stop_or_root(@content)
|
|
167
175
|
screen.focused = fallback
|
|
168
176
|
return
|
|
169
177
|
end
|
|
170
178
|
cursor = cursor.parent
|
|
171
179
|
end
|
|
172
180
|
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
185
|
+
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
186
|
+
# if `root` is `nil`.
|
|
187
|
+
# @param root [Component, nil]
|
|
188
|
+
# @return [Component, nil]
|
|
189
|
+
def first_tab_stop_or_root(root)
|
|
190
|
+
return nil if root.nil?
|
|
191
|
+
|
|
192
|
+
root.on_tree { |c| return c if c.tab_stop? }
|
|
193
|
+
root
|
|
194
|
+
end
|
|
173
195
|
end
|
|
174
196
|
end
|