tuile 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdf4dfc626b692fdeeb9dabbcd08fe94a74045ae24340b24074d601856b87bf8
4
- data.tar.gz: a38a8d8f04c53341a1bf6adfbf551209fc8e917f6957076bcf291bb4c2f7837f
3
+ metadata.gz: ffd96aaba12d84ccc9f3417db01f75b3b91f0f89cd7358864fdfd1b0dcaa778b
4
+ data.tar.gz: fdbc08c48e4b908ee8b3cebc7bf949e88cf6cc2a1bb58260b53c8612bc6060cc
5
5
  SHA512:
6
- metadata.gz: ee942fd7bd9c9c35212f20ae78d043df5c2fe5c9dfb3e3ca5b1e3acd98a1dfd9d7708c5a8d481f49b690c50c56b8d9f81f771adb668937ae0b45e5244f84fd71
7
- data.tar.gz: 2735212649ff50b35814125ce0d91043f91bba7e4a8c4aa4a35b730708f66473536e2c240c70f45197bd89ef36154694119c8dfa0c70527f28e8c6520419dcd5
6
+ metadata.gz: bc286448b580b3978de8652088f491e989618e0fb63c6063035f8f284ae8b57a2192a3fea7913cb26cfee1147e1bc7108e2288da53a1676acf874a069c8c0249
7
+ data.tar.gz: 20aa07e2b6b53ed16e55211401277b1d97a87bc47e4fda07d7d0f8d642a36b3238ec46495262541f6b5e164d6fcde7bcf5b574a7cd22fc41f38eec9724a3e76c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-06-09
4
+
5
+ - Lower the Ruby floor to 3.3 (was 3.4): replaced the `it` implicit block parameter (3.4+) with `_1` throughout, and added 3.3 to the CI matrix.
6
+ - Fix `Component::Popup#close` raising `Tuile::Error` when the popup was not open — it is now the documented no-op (also covers calling `close` twice). `Screen#remove_popup` honors its "does nothing if not open" contract by guarding on `has_popup?`; `ScreenPane#remove_popup` keeps its strict internal assertion.
7
+
3
8
  ## [0.6.0] - 2026-06-07
4
9
 
5
10
  - Add `Tuile::Theme` — semantic color tokens for the accents built-in components paint (the list-cursor/focused-input highlight `active_bg_color`, the inactive input well `input_bg_color`, the active window border `active_border_color`, the status-bar `hint_color`), with `DARK`/`LIGHT` presets and rendering helpers (`#active_bg`, `#active_border`, `#input_bg`, `#hint`). The current theme lives at `Screen#theme`; assigning restyles the whole UI in a single invalidate-everything pass. Everything that isn't an accent keeps inheriting the terminal's own default fg/bg.
data/README.md CHANGED
@@ -47,7 +47,7 @@ Or pin to git directly:
47
47
  gem "tuile", git: "https://github.com/mvysny/tuile.git"
