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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +51 -24
  4. data/book/01-first-app.md +186 -0
  5. data/book/02-repaint.md +177 -0
  6. data/book/03-layout.md +379 -0
  7. data/book/04-event-loop.md +209 -0
  8. data/book/05-focus.md +188 -0
  9. data/book/06-theming.md +250 -0
  10. data/book/07-components.md +231 -0
  11. data/book/08-testing.md +186 -0
  12. data/book/README.md +79 -0
  13. data/examples/hello_world.rb +1 -2
  14. data/examples/sampler.rb +109 -0
  15. data/lib/tuile/ansi.rb +16 -0
  16. data/lib/tuile/buffer.rb +481 -0
  17. data/lib/tuile/component/button.rb +3 -14
  18. data/lib/tuile/component/has_content.rb +0 -6
  19. data/lib/tuile/component/info_window.rb +4 -2
  20. data/lib/tuile/component/label.rb +15 -23
  21. data/lib/tuile/component/layout.rb +0 -21
  22. data/lib/tuile/component/list.rb +10 -37
  23. data/lib/tuile/component/log_window.rb +6 -5
  24. data/lib/tuile/component/picker_window.rb +4 -2
  25. data/lib/tuile/component/popup.rb +85 -55
  26. data/lib/tuile/component/text_area.rb +1 -1
  27. data/lib/tuile/component/text_field.rb +1 -1
  28. data/lib/tuile/component/text_input.rb +25 -9
  29. data/lib/tuile/component/text_view.rb +6 -30
  30. data/lib/tuile/component/window.rb +77 -112
  31. data/lib/tuile/component.rb +16 -71
  32. data/lib/tuile/event_queue.rb +31 -10
  33. data/lib/tuile/fake_event_queue.rb +21 -5
  34. data/lib/tuile/fake_screen.rb +14 -1
  35. data/lib/tuile/fraction.rb +46 -0
  36. data/lib/tuile/screen.rb +98 -105
  37. data/lib/tuile/screen_pane.rb +82 -19
  38. data/lib/tuile/styled_string.rb +40 -30
  39. data/lib/tuile/version.rb +1 -1
  40. data/sig/tuile.rbs +653 -282
  41. metadata +13 -3
  42. data/lib/tuile/sizing.rb +0 -59
data/lib/tuile/screen.rb CHANGED
@@ -35,9 +35,9 @@ module Tuile
35
35
  @repainting = Set.new
36
36
  # Until the event loop is run, we pretend we're in the UI thread.
37
37
  @pretend_ui_lock = true
38
- @scheme = detect_scheme
38
+ @color_scheme = detect_scheme
39
39
  @theme_def = ThemeDef.default
40
- @theme = @theme_def.for(@scheme)
40
+ @theme = @theme_def.for(@color_scheme)
41
41
  # Structural root of the component tree: holds tiled content, popup
42
42
  # stack and status bar.
43
43
  @pane = ScreenPane.new
@@ -45,6 +45,10 @@ module Tuile
45
45
  # App-level keyboard shortcuts dispatched by {#handle_key} before keys
46
46
  # reach the pane. See {#register_global_shortcut}.
47
47
  @global_shortcuts = {}
48
+ # The back buffer components paint into. {#repaint} flushes its diff to
49
+ # the terminal, so only changed cells are emitted (flicker-free on any
50
+ # terminal). Sized to the current viewport; {#layout} resizes it.
51
+ @buffer = Buffer.new(@size)
48
52
  end
49
53
 
50
54
  # Entry in the global shortcut registry: the block to run, whether it
@@ -56,6 +60,13 @@ module Tuile
56
60
  # @return [ScreenPane] the structural root of the component tree.
57
61
  attr_reader :pane
58
62
 
63
+ # @return [Symbol] `:light` or `:dark`
64
+ attr_reader :color_scheme
65
+
66
+ # @return [Buffer] the back buffer components paint into
67
+ # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
68
+ attr_reader :buffer
69
+
59
70
  # Handler invoked when a {StandardError} escapes an event handler inside
60
71
  # the event loop (e.g. a {Component::TextField}'s `on_change` raises).
61
72
  #
@@ -125,7 +136,7 @@ module Tuile
125
136
 
126
137
  check_locked
127
138
  @theme_def = theme_def
128
- self.theme = @theme_def.for(@scheme)
139
+ self.theme = @theme_def.for(@color_scheme)
129
140
  end
130
141
 
131
142
  # Replaces the theme and restyles the whole UI: fires
