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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23a6288632a551240d224975faee1f71d477bb1465126d70915950f67c187650
4
- data.tar.gz: 33e26892d2a625e85d5d2f2fd058a04080ef12ea38825cb2f9727a346f36c6a2
3
+ metadata.gz: bf132f33d9f0dfd061a502d0b974a9906436bbc585bf363eb0ec7d97a2b223ae
4
+ data.tar.gz: 2d91fe558079a4b5c43abd3818c3bb47cd50dc4ac3372c4afe011ef33dc97045
5
5
  SHA512:
6
- metadata.gz: 233960b007d8c81281e6726e605ab48e9bdc389399b796a4d9a19314e1be2bfab019a787ddc6ab8b421221010cb9402fdc102737fef085aaa326641b76275eed
7
- data.tar.gz: 68b2425566e2a2586d686699df135a4a7fce835b0f2ca5f090204b3b82f6d3b6d7813375f9129e5256eea627be8df72e667694869d4ba475de55221c0db5ae8e
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:`.
@@ -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]