tuile 0.4.0 → 0.5.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 +13 -0
- data/lib/tuile/color.rb +127 -0
- data/lib/tuile/component/label.rb +33 -3
- data/lib/tuile/component/text_view.rb +693 -54
- data/lib/tuile/event_queue.rb +104 -9
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/screen.rb +2 -0
- data/lib/tuile/styled_string.rb +28 -61
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +533 -52
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf132f33d9f0dfd061a502d0b974a9906436bbc585bf363eb0ec7d97a2b223ae
|
|
4
|
+
data.tar.gz: 2d91fe558079a4b5c43abd3818c3bb47cd50dc4ac3372c4afe011ef33dc97045
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8258e9e552143470a5642559f6db98e5292e14fa2fa52c30a06e26bf505e44ff1c889da73792bc262def396997861187d02d95d4189ced1f853ab91333e606b5
|
|
7
|
+
data.tar.gz: 99c03866f8d65ec2c6ca61381078b69616a3e23b3dada3cc11a3d4e2e1fc2973e1d15ac2ac0147e0e583edd20fae247e5be85ad974cf53e245a8cc109b3fe084
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-05-21
|
|
4
|
+
|
|
5
|
+
- Add `Tuile::Color` — a value type wrapping the four color forms ANSI understands (named Symbol, 256-color Integer, RGB Array, or `nil`). Pre-defined constants `Color::RED`, `Color::BRIGHT_BLUE`, … cover the 16 named ANSI colors; `Color.coerce` accepts raw forms transparently.
|
|
6
|
+
- `Component::Label`: add `bg` accessor — applies a background color uniformly across every painted row (text, trailing pad, and blank rows past the last line). Accepts anything `Color.coerce` accepts.
|
|
7
|
+
- Add `Component::TextView::Region` — opaque handle to a contiguous run of hard lines, so apps can stream into logical sections without tracking line indices across sibling mutations. Create with `view.create_region`; mutate via `region.append`/`#<<`/`#text=`/`#add_line`/`#remove_last_n_lines`/`#replace`/`#insert`/`#remove`. Detached handles raise on every reader / mutator (except `#remove`, which is idempotent). `view.text=` / `clear` detach all region handles and install a fresh internal default.
|
|
8
|
+
- Add `Component::TextView#replace(range, str)` and `#insert(at, str)` for mid-buffer hard-line splices (Integer or Range, inclusive/exclusive end, empty range == insertion, `begin == hard-line count` valid for end-insertion).
|
|
9
|
+
- `Component::TextView`: incremental wrap via a per-hard-line row-count cache — mid-buffer mutations now re-wrap only the affected slice instead of the whole buffer. Speeds up the LLM streaming path (mid-document `region.append`, tombstone-style `region.text=`, `view.replace`/`view.insert`). `view.append` on the spatial tail keeps its existing fast path; `view.text=` and `on_width_changed` still do a full rewrap (now rebuilding the cache too).
|
|
10
|
+
- Add `EventQueue#tick(fps) { |n| ... }` returning a `Ticker` backed by `Concurrent::TimerTask`; fires on the event-loop thread with a 0-based monotonic counter. Intended for spinner animations, periodic refresh, or surfacing background-task progress. Auto-cancels on raise.
|
|
11
|
+
- Add `FakeEventQueue#tick` and `FakeTicker` — synchronous test double that drives ticks deterministically.
|
|
12
|
+
- **Breaking:** `StyledString::Style#fg` and `#bg` now return `Color` (or `nil`) instead of the raw `Symbol`/`Integer`/`Array`. `Style.new` and `#merge` continue to accept the raw forms via `Color.coerce`.
|
|
13
|
+
- **Breaking:** Remove `StyledString::Style::COLOR_SYMBOLS` — moved to `Color::COLOR_SYMBOLS`.
|
|
14
|
+
- **Breaking:** `EventQueue#run_loop` now yields submitted `Proc` events to its consumer block instead of dispatching them inline, so a raise from a `submit{}` block is routed through `Screen#on_error` like any other event. Custom `run_loop` consumers must `call` Procs in their case statement.
|
|
15
|
+
|
|
3
16
|
## [0.4.0] - 2026-05-20
|
|
4
17
|
|
|
5
18
|
- Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
|
data/lib/tuile/color.rb
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# An immutable terminal color. Accepts the three forms ANSI/SGR understands:
|
|
5
|
+
#
|
|
6
|
+
# - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
|
|
7
|
+
# (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
|
|
8
|
+
# - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
|
|
9
|
+
# - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
|
|
10
|
+
#
|
|
11
|
+
# A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
|
|
12
|
+
# …) so callers can reach for `Color::RED` instead of building one each time.
|
|
13
|
+
# {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
|
|
14
|
+
# an existing {Color} (returned as-is), so APIs that accept colors typically
|
|
15
|
+
# take `[Color, nil]` and pass through {.coerce}.
|
|
16
|
+
#
|
|
17
|
+
# ```ruby
|
|
18
|
+
# Color.new(:red) # named
|
|
19
|
+
# Color.new(42) # 256-color palette
|
|
20
|
+
# Color.new([255, 100, 0]) # RGB
|
|
21
|
+
# Color::RED # constant
|
|
22
|
+
# Color.coerce(:red) # accepts raw forms, returns Color
|
|
23
|
+
# Color.coerce(nil) # nil → nil
|
|
24
|
+
# ```
|
|
25
|
+
#
|
|
26
|
+
# {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
|
|
27
|
+
# raw numeric codes so callers (notably {StyledString}) can combine them with
|
|
28
|
+
# other SGR attributes in a single sequence.
|
|
29
|
+
class Color
|
|
30
|
+
# Symbolic color names. Order is significant: indices 0..7 map to the
|
|
31
|
+
# standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to
|
|
32
|
+
# bright variants (SGR 90..97 / 100..107).
|
|
33
|
+
# @return [Array<Symbol>]
|
|
34
|
+
COLOR_SYMBOLS = %i[
|
|
35
|
+
black red green yellow blue magenta cyan white
|
|
36
|
+
bright_black bright_red bright_green bright_yellow
|
|
37
|
+
bright_blue bright_magenta bright_cyan bright_white
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Coerces the input to a {Color}. `nil` passes through unchanged (callers
|
|
41
|
+
# use `nil` for the terminal default); an existing {Color} is returned
|
|
42
|
+
# as-is; otherwise the value is fed to {.new}.
|
|
43
|
+
#
|
|
44
|
+
# @param value [Color, Symbol, Integer, Array<Integer>, nil]
|
|
45
|
+
# @return [Color, nil]
|
|
46
|
+
# @raise [ArgumentError] when `value` is not one of the accepted forms.
|
|
47
|
+
def self.coerce(value)
|
|
48
|
+
case value
|
|
49
|
+
when nil, Color then value
|
|
50
|
+
else new(value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param value [Symbol, Integer, Array<Integer>] see class-level docs for
|
|
55
|
+
# the three accepted forms.
|
|
56
|
+
# @raise [ArgumentError] when `value` is not one of the accepted forms.
|
|
57
|
+
def initialize(value)
|
|
58
|
+
unless COLOR_SYMBOLS.include?(value) ||
|
|
59
|
+
(value.is_a?(Integer) && value.between?(0, 255)) ||
|
|
60
|
+
(value.is_a?(Array) && value.length == 3 &&
|
|
61
|
+
value.all? { |v| v.is_a?(Integer) && v.between?(0, 255) })
|
|
62
|
+
raise ArgumentError, "invalid color: #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@value = value.is_a?(Array) ? value.dup.freeze : value
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The underlying raw representation — a Symbol, Integer, or frozen
|
|
70
|
+
# Array<Integer>.
|
|
71
|
+
# @return [Symbol, Integer, Array<Integer>]
|
|
72
|
+
attr_reader :value
|
|
73
|
+
|
|
74
|
+
# SGR parameter codes for emitting this color as either a foreground
|
|
75
|
+
# (`target: :fg`) or background (`target: :bg`). Returned as an array so
|
|
76
|
+
# callers can splice them into a multi-attribute SGR (e.g. bold + color).
|
|
77
|
+
#
|
|
78
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
79
|
+
# @return [Array<Integer>]
|
|
80
|
+
# @raise [ArgumentError] when `target` is neither `:fg` nor `:bg`.
|
|
81
|
+
def sgr_codes(target = :fg)
|
|
82
|
+
base, ext = case target
|
|
83
|
+
when :fg then [30, 38]
|
|
84
|
+
when :bg then [40, 48]
|
|
85
|
+
else raise ArgumentError, "target must be :fg or :bg, got #{target.inspect}"
|
|
86
|
+
end
|
|
87
|
+
case @value
|
|
88
|
+
when Symbol
|
|
89
|
+
idx = COLOR_SYMBOLS.index(@value)
|
|
90
|
+
idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
|
|
91
|
+
when Integer then [ext, 5, @value]
|
|
92
|
+
when Array then [ext, 2, *@value]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
|
|
97
|
+
# `print`-style direct emission; for composing with other attributes use
|
|
98
|
+
# {#sgr_codes} instead.
|
|
99
|
+
#
|
|
100
|
+
# @param target [Symbol] `:fg` or `:bg`.
|
|
101
|
+
# @return [String]
|
|
102
|
+
def to_ansi(target = :fg)
|
|
103
|
+
"\e[#{sgr_codes(target).join(";")}m"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param other [Object]
|
|
107
|
+
# @return [Boolean]
|
|
108
|
+
def ==(other)
|
|
109
|
+
other.is_a?(Color) && @value == other.value
|
|
110
|
+
end
|
|
111
|
+
alias eql? ==
|
|
112
|
+
|
|
113
|
+
# @return [Integer]
|
|
114
|
+
def hash
|
|
115
|
+
[self.class, @value].hash
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [String]
|
|
119
|
+
def inspect
|
|
120
|
+
"#<#{self.class.name} #{@value.inspect}>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
COLOR_SYMBOLS.each do |sym|
|
|
124
|
+
const_set(sym.upcase, new(sym))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -11,6 +11,7 @@ module Tuile
|
|
|
11
11
|
def initialize
|
|
12
12
|
super
|
|
13
13
|
@text = StyledString::EMPTY
|
|
14
|
+
@bg = nil
|
|
14
15
|
@clipped_lines = []
|
|
15
16
|
@blank_line = ""
|
|
16
17
|
end
|
|
@@ -19,6 +20,11 @@ module Tuile
|
|
|
19
20
|
# {StyledString}.
|
|
20
21
|
attr_reader :text
|
|
21
22
|
|
|
23
|
+
# @return [Color, nil] background color applied uniformly across every
|
|
24
|
+
# painted row (including padding past the text). `nil` (default)
|
|
25
|
+
# leaves whatever bg the text's own styling carries.
|
|
26
|
+
attr_reader :bg
|
|
27
|
+
|
|
22
28
|
# Replaces the text. A `String` is parsed via {StyledString.parse}
|
|
23
29
|
# (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
|
|
24
30
|
# coerced to an empty {StyledString}. Lines wider than {#rect} are
|
|
@@ -35,6 +41,23 @@ module Tuile
|
|
|
35
41
|
invalidate
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# Sets the background color. Coerced via {Color.coerce}, so a Symbol,
|
|
45
|
+
# Integer, Array, {Color}, or `nil` all work. `nil` clears the override
|
|
46
|
+
# — the label paints with whatever bg the text's own styling provides.
|
|
47
|
+
# Otherwise the bg overlays every span (including the trailing pad and
|
|
48
|
+
# blank rows past the last text line).
|
|
49
|
+
#
|
|
50
|
+
# @param value [Color, Symbol, Integer, Array<Integer>, nil]
|
|
51
|
+
# @return [void]
|
|
52
|
+
def bg=(value)
|
|
53
|
+
new_bg = Color.coerce(value)
|
|
54
|
+
return if @bg == new_bg
|
|
55
|
+
|
|
56
|
+
@bg = new_bg
|
|
57
|
+
update_clipped_lines
|
|
58
|
+
invalidate
|
|
59
|
+
end
|
|
60
|
+
|
|
38
61
|
# @return [Size] longest hard-line's display width × number of hard
|
|
39
62
|
# lines. Reported on the *unclipped* text — sizing is intrinsic to
|
|
40
63
|
# the content, not the viewport. Empty text returns `Size.new(0, 0)`.
|
|
@@ -79,12 +102,19 @@ module Tuile
|
|
|
79
102
|
# Each line is ellipsized to fit, padded with trailing spaces out to
|
|
80
103
|
# the full width, and pre-rendered to ANSI so {#repaint} is just a
|
|
81
104
|
# lookup + screen.print per row. {@blank_line} covers rows past the
|
|
82
|
-
# last text line.
|
|
105
|
+
# last text line. When {#bg} is set, every produced line (and the
|
|
106
|
+
# blank row) has the bg applied uniformly.
|
|
83
107
|
# @return [void]
|
|
84
108
|
def update_clipped_lines
|
|
85
109
|
width = rect.width.clamp(0, nil)
|
|
86
|
-
@blank_line = " " * width
|
|
87
|
-
@clipped_lines = @text.lines.map { |line| pad_to(line.ellipsize(width), width).to_ansi }
|
|
110
|
+
@blank_line = apply_bg(StyledString.plain(" " * width)).to_ansi
|
|
111
|
+
@clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)).to_ansi }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param line [StyledString]
|
|
115
|
+
# @return [StyledString]
|
|
116
|
+
def apply_bg(line)
|
|
117
|
+
@bg ? line.with_bg(@bg) : line
|
|
88
118
|
end
|
|
89
119
|
|
|
90
120
|
# @param line [StyledString]
|