@@ -232,7 +243,7 @@ module Tuile
232
243
  # @api private
233
244
  # @return [void]
234
245
  def refresh_status_bar
235
- top_popup = @pane.popups.last
246
+ top_popup = @pane.modal_popup
236
247
  globals = global_shortcut_hints(popup_open: !top_popup.nil?)
237
248
  @pane.status_bar.text = if top_popup.nil?
238
249
  ["q #{@theme.hint("quit")}", *globals,
@@ -449,31 +460,16 @@ module Tuile
449
460
  @@instance&.close
450
461
  end
451
462
 
452
- # Prints given strings. While {#repaint} is running, writes are
453
- # accumulated into a frame buffer and flushed to the terminal as a
454
- # single `$stdout.write` at the end of the cycle. This stops the
455
- # emulator from rendering half-finished frames (e.g. a layout's
456
- # clear-background pass before its children have re-painted), which
457
- # was visible as a brief flicker when the auto-clear path triggers.
458
- #
459
- # Outside repaint, writes go straight to stdout. We deliberately
460
- # don't raise on a "print outside repaint" — that would be a useful
461
- # guardrail against components painting outside the repaint loop,
462
- # but it'd force terminal-housekeeping writes (`Screen#clear`,
463
- # mouse-tracking start/stop, cursor-show on teardown) to bypass
464
- # this method entirely and write directly to `$stdout`. {FakeScreen}
465
- # overrides `print` to capture every byte into its `@prints` array,
466
- # and tests that exercise `run_event_loop` against a real {Screen}
467
- # would otherwise leak escape sequences to the test runner's stdout.
468
- # Keeping `print` as the single sink preserves that override seam.
463
+ # Writes terminal-housekeeping escapes straight to stdout: {#clear},
464
+ # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
465
+ # on teardown. Component painting does *not* go through here anymore — it
466
+ # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
467
+ # overrides this (and {#emit}) to capture into `@prints` instead of the
468
+ # test runner's stdout.
469
469
  # @param args [String] stuff to print.
470
470
  # @return [void]
471
471
  def print(*args)
472
- if @frame_buffer
473
- args.each { |s| @frame_buffer << s.to_s }
474
- else
475
- Kernel.print(*args)
476
- end
472
+ Kernel.print(*args)
477
473
  end
478
474
 
479
475
  # Repaints the screen; tries to be as effective as possible, by only
@@ -489,64 +485,61 @@ module Tuile
489
485
  # simple and very fast in common cases.
490
486
 
491
487
  did_paint = false
492
- @frame_buffer = +""
493
- begin
494
- until @invalidated.empty?
495
- # Defensive filter: a component can become detached between enqueue
496
- # and drain (popup close, sibling removed mid-event-handling, focus
497
- # repair). Detached components have no place on the screen and must
498
- # never paint, even though Component#invalidate already gates them
499
- # out — this catches the case where attachment changed since.
500
- @invalidated.delete_if { |c| !c.attached? }
501
- break if @invalidated.empty?
502
-
503
- did_paint = true
504
- popups = @pane.popups
505
-
506
- # Partition invalidated components into tiled vs popup-tree. Sorting
507
- # by depth across the whole tree would interleave them: a tiled
508
- # grandchild (depth 3) sorts after a popup's content (depth 2) and
509
- # overdraws it.
510
- popup_tree = Set.new
511
- popups.each { |p| p.on_tree { popup_tree << _1 } }
512
- tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(_1) }
513
-
514
- # Within the tiled tree, paint parents before children.
515
- tiled.sort_by!(&:depth)
516
-
517
- repaint = if tiled.empty?
518
- # Only popups need repaint — paint just their invalidated
519
- # components in depth order.
520
- popup_invalidated.sort_by(&:depth)
521
- else
522
- # Tiled components may overdraw popups; repaint each open
523
- # popup's full subtree on top, in stacking order
524
- # (parent-before-child within each popup).
525
- tiled + popups.flat_map { |p| collect_subtree(p) }
526
- end
527
-
528
- @repainting = repaint.to_set
529
- @invalidated.clear
530
-
531
- # Don't call {#clear} before repaint — causes flickering, and only
532
- # needed when @content doesn't cover the entire screen.
533
- repaint.each(&:repaint)
534
-
535
- # Repaint done, mark all components as up-to-date.
536
- @repainting.clear
488
+ until @invalidated.empty?
489
+ # Defensive filter: a component can become detached between enqueue
490
+ # and drain (popup close, sibling removed mid-event-handling, focus
491
+ # repair). Detached components have no place on the screen and must
492
+ # never paint, even though Component#invalidate already gates them
493
+ # out this catches the case where attachment changed since.
494
+ @invalidated.delete_if { |c| !c.attached? }
495
+ break if @invalidated.empty?
496
+
497
+ did_paint = true
498
+ popups = @pane.popups
499
+
500
+ # Build the repaint list in z-order, leaning on the tree itself rather
501
+ # than a depth sort. The pane's pre-order traversal already orders the
502
+ # tiled layer (content subtree + status bar) parent-before-child; the
503
+ # popups are the top layer and must paint last. The status bar is a
504
+ # *late* pane child yet sits under the popups, so a single pane.on_tree
505
+ # walk won't do — we collect the tiled layer first, then append popups.
506
+ popup_members = Set.new
507
+ popups.each { |p| p.on_tree { popup_members << _1 } }
508
+
509
+ # Tiled layer: invalidated non-popup components, in tree order.
510
+ repaint = []
511
+ tiled_invalidated = false
512
+ @pane.on_tree do |c|
513
+ next if popup_members.include?(c)
514
+ next unless @invalidated.include?(c)
515
+
516
+ repaint << c
517
+ tiled_invalidated = true
537
518
  end
