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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +1 -1
- data/examples/sampler.rb +3 -3
- data/lib/tuile/component/layout.rb +1 -1
- data/lib/tuile/component/list.rb +35 -12
- data/lib/tuile/component/log_window.rb +1 -0
- data/lib/tuile/component/picker_window.rb +4 -4
- data/lib/tuile/component/popup.rb +23 -2
- data/lib/tuile/component/text_view.rb +24 -3
- data/lib/tuile/component.rb +1 -1
- data/lib/tuile/keys.rb +2 -6
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +22 -16
- data/lib/tuile/screen_pane.rb +1 -1
- data/lib/tuile/styled_string.rb +125 -30
- data/lib/tuile/version.rb +1 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +128 -21
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ffd96aaba12d84ccc9f3417db01f75b3b91f0f89cd7358864fdfd1b0dcaa778b
|
|
4
|
+
data.tar.gz: fdbc08c48e4b908ee8b3cebc7bf949e88cf6cc2a1bb58260b53c8612bc6060cc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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?
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -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,
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
639
|
+
[candidates.select { _1 <= current }, candidates.select { _1 > current }]
|
|
626
640
|
else
|
|
627
|
-
[candidates.select {
|
|
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 {
|
|
646
|
+
[candidates.select { _1 >= current }, candidates.select { _1 < current }]
|
|
633
647
|
else
|
|
634
|
-
[candidates.select {
|
|
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)
|
|
@@ -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(
|
|
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 { "#{
|
|
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? {
|
|
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 { "#{
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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,
|
|
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?
|
data/lib/tuile/component.rb
CHANGED
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
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 <<
|
|
499
|
-
tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(
|
|
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 <<
|
|
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
|
#
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -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 {
|
|
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
|
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
|
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,
|
|
49
|
-
#
|
|
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
|
-
|
|
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}.
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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].
|
|
226
|
-
|
|
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
data/mise.toml
ADDED
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
|
|
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
|
|
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,
|
|
3298
|
-
#
|
|
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
|
-
|
|
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}.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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:
|
|
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: []
|