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.
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
- Kernel.print(*args)
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
- until @invalidated.empty?
259
- did_paint = true
260
- popups = @pane.popups
261
-
262
- # Partition invalidated components into tiled vs popup-tree. Sorting
263
- # by depth across the whole tree would interleave them: a tiled
264
- # grandchild (depth 3) sorts after a popup's content (depth 2) and
265
- # overdraws it.
266
- popup_tree = Set.new
267
- popups.each { |p| p.on_tree { popup_tree << it } }
268
- tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
269
-
270
- # Within the tiled tree, paint parents before children.
271
- tiled.sort_by!(&:depth)
272
-
273
- repaint = if tiled.empty?
274
- # Only popups need repaint — paint just their invalidated
275
- # components in depth order.
276
- popup_invalidated.sort_by(&:depth)
277
- else
278
- # Tiled components may overdraw popups; repaint each open
279
- # popup's full subtree on top, in stacking order
280
- # (parent-before-child within each popup).
281
- tiled + popups.flat_map { |p| collect_subtree(p) }
282
- end
283
-
284
- @repainting = repaint.to_set
285
- @invalidated.clear
286
-
287
- # Don't call {#clear} before repaint — causes flickering, and only
288
- # needed when @content doesn't cover the entire screen.
289
- repaint.each(&:repaint)
290
-
291
- # Repaint done, mark all components as up-to-date.
292
- @repainting.clear
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) = @pane.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]
@@ -140,16 +140,22 @@ module Tuile
140
140
  # @param event [MouseEvent]
141
141
  # @return [void]
142
142
  def handle_mouse(event)
143
- clicked = @popups.rfind { it.rect.contains?(event.point) }
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 now-topmost popup, then to the prior focus
151
- # snapshotted when this popup was opened (if still attached), then to
152
- # content, then nil.
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 ||= @removing_popup_prior if @removing_popup_prior&.attached?
166
- fallback ||= @content
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