538
- position_cursor if did_paint
539
- unless @frame_buffer.empty?
540
- $stdout.write(@frame_buffer)
541
- $stdout.flush
519
+
520
+ # Popups on top: the whole stack when a tiled repaint may have clobbered
521
+ # cells they share in the buffer, else just the invalidated popup
522
+ # components. Overdraw into the buffer is free (only net-visible cell
523
+ # changes reach the terminal), so reasserting the stack is cheap.
524
+ popups.each do |p|
525
+ p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
542
526
  end
543
- ensure
544
- # Always release the frame buffer, even on exception, so any
545
- # subsequent {#print} call (e.g. teardown emits during crash unwind)
546
- # reaches stdout instead of being swallowed by a stranded buffer.
547
- # The partial frame we hold here is incoherent discard it.
548
- @frame_buffer = nil
527
+
528
+ @repainting = repaint.to_set
529
+ @invalidated.clear
530
+
531
+ # Components write into @buffer; overdraw is free and correct here
532
+ # because the buffer only diffs net-visible changes to the terminal.
533
+ repaint.each(&:repaint)
534
+
535
+ # Repaint done, mark all components as up-to-date.
536
+ @repainting.clear
549
537
  end
538
+ return unless did_paint
539
+
540
+ # Flush only the changed cells, then reposition the cursor — all inside
541
+ # one synchronized-output batch so the terminal composites it atomically.
542
+ emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}")
550
543
  end
551
544
 
552
545
  # Returns the absolute screen coordinates where the hardware cursor should
@@ -575,8 +568,8 @@ module Tuile
575
568
  # @param scheme [Symbol] `:dark` or `:light`.
576
569
  # @return [void]
577
570
  def on_color_scheme(scheme)
578
- @scheme = scheme
579
- self.theme = @theme_def.for(@scheme)
571
+ @color_scheme = scheme
572
+ self.theme = @theme_def.for(@color_scheme)
580
573
  end
581
574
 
582
575
  # Walks the current modal scope in pre-order, collects tab stops, and
@@ -588,7 +581,7 @@ module Tuile
588
581
  # @return [Boolean] true if focus moved.
589
582
  def cycle_focus(forward:)
590
583
  check_locked
591
- scope = @pane.popups.last || @pane.content
584
+ scope = @pane.modal_popup || @pane.content
592
585
  return false if scope.nil?
593
586
 
594
587
  stops = []
@@ -607,25 +600,22 @@ module Tuile
607
600
  true
608
601
  end
609
602
 
610
- # Collects a component and all its descendants in tree order
611
- # (parent before children).
612
- # @param component [Component]
613
- # @return [Array<Component>]
614
- def collect_subtree(component)
615
- result = []
616
- component.on_tree { result << _1 }
617
- result
603
+ # The escape sequence positioning the hardware cursor for the current focus
604
+ # state: hidden when nothing owns it, else moved to the focused component's
605
+ # {Component#cursor_position} and shown. Appended to each frame's flush.
606
+ # @return [String]
607
+ def cursor_sequence
608
+ pos = cursor_position
609
+ pos.nil? ? TTY::Cursor.hide : "#{TTY::Cursor.move_to(pos.x, pos.y)}#{TTY::Cursor.show}"
618
610
  end