48
48
  ```
49
49
 
50
- Tuile requires Ruby 3.4+.
50
+ Tuile requires Ruby 3.3+.
51
51
 
52
52
  API documentation: <https://rubydoc.info/gems/tuile>.
53
53
 
data/examples/sampler.rb CHANGED
@@ -39,7 +39,7 @@ module SamplerExample
39
39
  def initialize
40
40
  super()
41
41
  @entry_list = build_entry_list
42
- @left_window = Tuile::Component::Window.new("Components").tap { it.content = @entry_list }
42
+ @left_window = Tuile::Component::Window.new("Components").tap { _1.content = @entry_list }
43
43
  @right_window = Tuile::Component::Window.new
44
44
  add(@left_window)
45
45
  add(@right_window)
@@ -202,9 +202,9 @@ module SamplerExample
202
202
 
203
203
  def build_layout
204
204
  left = Tuile::Component::Window.new("Left")
205
- left.content = Tuile::Component::Label.new.tap { it.text = "Nested left window." }
205
+ left.content = Tuile::Component::Label.new.tap { _1.text = "Nested left window." }
206
206
  right = Tuile::Component::Window.new("Right")
207
- right.content = Tuile::Component::Label.new.tap { it.text = "Nested right window." }
207
+ right.content = Tuile::Component::Label.new.tap { _1.text = "Nested right window." }
208
208
  panel(left, right) do |r|
209
209
  half = r.width / 2
210
210
  left.rect = Tuile::Rect.new(r.left, r.top, half, r.height)
@@ -34,7 +34,7 @@ module Tuile
34
34
  # @return [void]
35
35
  def add(child)
36
36
  if child.is_a? Enumerable
37
- child.each { add(it) }
37
+ child.each { add(_1) }
38
38
  else
39
39
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
40
40
  raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
@@ -23,6 +23,7 @@ module Tuile
23
23
  @padded_lines = []
24
24
  @blank_padded = StyledString::EMPTY
25
25
  @auto_scroll = false
26
+ @follow = true
26
27
  @top_line = 0
27
28
  @cursor = Cursor::None.new
28
29
  @scrollbar_visibility = :gone
@@ -50,9 +51,18 @@ module Tuile
50
51
  attr_accessor :on_cursor_changed
51
52
 
52
53
  # @return [Boolean] if true and a line is added or new content is set,
53
- # auto-scrolls to the bottom.
54
+ # auto-scrolls to the bottom — but only while the viewport is already
55
+ # pinned to the last line (see {#following?}). Scroll up to read older
56
+ # content and appends stop yanking you back down; scroll back to the
57
+ # bottom and tailing resumes.
54
58
  attr_reader :auto_scroll
55
59
 
60
+ # @return [Boolean] whether {#auto_scroll} is currently tailing. True
61
+ # while the viewport sits at the last line; flips to false the moment
62
+ # the user scrolls up, and back to true once they scroll to the bottom
63
+ # again. Only consulted when {#auto_scroll} is enabled.
64
+ def following? = @follow
65
+
56
66
  # @return [Integer] top line of the viewport. 0 or positive.
57
67
  attr_reader :top_line
58
68
 
@@ -87,10 +97,12 @@ module Tuile
87
97
  invalidate
88
98
  end
89
99
 
90
- # Sets the new auto_scroll. If true, immediately scrolls to the bottom.
100
+ # Sets the new auto_scroll. If true, re-engages tailing and immediately
101
+ # scrolls to the bottom.
91
102
  # @param new_auto_scroll [Boolean]
92
103
  def auto_scroll=(new_auto_scroll)
93
104
  @auto_scroll = new_auto_scroll
105
+ @follow = true if new_auto_scroll
94
106
  update_top_line_if_auto_scroll
95
107
  end
96
108
 
@@ -113,6 +125,7 @@ module Tuile
113
125
  return unless @top_line != new_top_line
114
126
 
115
127
  @top_line = new_top_line
128
+ @follow = at_bottom?
116
129
  invalidate
117
130
  end
118
131
 
@@ -163,6 +176,7 @@ module Tuile
163
176
  # @return [void]
164
177
  def add_line(line)
165
178
  raise ArgumentError, "line is nil" if line.nil?
179
+
166
180
  add_lines [line]
167
181
  end
168
182
 
@@ -417,7 +431,7 @@ module Tuile
417
431
  # @param position [Integer] initial position.
418
432
  def initialize(positions, position: positions[0])
419
433
  @positions = positions.sort
420
- position = @positions[@positions.rindex { it < position } || 0] unless @positions.include?(position)
434
+ position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
421
435
  super(position: position)
422
436
  end
423
437
 
@@ -427,7 +441,7 @@ module Tuile
427
441
  # @return [Boolean]
428
442
  def handle_mouse(line, event, _line_count)
429
443
  if event.button == :left
430
- prev_pos = @positions.reverse_each.find { it <= line }
444
+ prev_pos = @positions.reverse_each.find { _1 <= line }
431
445
  return go_to_first if prev_pos.nil?
432
446
 
433
447
  go(prev_pos)
@@ -439,7 +453,7 @@ module Tuile
439
453
  # @param line_count [Integer]
440
454
  # @return [Array<Integer>]
441
455
  def candidate_positions(line_count)
442
- @positions.select { it < line_count }
456
+ @positions.select { _1 < line_count }
443
457
  end
444
458
 
445
459
  # @param _line_count [Integer]
@@ -454,7 +468,7 @@ module Tuile
454
468
  # @param line_count [Integer]
455
469
  # @return [Boolean]
456
470
  def go_down_by(lines, line_count)
457
- next_pos = @positions.find { it >= @position + lines }
471
+ next_pos = @positions.find { _1 >= @position + lines }
458
472
  return go_to_last(line_count) if next_pos.nil?
459
473
 
460
474
  go(next_pos)
@@ -463,7 +477,7 @@ module Tuile
463
477
  # @param lines [Integer]
464
478
  # @return [Boolean]
465
479
  def go_up_by(lines)
466
- prev_pos = @positions.reverse_each.find { it <= @position - lines }
480
+ prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
467
481
  return go_to_first if prev_pos.nil?
468
482
 
469
483
  go(prev_pos)
@@ -622,16 +636,16 @@ module Tuile
622
636
  def order_for_search(candidates, current, include_current:, reverse:)
623
637
  if reverse
624
638
  before, after = if include_current
625
- [candidates.select { it <= current }, candidates.select { it > current }]
639
+ [candidates.select { _1 <= current }, candidates.select { _1 > current }]
626
640
  else
627
- [candidates.select { it < current }, candidates.select { it >= current }]
641
+ [candidates.select { _1 < current }, candidates.select { _1 >= current }]
628
642
  end
629
643
  before.reverse + after.reverse
630
644
  else
631
645
  after, before = if include_current
632
- [candidates.select { it >= current }, candidates.select { it < current }]
646
+ [candidates.select { _1 >= current }, candidates.select { _1 < current }]
633
647
  else
634
- [candidates.select { it > current }, candidates.select { it <= current }]
648
+ [candidates.select { _1 > current }, candidates.select { _1 <= current }]
635
649
  end
636
650
  after + before
637
651
  end
@@ -653,6 +667,10 @@ module Tuile
653
667
  # @return [Integer] the max value of {#top_line}.
654
668
  def top_line_max = (@lines.size - rect.height).clamp(0, nil)
655
669
 
670
+ # @return [Boolean] whether the viewport is pinned to the last line.
671
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
672
+ def at_bottom? = @top_line == top_line_max
673
+
656
674
  # @return [Integer] the number of visible lines.
657
675
  def viewport_lines = rect.height
658
676
 
@@ -675,9 +693,14 @@ module Tuile
675
693
  # which would leave `top_line` past the last item once a real rect
676
694
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
677
695
  # the snap-to-bottom intent is preserved.
696
+ #
697
+ # Gated on {#following?}: once the user scrolls up off the bottom the
698
+ # cursor snap and viewport pin are both skipped, so reading older
699
+ # content is not interrupted by incoming lines. {#top_line=} re-arms
700
+ # `@follow` when the viewport returns to the bottom.
678
701
  # @return [void]
679
702
  def update_top_line_if_auto_scroll
680
- return unless @auto_scroll
703
+ return unless @auto_scroll && @follow
681
704
  return if rect.empty?
682
705
 
683
706
  notify_cursor_changed if @cursor.go_to_last(@lines.size)
@@ -28,6 +28,7 @@ module Tuile
28
28
  # @return [void]
29
29
  def log(string)
30
30
  return if string.nil?
31
+
31
32
  screen.event_queue.submit do
32
33
  content.add_line(string)
33
34
  end
@@ -34,10 +34,10 @@ module Tuile
34
34
  raise ArgumentError, "options must not be empty" if options.empty?
35
35
 
36
36
  super(caption)
37
- @options = options.map { Option.new(it[0], it[1]) }
37
+ @options = options.map { Option.new(_1[0], _1[1]) }
38
38
  @block = block
39
39
  list = Component::List.new
40
- list.lines = @options.map { "#{it.key} #{screen.theme.hint(it.caption)}" }
40
+ list.lines = @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }
41
41
  list.cursor = Component::List::Cursor.new
42
42
  list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
43
43
  self.content = list
@@ -55,7 +55,7 @@ module Tuile
55
55
  def handle_key(key)
56
56
  return true if super
57
57
 
58
- if @options.any? { it.key == key }
58
+ if @options.any? { _1.key == key }
59
59
  select_option(key)
60
60
  true
61
61
  else
@@ -65,7 +65,7 @@ module Tuile
65
65
 
66
66
  # @return [String]
67
67
  def keyboard_hint
68
- @options.map { "#{it.key} #{screen.theme.hint(it.caption)}" }.join(" ")
68
+ @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
69
69
  end
70
70
 
71
71
  # Opens a picker as a popup. Picking an option fires `block`, then
@@ -35,6 +35,21 @@ module Tuile
35
35
 
36
36
  def focusable? = true
37
37
 
38
+ # Reassigns the popup's rect, escalating to a full scene repaint when an
39
+ # open popup shrinks or moves so its new rect no longer covers the cells
40
+ # it previously painted. A popup overdraws the scene without clipping and
41
+ # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
42
+ # would repaint into the new rect and leave the vacated cells showing
43
+ # stale content. When the new rect fully covers the old one (the popup
44
+ # only grew), the fast path is correct and the full repaint is skipped.
45
+ # @param new_rect [Rect]
46
+ # @return [void]
47
+ def rect=(new_rect)
48
+ old_rect = rect
49
+ super
50
+ screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
51
+ end
52
+
38
53
  # Mounts this popup on the {Screen}. Recomputes the popup's size from
39
54
  # the current content first, so reopening a popup whose content has
40
55
  # grown or shrunk while closed picks up the new size.
@@ -125,12 +140,18 @@ module Tuile
125
140
 
126
141
  # Recompute width/height from {#content}'s natural size and recenter
127
142
  # if currently open. Called whenever content is (re)assigned.
143
+ #
144
+ # Computes the final (centered) rect and assigns it in one step rather
145
+ # than positioning at the origin and then centering: the intermediate
146
+ # origin rect rarely covers the previous one, which would make
147
+ # {#rect=}'s shrink/move detection fire a full repaint on every resize.
128
148
  # @return [void]
129
149
  def update_rect
130
150
  size = @content.content_size.clamp_height(max_height)
131
151
  size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
132
- self.rect = Rect.new(0, 0, size.width, size.height)
133
- center if open?
152
+ r = Rect.new(0, 0, size.width, size.height)
153
+ r = r.centered(screen.size) if open?
154
+ self.rect = r
134
155
  end
135
156
  end
136
157
  end
@@ -53,6 +53,7 @@ module Tuile
53
53
  @blank_line = StyledString::EMPTY
54
54
  @top_line = 0
55
55
  @auto_scroll = false
56
+ @follow = true
56
57
  @scrollbar_visibility = :gone
57
58
  # The view always has at least one region — an implicit default. It
58
59
  # owns whatever hard lines exist that no later region claims. App
@@ -80,9 +81,18 @@ module Tuile
80
81
  attr_reader :scrollbar_visibility
81
82
 
82
83
  # @return [Boolean] if true, mutating the text scrolls the viewport so
83
- # the last line stays in view. Default `false`.
84
+ # the last line stays in view but only while the viewport is already
85
+ # pinned to the last line (see {#following?}). Scroll up to read older
86
+ # content and appends stop yanking you back down; scroll back to the
87
+ # bottom and tailing resumes. Default `false`.
84
88
  attr_reader :auto_scroll
85
89
 
90
+ # @return [Boolean] whether {#auto_scroll} is currently tailing. True
91
+ # while the viewport sits at the last line; flips to false the moment
92
+ # the user scrolls up, and back to true once they scroll to the bottom
93
+ # again. Only consulted when {#auto_scroll} is enabled.
94
+ def following? = @follow
95
+
86
96
  # Replaces the text. Embedded `\n` characters become hard line breaks.
87
97
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
88
98
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
@@ -347,6 +357,7 @@ module Tuile
347
357
  return if @top_line == new_top_line
348
358
 
349
359
  @top_line = new_top_line
360
+ @follow = at_bottom?
350
361
  invalidate
351
362
  end
352
363
 
@@ -361,11 +372,13 @@ module Tuile
361
372
  invalidate
362
373
  end
363
374
 
364
- # Sets `auto_scroll`. If true, immediately scrolls to the bottom.
375
+ # Sets `auto_scroll`. If true, re-engages tailing and immediately
376
+ # scrolls to the bottom.
365
377
  # @param value [Boolean]
366
378
  # @return [void]
367
379
  def auto_scroll=(value)
368
380
  @auto_scroll = value ? true : false
381
+ @follow = true if @auto_scroll
369
382
  update_top_line_if_auto_scroll
370
383
  end
371
384
 
@@ -848,14 +861,22 @@ module Tuile
848
861
  self.top_line = clamped unless @top_line == clamped
849
862
  end
850
863
 
864
+ # Gated on {#following?}: once the user scrolls up off the bottom the
865
+ # viewport pin is skipped, so reading older content is not interrupted
866
+ # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
867
+ # returns to the bottom.
851
868
  # @return [void]
852
869
  def update_top_line_if_auto_scroll
853
- return unless @auto_scroll
870
+ return unless @auto_scroll && @follow
854
871
 
855
872
  target = (@physical_lines.size - viewport_lines).clamp(0, nil)
856
873
  self.top_line = target if @top_line != target
857
874
  end
858
875
 
876
+ # @return [Boolean] whether the viewport is pinned to the last line.
877
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
878
+ def at_bottom? = @top_line == top_line_max
879
+
859
880
  # @return [Boolean]
860
881
  def scrollbar_visible?
861
882
  return false if rect.empty?
@@ -190,7 +190,7 @@ module Tuile
190
190
  # @return [void]
191
191
  def on_tree(&block)
192
192
  block.call(self)
193
- children.each { it.on_tree(&block) }
193
+ children.each { _1.on_tree(&block) }
194
194
  end
195
195
 
196
196
  # Called when the component receives focus.
data/lib/tuile/keys.rb CHANGED
@@ -156,9 +156,7 @@ module Tuile
156
156
  # sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
157
157
  # with a blocking read so the parser downstream sees a complete event
158
158
  # instead of leaking tail bytes as keypresses.
159
- if char.start_with?("\e[M") && char.bytesize < 6
160
- char += $stdin.read(6 - char.bytesize)
161
- end
159
+ char += $stdin.read(6 - char.bytesize) if char.start_with?("\e[M") && char.bytesize < 6
162
160
 
163
161
  # Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
164
162
  # can outgrow the 5-byte gulp above — the mode-2031 color-scheme
@@ -166,9 +164,7 @@ module Tuile
166
164
  # bytes after the `\e`. Drain to the final byte with blocking 1-byte
167
165
  # reads so the tail doesn't surface as phantom keypresses. Keyboard
168
166
  # sequences never start with `\e[?`, so this can't eat a regular key.
169
- if char.start_with?("\e[?")
170
- char += $stdin.read(1) until char.match?(/[\x40-\x7e]\z/)
171
- end
167
+ char += $stdin.read(1) while char.start_with?("\e[?") && !char.match?(/[\x40-\x7e]\z/)
172
168
 
173
169
  char
174
170
  end
data/lib/tuile/rect.rb CHANGED
@@ -49,6 +49,18 @@ module Tuile
49
49
  point.x >= left && point.x < left + width && point.y >= top && point.y < top + height
50
50
  end
51
51
 
52
+ # @param other [Rect] another rectangle.
53
+ # @return [Boolean] true if `other` lies entirely within this rectangle.
54
+ # Uses the same half-open edges as {#contains?} (right/bottom exclusive).
55
+ # An {#empty? empty} `other` covers no cells, so it is trivially contained.
56
+ def contains_rect?(other)
57
+ return true if other.empty?
58
+
59
+ other.left >= left && other.top >= top &&
60
+ other.left + other.width <= left + width &&
61
+ other.top + other.height <= top + height
62
+ end
63
+
52
64
  # @return [Size]
53
65
  def size = Size.new(width, height)
54
66
 
data/lib/tuile/screen.rb CHANGED
@@ -206,7 +206,7 @@ module Tuile
206
206
  check_locked
207
207
  if focused.nil?
208
208
  @focused = nil
209
- @pane.on_tree { it.active = false }
209
+ @pane.on_tree { _1.active = false }
210
210
  else
211
211
  raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
212
212
 
@@ -217,7 +217,7 @@ module Tuile
217
217
  active << cursor
218
218
  cursor = cursor.parent
219
219
  end
220
- @pane.on_tree { it.active = active.include?(it) }
220
+ @pane.on_tree { _1.active = active.include?(_1) }
221
221
  @focused.on_focus
222
222
  end
223
223
  refresh_status_bar
@@ -370,9 +370,7 @@ module Tuile
370
370
  raise ArgumentError,
371
371
  "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
372
372
  end
373
- unless hint.nil? || hint.is_a?(String)
374
- raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}"
375
- end
373
+ raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}" unless hint.nil? || hint.is_a?(String)
376
374
 
377
375
  @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
378
376
  refresh_status_bar
@@ -391,7 +389,7 @@ module Tuile
391
389
  def active_window
392
390
  check_locked
393
391
  result = nil
394
- @pane.content&.on_tree { result = it if it.is_a?(Component::Window) && it.active? }
392
+ @pane.content&.on_tree { result = _1 if _1.is_a?(Component::Window) && _1.active? }
395
393
  result
396
394
  end
397
395
 
@@ -404,10 +402,25 @@ module Tuile
404
402
  # @return [void]
405
403
  def remove_popup(window)
406
404
  check_locked
405
+ return unless @pane.has_popup?(window)
406
+
407
407
  @pane.remove_popup(window)
408
408
  needs_full_repaint
409
409
  end
410
410
 
411
+ # Invalidates the entire attached tree, forcing every component to repaint
412
+ # on the next cycle. Needed whenever something overdraws the scene without
413
+ # clipping and then exposes what was underneath — a closing popup
414
+ # ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
415
+ # longer covers the cells it previously painted ({Component::Popup#rect=}).
416
+ # The popup-only fast path in {#repaint} can't clear those vacated cells on
417
+ # its own, so we accept the cost of a full repaint.
418
+ # @api private
419
+ # @return [void]
420
+ def needs_full_repaint
421
+ @pane&.on_tree { invalidate _1 }
422
+ end
423
+
411
424
  # Internal — use {Component::Popup#open?} instead.
412
425
  # @api private
413
426
  # @param window [Component::Popup]
@@ -495,8 +508,8 @@ module Tuile
495
508
  # grandchild (depth 3) sorts after a popup's content (depth 2) and
496
509
  # overdraws it.
497
510
  popup_tree = Set.new
498
- popups.each { |p| p.on_tree { popup_tree << it } }
499
- tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
511
+ popups.each { |p| p.on_tree { popup_tree << _1 } }
512
+ tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(_1) }
500
513
 
501
514
  # Within the tiled tree, paint parents before children.
502
515
  tiled.sort_by!(&:depth)
@@ -600,7 +613,7 @@ module Tuile
600
613
  # @return [Array<Component>]
601
614
  def collect_subtree(component)
602
615
  result = []
603
- component.on_tree { result << it }
616
+ component.on_tree { result << _1 }
604
617
  result
605
618
  end
606
619
 
@@ -626,13 +639,6 @@ module Tuile
626
639
  repaint
627
640
  end
628
641
 
629
- # Called after a popup is closed. Since a popup can cover any window,
630
- # top-level component or other popups, we need to redraw everything.
631
- # @return [void]
632
- def needs_full_repaint
633
- @pane&.on_tree { invalidate it }
634
- end
635
-
636
642
  # A key has been pressed on the keyboard. Handle it, or forward to active
637
643
  # window.
638
644
  #
@@ -140,7 +140,7 @@ module Tuile
140
140
  # @param event [MouseEvent]
141
141
  # @return [void]
142
142
  def handle_mouse(event)
143
- clicked = @popups.reverse_each.find { it.rect.contains?(event.point) }
143
+ clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
144
144
  clicked = @content if clicked.nil? && @popups.empty?
145
145
  clicked&.handle_mouse(event)
146
146
  end
@@ -3,7 +3,7 @@
3
3
  module Tuile
4
4
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
5
5
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
6
- # `underline`). Spans are non-overlapping and fully tile the string — every
6
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
7
7
  # character has exactly one resolved style, no overlay layers to merge.
8
8
  #
9
9
  # Where this differs from threading SGR escapes through a plain `String`:
@@ -43,12 +43,21 @@ module Tuile
43
43
  #
44
44
  # ## Parser
45
45
  #
46
- # {.parse} is strict by design: it recognizes only the SGR codes
46
+ # {.parse} is strict by default: it recognizes only the SGR codes
47
47
  # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
48
- # underline). Anything else — unmodeled attributes (dim, blink, reverse,
49
- # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
48
+ # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
49
+ # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
50
50
  # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
51
51
  # round-trip parse(to_ansi(x)) == x contract honest.
52
+ #
53
+ # Pass `lenient: true` to instead **discard** everything the parser can't
54
+ # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
55
+ # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
56
+ # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
57
+ # silently dropped. This is the mode for piping in colored output you don't
58
+ # control (e.g. `git --color` through a pager): "give me the colors, throw
59
+ # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
60
+ # round-trip back to `x`.
52
61
  class StyledString
53
62
  # Raised by {.parse} on malformed or unsupported escape sequences.
54
63
  class ParseError < Error; end
@@ -69,16 +78,19 @@ module Tuile
69
78
  # @return [Boolean]
70
79
  # @!attribute [r] underline
71
80
  # @return [Boolean]
72
- class Style < Data.define(:fg, :bg, :bold, :italic, :underline)
81
+ # @!attribute [r] strikethrough
82
+ # @return [Boolean]
83
+ class Style < Data.define(:fg, :bg, :bold, :italic, :underline, :strikethrough)
73
84
  # @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
74
85
  # @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
75
86
  # @param bold [Boolean]
76
87
  # @param italic [Boolean]
77
88
  # @param underline [Boolean]
89
+ # @param strikethrough [Boolean]
78
90
  # @return [Style]
79
91
  # @raise [ArgumentError] when a color is not one of the accepted forms.
80
- def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false)
81
- super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:)
92
+ def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false, strikethrough: false)
93
+ super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:, strikethrough:)
82
94
  end
83
95
 
84
96
  # The style with no color and no attributes — what the terminal shows
@@ -117,8 +129,9 @@ module Tuile
117
129
  # @api private
118
130
  # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
119
131
  # text accumulates into the current span; each `\e[...m` flushes the
120
- # current span and updates the running {Style}. Anything outside the
121
- # supported SGR alphabet raises {ParseError}.
132
+ # current span and updates the running {Style}. In strict mode anything
133
+ # outside the supported SGR alphabet raises {ParseError}; in lenient mode
134
+ # it is consumed and discarded (see {StyledString} "## Parser").
122
135
  class Parser
123
136
  # @return [Array<Symbol>]
124
137
  STANDARD_COLORS = Color::COLOR_SYMBOLS[0, 8].freeze
@@ -128,9 +141,20 @@ module Tuile
128
141
  BRIGHT_COLORS = Color::COLOR_SYMBOLS[8, 8].freeze
129
142
  private_constant :BRIGHT_COLORS
130
143
 
144
+ # ESC-introducers (the byte after `\e`) whose payload runs until a string
145
+ # terminator (ST `\e\\` or BEL): OSC `]`, DCS `P`, SOS `X`, PM `^`,
146
+ # APC `_`. In lenient mode the whole sequence — payload included — is
147
+ # swallowed so it never leaks into span text.
148
+ # @return [Array<String>]
149
+ STRING_INTRODUCERS = %w(] P X ^ _).freeze
150
+ private_constant :STRING_INTRODUCERS
151
+
131
152
  # @param input [String]
132
- def initialize(input)
153
+ # @param lenient [Boolean] when true, discard unmodeled SGR codes and
154
+ # non-SGR escapes instead of raising {ParseError}.
155
+ def initialize(input, lenient: false)
133
156
  @scanner = StringScanner.new(input)
157
+ @lenient = lenient
134
158
  @style = Style::DEFAULT
135
159
  @text = +""
136
160
  @spans = []
@@ -160,16 +184,70 @@ module Tuile
160
184
  # @return [void]
161
185
  def consume_escape
162
186
  @scanner.getch # \e
163
- bracket = @scanner.getch
164
- raise ParseError, "expected '[' after ESC, got #{bracket.inspect}" if bracket != "["
187
+ intro = @scanner.getch
188
+ case intro
189
+ when "[" then consume_csi
190
+ when nil then raise ParseError, "unterminated escape sequence" unless @lenient
191
+ else
192
+ raise ParseError, "expected '[' after ESC, got #{intro.inspect}" unless @lenient
193
+
194
+ consume_non_csi(intro)
195
+ end
196
+ end
165
197
 
166
- params = @scanner.scan(/[\d;]*/) || ""
198
+ # Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
199
+ # (`\e[...m` with numeric/`;` params and no intermediates) is applied;
200
+ # anything else is a non-SGR or malformed CSI — raises in strict mode,
201
+ # swallowed in lenient. Scans the full CSI grammar (parameter bytes
202
+ # `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
203
+ # mode consumes the whole sequence even for private-marker forms like
204
+ # `\e[?25l`.
205
+ # @return [void]
206
+ def consume_csi
207
+ params = @scanner.scan(/[\x30-\x3F]*/) || ""
208
+ intermediates = @scanner.scan(/[\x20-\x2F]*/) || ""
167
209
  final = @scanner.getch
168
- raise ParseError, "unterminated escape sequence" if final.nil?
169
- raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" if final != "m"
170
210
 
211
+ if final == "m" && intermediates.empty? && params.match?(/\A[\d;]*\z/)
212
+ flush
213
+ return apply_sgr(params)
214
+ end
215
+
216
+ raise ParseError, "unterminated escape sequence" if final.nil? && !@lenient
217
+ raise ParseError, "non-SGR CSI sequence (final byte #{final.inspect})" unless @lenient
218
+
219
+ flush
220
+ end
221
+
222
+ # Lenient-only: discards a non-CSI escape (`\e` and `intro` already
223
+ # eaten). OSC/DCS/string sequences run to their string terminator; an
224
+ # nF escape (`\e( B`) eats its intermediates plus one final byte; any
225
+ # other Fe/Fp/Fs escape was complete in `intro` alone.
226
+ # @param intro [String] the byte after `\e` (never `"["`).
227
+ # @return [void]
228
+ def consume_non_csi(intro)
171
229
  flush
172
- apply_sgr(params)
230
+ if STRING_INTRODUCERS.include?(intro)
231
+ consume_string_sequence
232
+ elsif intro.match?(/[\x20-\x2F]/)
233
+ @scanner.scan(/[\x20-\x2F]*/)
234
+ @scanner.getch
235
+ end
236
+ end
237
+
238
+ # Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
239
+ # including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
240
+ # @return [void]
241
+ def consume_string_sequence
242
+ until @scanner.eos?
243
+ ch = @scanner.getch
244
+ break if ch == "\a"
245
+
246
+ if ch == "\e"
247
+ @scanner.getch if @scanner.peek(1) == "\\"
248
+ break
249
+ end
250
+ end
173
251
  end
174
252
 
175
253
  # @param params_str [String]
@@ -187,6 +265,8 @@ module Tuile
187
265
  when 23 then @style = @style.merge(italic: false)
188
266
  when 4 then @style = @style.merge(underline: true)
189
267
  when 24 then @style = @style.merge(underline: false)
268
+ when 9 then @style = @style.merge(strikethrough: true)
269
+ when 29 then @style = @style.merge(strikethrough: false)
190
270
  when 30..37 then @style = @style.merge(fg: STANDARD_COLORS[code - 30])
191
271
  when 38
192
272
  i += consume_extended_color(codes, i, :fg)
@@ -199,7 +279,7 @@ module Tuile
199
279
  when 49 then @style = @style.merge(bg: nil)
200
280
  when 90..97 then @style = @style.merge(fg: BRIGHT_COLORS[code - 90])
201
281
  when 100..107 then @style = @style.merge(bg: BRIGHT_COLORS[code - 100])
202
- else raise ParseError, "unsupported SGR code #{code}"
282
+ else raise ParseError, "unsupported SGR code #{code}" unless @lenient
203
283
  end
204
284
  i += 1
205
285
  end
@@ -208,27 +288,37 @@ module Tuile
208
288
  # @param codes [Array<Integer>]
209
289
  # @param index [Integer]
210
290
  # @param target [Symbol] either `:fg` or `:bg`.
211
- # @return [Integer] how many SGR codes were consumed (3 for 256-color, 5 for RGB).
291
+ # @return [Integer] how many SGR codes were consumed. In lenient mode a
292
+ # malformed color is skipped rather than applied, but the same count is
293
+ # returned (3 for 256-color, 5 for RGB) so the running index advances
294
+ # past its operands; an unknown selector skips just `38`/`48` + the
295
+ # selector byte (2), letting the rest be reprocessed.
212
296
  def consume_extended_color(codes, index, target)
213
297
  mode = codes[index + 1]
214
298
  case mode
215
299
  when 5
216
300
  n = codes[index + 2]
217
- raise ParseError, "invalid 256-color index #{n.inspect}" unless n&.between?(0, 255)
218
-
219
- @style = @style.merge(target => n)
301
+ if n&.between?(0, 255)
302
+ @style = @style.merge(target => n)
303
+ elsif !@lenient
304
+ raise ParseError, "invalid 256-color index #{n.inspect}"
305
+ end
220
306
  3
221
307
  when 2
222
308
  r = codes[index + 2]
223
309
  g = codes[index + 3]
224
310
  b = codes[index + 4]
225
- [r, g, b].each do |v|
226
- raise ParseError, "invalid RGB component #{v.inspect}" unless v&.between?(0, 255)
311
+ if [r, g, b].all? { |v| v&.between?(0, 255) }
312
+ @style = @style.merge(target => [r, g, b])
313
+ elsif !@lenient
314
+ bad = [r, g, b].find { |v| !v&.between?(0, 255) }
315
+ raise ParseError, "invalid RGB component #{bad.inspect}"
227
316
  end
228
- @style = @style.merge(target => [r, g, b])
229
317
  5
230
318
  else
231
- raise ParseError, "unsupported extended-color selector #{mode.inspect}"
319
+ raise ParseError, "unsupported extended-color selector #{mode.inspect}" unless @lenient
320
+
321
+ 2
232
322
  end
233
323
  end
234
324
 
@@ -268,10 +358,14 @@ module Tuile
268
358
  # default-styled span.
269
359
  #
270
360
  # @param input [String, StyledString, nil]
361
+ # @param lenient [Boolean] when true, unmodeled SGR codes and non-SGR
362
+ # escapes are discarded instead of raising — see {StyledString}
363
+ # "## Parser". Lossy: the result no longer round-trips to `input`.
271
364
  # @return [StyledString]
272
- # @raise [ParseError] on unsupported or malformed escape sequences.
365
+ # @raise [ParseError] on unsupported or malformed escape sequences
366
+ # (strict mode only).
273
367
  # @raise [TypeError] when `input` is none of String, StyledString, nil.
274
- def parse(input)
368
+ def parse(input, lenient: false)
275
369
  case input
276
370
  when nil then EMPTY
277
371
  when StyledString then input
@@ -279,7 +373,7 @@ module Tuile
279
373
  return EMPTY if input.empty?
280
374
  return new([Span.new(text: input, style: Style::DEFAULT)]) unless input.include?("\e")
281
375
 
282
- Parser.new(input).parse
376
+ Parser.new(input, lenient:).parse
283
377
  else
284
378
  raise TypeError, "cannot parse #{input.class}"
285
379
  end
@@ -456,7 +550,7 @@ module Tuile
456
550
 
457
551
  # Returns a new {StyledString} with `bg` applied to every span, preserving
458
552
  # each span's text and other style attributes (`fg`, `bold`, `italic`,
459
- # `underline`). Useful for row-level highlights — the new bg overlays
553
+ # `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
460
554
  # without dropping foreground colors the original styling carried.
461
555
  #
462
556
  # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background
@@ -469,7 +563,7 @@ module Tuile
469
563
 
470
564
  # Returns a new {StyledString} with `fg` applied to every span, preserving
471
565
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
472
- # `underline`). The new fg overlays without dropping background colors or
566
+ # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
473
567
  # text attributes the original styling carried.
474
568
  #
475
569
  # @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground
@@ -528,6 +622,7 @@ module Tuile
528
622
  codes << (to.bold ? 1 : 22) if from.bold != to.bold
529
623
  codes << (to.italic ? 3 : 23) if from.italic != to.italic
530
624
  codes << (to.underline ? 4 : 24) if from.underline != to.underline
625
+ codes << (to.strikethrough ? 9 : 29) if from.strikethrough != to.strikethrough
531
626
  codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
532
627
  codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
533
628
  return "" if codes.empty?
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.6.0"
5
+ VERSION = "0.7.0"
6
6
  end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.3"
data/sig/tuile.rbs CHANGED
@@ -136,6 +136,13 @@ module Tuile
136
136
  # _@param_ `point`
137
137
  def contains?: (Point point) -> bool
138
138
 
139
+ # _@param_ `other` — another rectangle.
140
+ #
141
+ # _@return_ — true if `other` lies entirely within this rectangle.
142
+ # Uses the same half-open edges as {#contains?} (right/bottom exclusive).
143
+ # An {#empty? empty} `other` covers no cells, so it is trivially contained.
144
+ def contains_rect?: (Rect other) -> bool
145
+
139
146
  def size: () -> Size
140
147
 
141
148
  def top_left: () -> Point
@@ -665,6 +672,15 @@ module Tuile
665
672
  # _@param_ `window`
666
673
  def remove_popup: (Component::Popup window) -> void
667
674
 
675
+ # Invalidates the entire attached tree, forcing every component to repaint
676
+ # on the next cycle. Needed whenever something overdraws the scene without
677
+ # clipping and then exposes what was underneath — a closing popup
678
+ # ({#remove_popup}), or a popup that shrinks or moves so its new {#rect} no
679
+ # longer covers the cells it previously painted ({Component::Popup#rect=}).
680
+ # The popup-only fast path in {#repaint} can't clear those vacated cells on
681
+ # its own, so we accept the cost of a full repaint.
682
+ def needs_full_repaint: () -> void
683
+
668
684
  # Internal — use {Component::Popup#open?} instead.
669
685
  #
670
686
  # _@param_ `window`
@@ -755,10 +771,6 @@ module Tuile
755
771
  # starts. {#size} provides correct size of the terminal.
756
772
  def layout: () -> void
757
773
 
758
- # Called after a popup is closed. Since a popup can cover any window,
759
- # top-level component or other popups, we need to redraw everything.
760
- def needs_full_repaint: () -> void
761
-
762
774
  # A key has been pressed on the keyboard. Handle it, or forward to active
763
775
  # window.
764
776
  #
@@ -1141,6 +1153,12 @@ module Tuile
1141
1153
  class List < Component
1142
1154
  def initialize: () -> void
1143
1155
 
1156
+ # _@return_ — whether {#auto_scroll} is currently tailing. True
1157
+ # while the viewport sits at the last line; flips to false the moment
1158
+ # the user scrolls up, and back to true once they scroll to the bottom
1159
+ # again. Only consulted when {#auto_scroll} is enabled.
1160
+ def following?: () -> bool
1161
+
1144
1162
  # Sets new lines. Each entry is coerced into a {StyledString} (a
1145
1163
  # `String` is parsed via {StyledString.parse}, so embedded ANSI is
1146
1164
  # honored; a {StyledString} is used as-is; anything else is stringified
@@ -1309,6 +1327,10 @@ module Tuile
1309
1327
  # _@return_ — the max value of {#top_line}.
1310
1328
  def top_line_max: () -> Integer
1311
1329
 
1330
+ # _@return_ — whether the viewport is pinned to the last line.
1331
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
1332
+ def at_bottom?: () -> bool
1333
+
1312
1334
  # _@return_ — the number of visible lines.
1313
1335
  def viewport_lines: () -> Integer
1314
1336
 
@@ -1325,6 +1347,11 @@ module Tuile
1325
1347
  # which would leave `top_line` past the last item once a real rect
1326
1348
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
1327
1349
  # the snap-to-bottom intent is preserved.
1350
+ #
1351
+ # Gated on {#following?}: once the user scrolls up off the bottom the
1352
+ # cursor snap and viewport pin are both skipped, so reading older
1353
+ # content is not interrupted by incoming lines. {#top_line=} re-arms
1354
+ # `@follow` when the viewport returns to the bottom.
1328
1355
  def update_top_line_if_auto_scroll: () -> void
1329
1356
 
1330
1357
  # _@return_ — whether the scrollbar should be drawn right now.
@@ -1380,7 +1407,10 @@ module Tuile
1380
1407
  attr_accessor on_cursor_changed: Proc?
1381
1408
 
1382
1409
  # _@return_ — if true and a line is added or new content is set,
1383
- # auto-scrolls to the bottom.
1410
+ # auto-scrolls to the bottom — but only while the viewport is already
1411
+ # pinned to the last line (see {#following?}). Scroll up to read older
1412
+ # content and appends stop yanking you back down; scroll back to the
1413
+ # bottom and tailing resumes.
1384
1414
  attr_accessor auto_scroll: bool
1385
1415
 
1386
1416
  # _@return_ — top line of the viewport. 0 or positive.
@@ -1597,6 +1627,17 @@ module Tuile
1597
1627
 
1598
1628
  def focusable?: () -> bool
1599
1629
 
1630
+ # Reassigns the popup's rect, escalating to a full scene repaint when an
1631
+ # open popup shrinks or moves so its new rect no longer covers the cells
1632
+ # it previously painted. A popup overdraws the scene without clipping and
1633
+ # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
1634
+ # would repaint into the new rect and leave the vacated cells showing
1635
+ # stale content. When the new rect fully covers the old one (the popup
1636
+ # only grew), the fast path is correct and the full repaint is skipped.
1637
+ #
1638
+ # _@param_ `new_rect`
1639
+ def rect=: (Rect new_rect) -> void
1640
+
1600
1641
  # Mounts this popup on the {Screen}. Recomputes the popup's size from
1601
1642
  # the current content first, so reopening a popup whose content has
1602
1643
  # grown or shrunk while closed picks up the new size.
@@ -1652,6 +1693,11 @@ module Tuile
1652
1693
 
1653
1694
  # Recompute width/height from {#content}'s natural size and recenter
1654
1695
  # if currently open. Called whenever content is (re)assigned.
1696
+ #
1697
+ # Computes the final (centered) rect and assigns it in one step rather
1698
+ # than positioning at the origin and then centering: the intermediate
1699
+ # origin rect rarely covers the previous one, which would make
1700
+ # {#rect=}'s shrink/move detection fire a full repaint on every resize.
1655
1701
  def update_rect: () -> void
1656
1702
 
1657
1703
  # _@param_ `event`
@@ -1659,9 +1705,6 @@ module Tuile
1659
1705
 
1660
1706
  def children: () -> ::Array[Component]
1661
1707
 
1662
- # _@param_ `rect`
1663
- def rect=: (Rect rect) -> void
1664
-
1665
1708
  def on_focus: () -> void
1666
1709
  end
1667
1710
 
@@ -1985,6 +2028,12 @@ module Tuile
1985
2028
  # O(total spans).
1986
2029
  def text: () -> StyledString
1987
2030
 
2031
+ # _@return_ — whether {#auto_scroll} is currently tailing. True
2032
+ # while the viewport sits at the last line; flips to false the moment
2033
+ # the user scrolls up, and back to true once they scroll to the bottom
2034
+ # again. Only consulted when {#auto_scroll} is enabled.
2035
+ def following?: () -> bool
2036
+
1988
2037
  # Replaces the text. Embedded `\n` characters become hard line breaks.
1989
2038
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
1990
2039
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
@@ -2316,8 +2365,16 @@ module Tuile
2316
2365
  # _@param_ `target` — desired top line; clamped to `[0, top_line_max]`.
2317
2366
  def move_top_line_to: (Integer target) -> void
2318
2367
 
2368
+ # Gated on {#following?}: once the user scrolls up off the bottom the
2369
+ # viewport pin is skipped, so reading older content is not interrupted
2370
+ # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
2371
+ # returns to the bottom.
2319
2372
  def update_top_line_if_auto_scroll: () -> void
2320
2373
 
2374
+ # _@return_ — whether the viewport is pinned to the last line.
2375
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
2376
+ def at_bottom?: () -> bool
2377
+
2321
2378
  def scrollbar_visible?: () -> bool
2322
2379
 
2323
2380
  # Pads `line` with trailing default-styled spaces out to `width` display
@@ -2350,7 +2407,10 @@ module Tuile
2350
2407
  attr_accessor scrollbar_visibility: Symbol
2351
2408
 
2352
2409
  # _@return_ — if true, mutating the text scrolls the viewport so
2353
- # the last line stays in view. Default `false`.
2410
+ # the last line stays in view but only while the viewport is already
2411
+ # pinned to the last line (see {#following?}). Scroll up to read older
2412
+ # content and appends stop yanking you back down; scroll back to the
2413
+ # bottom and tailing resumes. Default `false`.
2354
2414
  attr_accessor auto_scroll: bool
2355
2415
 
2356
2416
  # _@return_ — longest hard-line's display width × number of hard
@@ -3252,7 +3312,7 @@ module Tuile
3252
3312
 
3253
3313
  # An immutable string-with-styling, modeled as a sequence of {Span}s where
3254
3314
  # each span carries a complete {Style} (`fg`, `bg`, `bold`, `italic`,
3255
- # `underline`). Spans are non-overlapping and fully tile the string — every
3315
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
3256
3316
  # character has exactly one resolved style, no overlay layers to merge.
3257
3317
  #
3258
3318
  # Where this differs from threading SGR escapes through a plain `String`:
@@ -3292,12 +3352,21 @@ module Tuile
3292
3352
  #
3293
3353
  # ## Parser
3294
3354
  #
3295
- # {.parse} is strict by design: it recognizes only the SGR codes
3355
+ # {.parse} is strict by default: it recognizes only the SGR codes
3296
3356
  # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
3297
- # underline). Anything else — unmodeled attributes (dim, blink, reverse,
3298
- # strike, conceal, double-underline, overline, ...), unknown SGR codes, or
3357
+ # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
3358
+ # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
3299
3359
  # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
3300
3360
  # round-trip parse(to_ansi(x)) == x contract honest.
3361
+ #
3362
+ # Pass `lenient: true` to instead **discard** everything the parser can't
3363
+ # model and keep going — recognized fg/bg/bold/italic/underline/strikethrough codes still
3364
+ # apply, and any unmodeled SGR code, malformed extended color, non-SGR CSI
3365
+ # (cursor moves, `\e[K`), OSC/DCS/string sequence, or stray escape is
3366
+ # silently dropped. This is the mode for piping in colored output you don't
3367
+ # control (e.g. `git --color` through a pager): "give me the colors, throw
3368
+ # the rest away." It is lossy by design — `parse(x, lenient: true)` does not
3369
+ # round-trip back to `x`.
3301
3370
  class StyledString
3302
3371
  EMPTY: StyledString
3303
3372
 
@@ -3317,7 +3386,9 @@ module Tuile
3317
3386
  # default-styled span.
3318
3387
  #
3319
3388
  # _@param_ `input`
3320
- def self.parse: ((String | StyledString)? input) -> StyledString
3389
+ #
3390
+ # _@param_ `lenient` — when true, unmodeled SGR codes and non-SGR escapes are discarded instead of raising — see {StyledString} "## Parser". Lossy: the result no longer round-trips to `input`.
3391
+ def self.parse: ((String | StyledString)? input, ?lenient: bool) -> StyledString
3321
3392
 
3322
3393
  # _@param_ `spans`
3323
3394
  def initialize: (?::Array[Span] spans) -> void
@@ -3404,7 +3475,7 @@ module Tuile
3404
3475
 
3405
3476
  # Returns a new {StyledString} with `bg` applied to every span, preserving
3406
3477
  # each span's text and other style attributes (`fg`, `bold`, `italic`,
3407
- # `underline`). Useful for row-level highlights — the new bg overlays
3478
+ # `underline`, `strikethrough`). Useful for row-level highlights — the new bg overlays
3408
3479
  # without dropping foreground colors the original styling carried.
3409
3480
  #
3410
3481
  # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
@@ -3412,7 +3483,7 @@ module Tuile
3412
3483
 
3413
3484
  # Returns a new {StyledString} with `fg` applied to every span, preserving
3414
3485
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
3415
- # `underline`). The new fg overlays without dropping background colors or
3486
+ # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
3416
3487
  # text attributes the original styling carried.
3417
3488
  #
3418
3489
  # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
@@ -3505,6 +3576,8 @@ module Tuile
3505
3576
  # @return [Boolean]
3506
3577
  # @!attribute [r] underline
3507
3578
  # @return [Boolean]
3579
+ # @!attribute [r] strikethrough
3580
+ # @return [Boolean]
3508
3581
  class Style
3509
3582
  DEFAULT: Style
3510
3583
 
@@ -3517,12 +3590,15 @@ module Tuile
3517
3590
  # _@param_ `italic`
3518
3591
  #
3519
3592
  # _@param_ `underline`
3593
+ #
3594
+ # _@param_ `strikethrough`
3520
3595
  def self.new: (
3521
3596
  ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
3522
3597
  ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
3523
3598
  ?bold: bool,
3524
3599
  ?italic: bool,
3525
- ?underline: bool
3600
+ ?underline: bool,
3601
+ ?strikethrough: bool
3526
3602
  ) -> Style
3527
3603
 
3528
3604
  def default?: () -> bool
@@ -3541,6 +3617,8 @@ module Tuile
3541
3617
  attr_reader italic: bool
3542
3618
 
3543
3619
  attr_reader underline: bool
3620
+
3621
+ attr_reader strikethrough: bool
3544
3622
  end
3545
3623
 
3546
3624
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -3566,14 +3644,18 @@ module Tuile
3566
3644
  # @api private
3567
3645
  # Hand-rolled SGR parser. State machine over a {StringScanner}: plain
3568
3646
  # text accumulates into the current span; each `\e[...m` flushes the
3569
- # current span and updates the running {Style}. Anything outside the
3570
- # supported SGR alphabet raises {ParseError}.
3647
+ # current span and updates the running {Style}. In strict mode anything
3648
+ # outside the supported SGR alphabet raises {ParseError}; in lenient mode
3649
+ # it is consumed and discarded (see {StyledString} "## Parser").
3571
3650
  class Parser
3572
3651
  STANDARD_COLORS: ::Array[Symbol]
3573
3652
  BRIGHT_COLORS: ::Array[Symbol]
3653
+ STRING_INTRODUCERS: ::Array[String]
3574
3654
 
3575
3655
  # _@param_ `input`
3576
- def initialize: (String input) -> void
3656
+ #
3657
+ # _@param_ `lenient` — when true, discard unmodeled SGR codes and non-SGR escapes instead of raising {ParseError}.
3658
+ def initialize: (String input, ?lenient: bool) -> void
3577
3659
 
3578
3660
  def parse: () -> StyledString
3579
3661
 
@@ -3581,6 +3663,27 @@ module Tuile
3581
3663
 
3582
3664
  def consume_escape: () -> void
3583
3665
 
3666
+ # Consumes a CSI sequence (`\e[` already eaten). A well-formed SGR
3667
+ # (`\e[...m` with numeric/`;` params and no intermediates) is applied;
3668
+ # anything else is a non-SGR or malformed CSI — raises in strict mode,
3669
+ # swallowed in lenient. Scans the full CSI grammar (parameter bytes
3670
+ # `\x30-\x3F`, intermediate bytes `\x20-\x2F`, final byte) so lenient
3671
+ # mode consumes the whole sequence even for private-marker forms like
3672
+ # `\e[?25l`.
3673
+ def consume_csi: () -> void
3674
+
3675
+ # Lenient-only: discards a non-CSI escape (`\e` and `intro` already
3676
+ # eaten). OSC/DCS/string sequences run to their string terminator; an
3677
+ # nF escape (`\e( B`) eats its intermediates plus one final byte; any
3678
+ # other Fe/Fp/Fs escape was complete in `intro` alone.
3679
+ #
3680
+ # _@param_ `intro` — the byte after `\e` (never `"["`).
3681
+ def consume_non_csi: (String intro) -> void
3682
+
3683
+ # Lenient-only: swallows an OSC/DCS/string-sequence payload up to and
3684
+ # including its terminator (BEL, or ST `\e\\`), or to EOS if unterminated.
3685
+ def consume_string_sequence: () -> void
3686
+
3584
3687
  # _@param_ `params_str`
3585
3688
  def apply_sgr: (String params_str) -> void
3586
3689
 
@@ -3590,7 +3693,11 @@ module Tuile
3590
3693
  #
3591
3694
  # _@param_ `target` — either `:fg` or `:bg`.
3592
3695
  #
3593
- # _@return_ — how many SGR codes were consumed (3 for 256-color, 5 for RGB).
3696
+ # _@return_ — how many SGR codes were consumed. In lenient mode a
3697
+ # malformed color is skipped rather than applied, but the same count is
3698
+ # returned (3 for 256-color, 5 for RGB) so the running index advances
3699
+ # past its operands; an unknown selector skips just `38`/`48` + the
3700
+ # selector byte (2), letting the rest be reprocessed.
3594
3701
  def consume_extended_color: (::Array[Integer] codes, Integer index, Symbol target) -> Integer
3595
3702
 
3596
3703
  def flush: () -> void
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tuile
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Vysny
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: concurrent-ruby
@@ -153,6 +154,7 @@ files:
153
154
  - lib/tuile/theme_def.rb
154
155
  - lib/tuile/version.rb
155
156
  - lib/tuile/vertical_scroll_bar.rb
157
+ - mise.toml
156
158
  - sig/tuile.rbs
157
159
  homepage: https://github.com/mvysny/tuile
158
160
  licenses:
@@ -161,6 +163,7 @@ metadata:
161
163
  homepage_uri: https://github.com/mvysny/tuile
162
164
  changelog_uri: https://github.com/mvysny/tuile/blob/master/CHANGELOG.md
163
165
  rubygems_mfa_required: 'true'
166
+ post_install_message:
164
167
  rdoc_options: []
165
168
  require_paths:
166
169
  - lib
@@ -168,14 +171,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
168
171
  requirements:
169
172
  - - ">="
170
173
  - !ruby/object:Gem::Version
171
- version: 3.4.0
174
+ version: 3.3.0
172
175
  required_rubygems_version: !ruby/object:Gem::Requirement
173
176
  requirements:
174
177
  - - ">="
175
178
  - !ruby/object:Gem::Version
176
179
  version: '0'
177
180
  requirements: []
178
- rubygems_version: 4.0.10
181
+ rubygems_version: 3.5.22
182
+ signing_key:
179
183
  specification_version: 4
180
184
  summary: A component-oriented terminal UI toolkit for Ruby.
181
185
  test_files: []