tuile 0.1.0 → 0.2.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.
@@ -44,14 +44,36 @@ module Tuile
44
44
  screen.focused = self
45
45
  end
46
46
 
47
- # Repaints the component. Default implementation does nothing.
47
+ # Repaints the component.
48
48
  #
49
- # The component must fully draw over {#rect}, and must not draw outside of
50
- # {#rect}.
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
- # Tip: use {#clear_background} to clear component background before painting.
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; end
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]
@@ -70,7 +70,20 @@ module Tuile
70
70
  event_loop(&)
71
71
  ensure
72
72
  Signal.trap("WINCH", "SYSTEM_DEFAULT")
73
- @key_thread&.kill
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 = "\u007f"
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 = "\u0015"
53
+ CTRL_U = ""
54
+ # @return [String]
55
+ CTRL_D = ""
56
+ # @return [String]
57
+ ENTER = "
40
58
  # @return [String]
41
- CTRL_D = "\u0004"
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
- ENTER = "\u000d"
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
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
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # Truncates a string to a given column width, preserving ANSI escape
5
+ # sequences and accounting for Unicode display width. Truncated output is
6
+ # suffixed with an ellipsis (`…`).
7
+ #
8
+ # Extracted from `strings-truncation` 0.1.0 (MIT, Piotr Murach) — only the
9
+ # default end-position, default-omission, no-separator path Tuile uses.
10
+ module Truncate
11
+ # @return [Regexp]
12
+ ANSI_REGEXP = /(\[)?\033(\[)?[;?\d]*[\dA-Za-z]([\];])?/
13
+ private_constant :ANSI_REGEXP
14
+
15
+ # @return [String]
16
+ RESET = "\e[0m"
17
+ private_constant :RESET
18
+
19
+ # @return [Regexp]
20
+ RESET_REGEXP = /#{Regexp.escape(RESET)}/
21
+ private_constant :RESET_REGEXP
22
+
23
+ # @return [Regexp]
24
+ END_REGEXP = /\A(#{ANSI_REGEXP})*\z/
25
+ private_constant :END_REGEXP
26
+
27
+ # @return [String]
28
+ OMISSION = "…"
29
+ private_constant :OMISSION
30
+
31
+ # @return [Integer]
32
+ OMISSION_WIDTH = 1
33
+ private_constant :OMISSION_WIDTH
34
+
35
+ module_function
36
+
37
+ # Truncate `text` to at most `length` display columns. ANSI escape
38
+ # sequences pass through without consuming budget; when characters are
39
+ # dropped, an ellipsis (`…`) is appended (and counts toward `length`).
40
+ #
41
+ # @param text [String]
42
+ # @param length [Integer, nil] target column width. A `nil` returns
43
+ # `text` unchanged.
44
+ # @return [String]
45
+ def truncate(text, length:)
46
+ return text if length.nil? || text.bytesize <= length
47
+ return "" if length.zero?
48
+
49
+ budget = length - OMISSION_WIDTH
50
+ scanner = StringScanner.new(text)
51
+ out = +""
52
+ visible = 0
53
+ ansi_open = false
54
+ stop = false
55
+
56
+ until scanner.eos? || stop
57
+ if scanner.scan(RESET_REGEXP)
58
+ unless scanner.eos?
59
+ out << scanner.matched
60
+ ansi_open = false
61
+ end
62
+ elsif scanner.scan(ANSI_REGEXP)
63
+ out << scanner.matched
64
+ ansi_open = true
65
+ else
66
+ char = scanner.getch
67
+ new_visible = visible + Unicode::DisplayWidth.of(char)
68
+
69
+ if new_visible <= budget || (scanner.check(END_REGEXP) && new_visible <= length)
70
+ out << char
71
+ visible = new_visible
72
+ else
73
+ stop = true
74
+ end
75
+ end
76
+ end
77
+
78
+ out << RESET if ansi_open
79
+ out << OMISSION if stop
80
+ out
81
+ end
82
+ end
83
+ end
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/tuile.rb CHANGED
@@ -5,7 +5,7 @@ require "io/console"
5
5
  require "logger"
6
6
  require "rainbow"
7
7
  require "singleton"
8
- require "strings-truncation"
8
+ require "strscan"
9
9
  require "tty-cursor"
10
10
  require "tty-screen"
11
11
  require "unicode/display_width"