619
611
 
620
- # Hides or moves the hardware cursor based on the current focus state.
612
+ # Writes an assembled frame (escape string) to the terminal. The single
613
+ # sink for repaint output; {FakeScreen} overrides it to capture instead.
614
+ # @param str [String]
621
615
  # @return [void]
622
- def position_cursor
623
- pos = cursor_position
624
- if pos.nil?
625
- print TTY::Cursor.hide
626
- else
627
- print TTY::Cursor.move_to(pos.x, pos.y), TTY::Cursor.show
628
- end
616
+ def emit(str)
617
+ $stdout.write(str)
618
+ $stdout.flush
629
619
  end
630
620
 
631
621
  # Recalculates positions of all windows, and repaints the scene.
@@ -634,6 +624,7 @@ module Tuile
634
624
  # @return [void]
635
625
  def layout
636
626
  check_locked
627
+ @buffer.resize(size) unless @buffer.size == size
637
628
  needs_full_repaint
638
629
  @pane.rect = Rect.new(0, 0, size.width, size.height)
639
630
  repaint
@@ -649,10 +640,12 @@ module Tuile
649
640
  # doesn't trap them.
650
641
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
651
642
  # registered with `over_popups: true` always fires; one with the
652
- # default `over_popups: false` fires only when no popup is open
653
- # (otherwise the popup receives the key normally).
654
- # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
655
- # tiled content.
643
+ # default `over_popups: false` fires only when no modal popup is open
644
+ # (otherwise the modal popup receives the key normally). A non-modal
645
+ # overlay doesn't suppress global shortcuts.
646
+ # 3. {ScreenPane#handle_key}, which captures a matching {#key_shortcut}
647
+ # in the active scope, then delivers the key to {#focused} and bubbles
648
+ # it up the focus chain.
656
649
  # @param key [String]
657
650
  # @return [Boolean] true if the key was handled by some window.
658
651
  def handle_key(key)
@@ -665,7 +658,7 @@ module Tuile
665
658
  true
666
659
  else
667
660
  shortcut = @global_shortcuts[key]
668
- if !shortcut.nil? && (shortcut.over_popups || @pane.popups.empty?)
661
+ if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?)
669
662
  shortcut.block.call
670
663
  true
671
664
  else
@@ -28,8 +28,9 @@ module Tuile
28
28
 
29
29
  # @return [Component, nil] the tiled content component.
30
30
  attr_reader :content
31
- # @return [Array<Component>] modal popups in stacking order; last is
32
- # topmost. The array must not be mutated by callers.
31
+ # @return [Array<Component>] overlay popups in stacking order; last is
32
+ # topmost. Holds both modal popups and non-modal overlays
33
+ # ({Component::Popup#modal?}). The array must not be mutated by callers.
33
34
  attr_reader :popups
34
35
  # @return [Component::Label] the bottom status bar.
35
36
  attr_reader :status_bar
@@ -57,7 +58,11 @@ module Tuile
57
58
  layout
58
59
  end
59
60
 
60
- # Adds a popup, centers it, focuses it, and invalidates it for repaint.
61
+ # Adds a popup and invalidates it for repaint. A modal popup is centered
62
+ # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
63
+ # left wherever the caller positions it and does *not* take focus, so the
64
+ # component that was focused keeps the cursor and keeps receiving keys —
65
+ # the overlay floats above the content, driven from app code.
61
66
  # @param window [Component::Popup]
62
67
  # @return [void]
63
68
  def add_popup(window)
@@ -67,8 +72,10 @@ module Tuile
67
72
  @popup_prior_focus[window] = screen.focused
68
73
  @popups << window
69
74
  window.parent = self
70
- window.center
71
- screen.focused = window
75
+ if window.modal?
76
+ window.center
77
+ screen.focused = window
78
+ end
72
79
  screen.invalidate(window)
73
80
  end
74
81
 
@@ -100,6 +107,13 @@ module Tuile
100
107
  # @return [Boolean] true if this pane currently hosts the popup.
101
108
  def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
102
109
 
110
+ # @return [Component::Popup, nil] the topmost *modal* popup, or nil when
111
+ # only non-modal overlays (or no popups) are open. This is the "modal
112
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
113
+ # the status bar, and confines Tab cycling. Non-modal overlays are
114
+ # excluded — they float above the content without capturing input.
115
+ def modal_popup = @popups.reverse_each.find(&:modal?)
116
+
103
117
  # Re-lays out children whenever the pane's own rect changes.
104
118
  # @param new_rect [Rect]
105
119
  # @return [void]
@@ -109,13 +123,16 @@ module Tuile
109
123
  end
110
124
 
111
125
  # Lays out content (full pane minus the bottom row) and the status bar
112
- # (bottom row). Popups self-position via {Component::Popup#center}.
126
+ # (bottom row). Each popup re-resolves its {Component::Popup#size} against
127
+ # the new screen via {Component::Popup#reposition} — so a {Fraction} size
128
+ # tracks resize — repositioning itself (modal popups recenter; non-modal
129
+ # overlays keep the top-left their owner assigned).
113
130
  # @return [void]
114
131
  def layout
115
132
  return if rect.empty?
116
133
 
117
134
  @content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
118
- @popups.each(&:center)
135
+ @popups.each(&:reposition)
119
136
  @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
120
137
  end
121
138
 
@@ -123,32 +140,55 @@ module Tuile
123
140
  # @return [void]
124
141
  def repaint; end
125
142
 
126
- # Topmost popup is modal: it eats keys. Falls through to content only
127
- # when no popup is open.
143
+ # Dispatches a key in two phases, both scoped to the topmost *modal* popup
144
+ # (when one is open) or else the tiled {#content}. Non-modal overlays are
145
+ # never the scope: focus stays in the content beneath them, and the overlay
146
+ # is driven by app code (which forwards keys to it explicitly), so it
147
+ # doesn't appear in this path at all.
148
+ #
149
+ # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
150
+ # focuses that component and consumes the key. Suppressed while a
151
+ # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
152
+ # {Component::TextField} isn't hijacked by a sibling's shortcut.
153
+ # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
154
+ # ancestor chain to the scope root; the first component to return true
155
+ # wins. Focus that is nil or sits outside the scope receives nothing,
156
+ # which is what keeps an open modal popup modal.
157
+ # @param key [String]
158
+ # @return [Boolean] true if the key was handled.
128
159
  def handle_key(key)
129
- topmost = @popups.last
130
- return topmost.handle_key(key) unless topmost.nil?
131
- return @content.handle_key(key) unless @content.nil?
160
+ scope = modal_popup || @content
161
+ return false if scope.nil?
132
162
 
133
- false
163
+ if screen.cursor_position.nil?
164
+ target = scope.find_shortcut_component(key)
165
+ unless target.nil?
166
+ screen.focused = target
167
+ return true
168
+ end
169
+ end
170
+
171
+ bubble_key(key, scope)
134
172
  end
135
173
 
136
174
  # Mouse events check popups in reverse stacking order (topmost first), and
137
- # fall through to content only when no popup is hit *and* there are no
138
- # popups open. This preserves modal click-blocking: an open popup eats
139
- # clicks even outside its rect.
175
+ # fall through to content only when no popup is hit *and* no modal popup is
176
+ # open. This preserves modal click-blocking an open modal eats clicks
177
+ # even outside its rect — while a non-modal overlay blocks nothing: clicks
178
+ # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
179
+ # content beneath.
140
180
  # @param event [MouseEvent]
141
181
  # @return [void]
142
182
  def handle_mouse(event)
143
183
  clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
144
- clicked = @content if clicked.nil? && @popups.empty?
184
+ clicked = @content if clicked.nil? && modal_popup.nil?
145
185
  clicked&.handle_mouse(event)
146
186
  end
147
187
 
148
188
  # Focus repair when a child detaches. Default {Component#on_child_removed}
149
189
  # would refocus to `self` (the pane), which isn't a useful focus target.
150
190
  # 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
191
+ # modal popup; falling back to the focus snapshotted when this popup was opened
152
192
  # (if still attached and still focusable); then to the first interactable
153
193
  # widget in {#content}; then to {#content} itself; then nil.
154
194
  #
@@ -167,7 +207,7 @@ module Tuile
167
207
  cursor = f
168
208
  while cursor
169
209
  if cursor == child
170
- fallback = first_tab_stop_or_root(@popups.last)
210
+ fallback = first_tab_stop_or_root(modal_popup)
171
211
  if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable?
172
212
  fallback = @removing_popup_prior
173
213
  end
@@ -181,6 +221,29 @@ module Tuile
181
221
 
182
222
  private
183
223
 
224
+ # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
225
+ # stopping at (and including) `scope`. Delivers to no one — returning false
226
+ # — when focus is nil or sits outside `scope`; the latter is what makes an
227
+ # open popup modal, since focus is always inside it and content beneath
228
+ # never receives keys.
229
+ # @param key [String]
230
+ # @param scope [Component] the modal scope root (topmost popup or content).
231
+ # @return [Boolean] true if some component on the chain handled the key.
232
+ def bubble_key(key, scope)
233
+ chain = []
234
+ cursor = screen.focused
235
+ until cursor.nil?
236
+ chain << cursor
237
+ break if cursor.equal?(scope)
238
+
239
+ cursor = cursor.parent
240
+ end
241
+ return false unless chain.last.equal?(scope)
242
+
243
+ chain.each { |c| return true if c.handle_key(key) }
244
+ false
245
+ end
246
+
184
247
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
185
248
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
186
249
  # if `root` is `nil`.
@@ -105,6 +105,45 @@ module Tuile
105
105
  # @param overrides [Hash{Symbol => Object}]
106
106
  # @return [Style]
107
107
  def merge(**overrides) = self.class.new(**to_h.merge(overrides))
108
+
109
+ # Minimal SGR escape that transitions a terminal already showing `self`
110
+ # into `other`: only the attributes that differ are emitted. Returns
111
+ # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
112
+ # (`\e[0m`, one code) when `other` is the default style — shorter than
113
+ # turning each attribute off individually.
114
+ #
115
+ # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
116
+ # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
117
+ # terminal currently holds), so both emit identical minimal sequences.
118
+ # @param other [Style] the style to transition to.
119
+ # @return [String]
120
+ def sgr_to(other)
121
+ return "" if self == other
122
+ return Ansi::RESET if other.default?
123
+
124
+ codes = []
125
+ codes << (other.bold ? 1 : 22) if bold != other.bold
126
+ codes << (other.italic ? 3 : 23) if italic != other.italic
127
+ codes << (other.underline ? 4 : 24) if underline != other.underline
128
+ codes << (other.strikethrough ? 9 : 29) if strikethrough != other.strikethrough
129
+ codes.concat(color_codes(other.fg, target: :fg)) if fg != other.fg
130
+ codes.concat(color_codes(other.bg, target: :bg)) if bg != other.bg
131
+ return "" if codes.empty?
132
+
133
+ "\e[#{codes.join(";")}m"
134
+ end
135
+
136
+ private
137
+
138
+ # @param color [Color, nil]
139
+ # @param target [Symbol] either `:fg` or `:bg`.
140
+ # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default"
141
+ # reset when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
142
+ def color_codes(color, target:)
143
+ return [target == :fg ? 39 : 49] if color.nil?
144
+
145
+ color.sgr_codes(target)
146
+ end
108
147
  end
109
148
 
110
149
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -586,7 +625,7 @@ module Tuile
586
625
  out = +""
587
626
  current = Style::DEFAULT
588
627
  @spans.each do |span|
589
- out << sgr_diff(current, span.style)
628
+ out << current.sgr_to(span.style)
590
629
  out << span.text
591
630
  current = span.style
592
631
  end
@@ -611,35 +650,6 @@ module Tuile
611
650
  result
612
651
  end
613
652
 
614
- # @param from [Style]
615
- # @param to [Style]
616
- # @return [String]
617
- def sgr_diff(from, to)
618
- return "" if from == to
619
- return Ansi::RESET if to.default?
620
-
621
- codes = []
622
- codes << (to.bold ? 1 : 22) if from.bold != to.bold
623
- codes << (to.italic ? 3 : 23) if from.italic != to.italic
624
- codes << (to.underline ? 4 : 24) if from.underline != to.underline
625
- codes << (to.strikethrough ? 9 : 29) if from.strikethrough != to.strikethrough
626
- codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
627
- codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
628
- return "" if codes.empty?
629
-
630
- "\e[#{codes.join(";")}m"
631
- end
632
-
633
- # @param color [Color, nil]
634
- # @param target [Symbol] `:fg` or `:bg`.
635
- # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default" reset
636
- # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
637
- def color_codes(color, target:)
638
- return [target == :fg ? 39 : 49] if color.nil?
639
-
640
- color.sgr_codes(target)
641
- end
642
-
643
653
  # @param start_or_range [Integer, Range]
644
654
  # @param len [Integer, nil]
645
655
  # @param total [Integer] receiver's full display width.
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.7.0"
5
+ VERSION = "0.9.0"
6
6
  end