tuile 0.4.0 → 0.6.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: fdf4dfc626b692fdeeb9dabbcd08fe94a74045ae24340b24074d601856b87bf8
4
+ data.tar.gz: a38a8d8f04c53341a1bf6adfbf551209fc8e917f6957076bcf291bb4c2f7837f
5
5
  SHA512:
6
- metadata.gz: 233960b007d8c81281e6726e605ab48e9bdc389399b796a4d9a19314e1be2bfab019a787ddc6ab8b421221010cb9402fdc102737fef085aaa326641b76275eed
7
- data.tar.gz: 68b2425566e2a2586d686699df135a4a7fce835b0f2ca5f090204b3b82f6d3b6d7813375f9129e5256eea627be8df72e667694869d4ba475de55221c0db5ae8e
6
+ metadata.gz: ee942fd7bd9c9c35212f20ae78d043df5c2fe5c9dfb3e3ca5b1e3acd98a1dfd9d7708c5a8d481f49b690c50c56b8d9f81f771adb668937ae0b45e5244f84fd71
7
+ data.tar.gz: 2735212649ff50b35814125ce0d91043f91bba7e4a8c4aa4a35b730708f66473536e2c240c70f45197bd89ef36154694119c8dfa0c70527f28e8c6520419dcd5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-06-07
4
+
5
+ - 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.
6
+ - Auto-detect the light/dark terminal background at startup: `Screen.new` queries the terminal via OSC 11 (`COLORFGBG` fallback, dark when inconclusive) and picks `Theme::LIGHT`/`Theme::DARK` to match.
7
+ - Follow OS light/dark appearance flips live via mode 2031 (kitty, foot, contour, ghostty, …): the screen re-picks the matching theme and repaints everything.
8
+ - Add app-specific theme tokens: `Theme#custom` (`Hash{Symbol => Color}`), looked up fail-fast via `Theme#[]` (`KeyError` on typos) and rendered via the generic `#fg`/`#bg` helpers. Subclass `Theme` to add one semantic coloring function per custom token — `Data#with` preserves the subclass. Theme tokens are strictly `Color` instances; `Color` gains the `Color.palette`/`Color.rgb` named constructors.
9
+ - Add `Tuile::ThemeDef` — an app's dark/light `Theme` pair. Assigning `Screen#theme_def=` is the durable way to theme an app: the screen picks the member matching the detected background at startup and on every appearance flip, where a bare `theme=` assignment is transient. Construction validates that both members declare the same custom key set.
10
+ - Add `ThemeDef.default` — the definition newly-constructed screens start from (initially `ThemeDef::DEFAULT`). Reassign it once in `spec_helper.rb` and every `Screen.fake` carries the app's custom tokens, instead of repeating `theme_def=` in each `before` block.
11
+ - Name the 256-color palette: a constant per standard xterm chart name for palette indices 16..255 (`Color::CADET_BLUE` is `Color.palette(72)`; `Color::DODGER_BLUE1`, `Color::GREY37`, …) — exact palette cells, no quantization, listed in `Color::PALETTE_NAMES`. Where the chart names several cells identically, the first cell wins the constant; indices 0..15 keep the symbolic `Color::RED`/`Color::BRIGHT_BLUE`/… constants, which respect the terminal's own scheme.
12
+ - Add `Color.hex` — a 24-bit RGB color from a CSS-style hex string (`Color.hex("#333333") == Color.rgb(51, 51, 51)`; leading `#` optional, case-insensitive, 3-digit shorthand expands as in CSS). Alpha forms (`#rgba`/`#rrggbbaa`) are rejected — SGR has no alpha channel. `Color.coerce` stays string-free; `.hex` is the explicit entry point.
13
+ - Add `Component#on_theme_changed` — fired pre-order across the attached tree on every theme change, so apps can rebuild styled content whose colors were derived from the old theme. Override it (calling `super`) or assign the `on_theme_changed=` proc.
14
+ - Add `Tuile::Sizing` (`FILL` / `WRAP_CONTENT` / `Sizing.fixed(n)`) and `Window#footer_sizing` — the footer slot is sized per policy against the inner width; a `WRAP_CONTENT` footer re-lays-out live as its content changes. The footer is excluded from `Window#content_size`: it is decoration overlaying the border and must not drive window size.
15
+ - `Component#content_size` is now maintained eagerly: content mutators assign via the protected `content_size=` setter, which fires `parent.on_child_content_size_changed(self)` only when the value actually changed. Fixes a `Popup` staleness — an open popup now re-sizes and recenters when its content grows.
16
+ - **Breaking:** `rainbow` is no longer a runtime dependency (nothing under `lib/` uses it — `Theme`/`StyledString`/`Color` produce all SGR output). Apps that style text with Rainbow must add it to their own Gemfile.
17
+
18
+ ## [0.5.0] - 2026-05-21
19
+
20
+ - 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.
21
+ - `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.
22
+ - 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.
23
+ - 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).
24
+ - `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).
25
+ - 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.
26
+ - Add `FakeEventQueue#tick` and `FakeTicker` — synchronous test double that drives ticks deterministically.
27
+ - **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`.
28
+ - **Breaking:** Remove `StyledString::Style::COLOR_SYMBOLS` — moved to `Color::COLOR_SYMBOLS`.
29
+ - **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.
30
+
3
31
  ## [0.4.0] - 2026-05-20
4
32
 
5
33
  - Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
data/README.md CHANGED
@@ -169,7 +169,7 @@ mechanism that handles it wins:
169
169
  ```ruby
170
170
  screen.register_global_shortcut(Tuile::Keys::CTRL_L,
171
171
  over_popups: true,
172
- hint: "^L #{Rainbow('log').cadetblue}") do
172
+ hint: "^L #{screen.theme.hint('log')}") do
173
173
  log_popup.open
174
174
  end
175
175
  screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
@@ -230,11 +230,155 @@ replaces it, prefixed with `q Close`:
230
230
  ```ruby
231
231
  class FilterWindow < Tuile::Component::Window
232
232
  def keyboard_hint
233
- "f #{Rainbow('filter').cadetblue} Enter #{Rainbow('open').cadetblue}"
233
+ "f #{screen.theme.hint('filter')} Enter #{screen.theme.hint('open')}"
234
234
  end
235
235
  end
236
236
  ```
237
237
 
238
+ ### Theming
239
+
240
+ The accent colors built-in components paint with — the list-cursor /
241
+ focused-input highlight, the inactive input "well", the active window
242
+ border, the status-bar hint color — come from a `Tuile::Theme`, a frozen
243
+ value type of semantic color tokens. The current theme lives at
244
+ `screen.theme`.
245
+
246
+ The theme is picked automatically when the screen is constructed:
247
+ `Screen.new` queries the terminal's background color (OSC 11, with a
248
+ `COLORFGBG` fallback) and selects `Theme::LIGHT` on light backgrounds,
249
+ `Theme::DARK` (the colors Tuile has always used) otherwise. While the
250
+ event loop runs, terminals supporting mode 2031 (kitty, foot, contour,
251
+ ghostty, …) push appearance changes, and the screen follows OS
252
+ light/dark flips live, repainting everything in the matching theme.
253
+ Override it any time:
254
+
255
+ ```ruby
256
+ screen.theme = Tuile::Theme::LIGHT
257
+ # or tweak a single token (tokens are strict: `Color` instances only):
258
+ screen.theme = Tuile::Theme::DARK.with(active_border_color: Tuile::Color::CYAN)
259
+ ```
260
+
261
+ Note a bare `theme=` assignment is transient: the next OS appearance flip
262
+ re-picks from the screen's `ThemeDef` and replaces it. To theme an app
263
+ durably, see [App themes](#app-themes) below.
264
+
265
+ The theme's primary API is its rendering helpers — `active_bg(text)`,
266
+ `active_border(text)`, `input_bg(text)`, `hint(text)` — which return the
267
+ text wrapped in the token's color:
268
+
269
+ ```ruby
270
+ screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
271
+ screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
272
+ ```
273
+
274
+ The raw colors are also readable via the `*_color` counterparts
275
+ (`active_bg_color`, …) for span-aware styling with `StyledString`.
276
+
277
+ Assigning a theme invalidates every component, so the whole UI restyles on
278
+ the next repaint. One caveat: strings with colors already baked in (global
279
+ shortcut `hint:`s, `Theme#hint` output you cached) don't restyle —
280
+ rebuild them in `Component#on_theme_changed` (see
281
+ [Reacting to theme changes](#reacting-to-theme-changes)).
282
+
283
+ Everything that isn't an accent deliberately inherits the terminal's own
284
+ default foreground/background, which already matches the user's terminal
285
+ theme — so there is no global `bg`/`fg` token to configure.
286
+
287
+ ### App themes
288
+
289
+ Your app's own colors belong in the theme too, so they restyle in the same
290
+ invalidate-everything pass and stay legible on both terminal backgrounds.
291
+ Beyond the built-in tokens, a theme carries app-specific tokens in
292
+ `custom` — a `Hash{Symbol => Color}`. Look them up with `theme[:token]`
293
+ (fail-fast: a typo raises `KeyError` instead of quietly painting a
294
+ default) and render with the generic `fg` / `bg` helpers:
295
+
296
+ ```ruby
297
+ theme = Tuile::Theme::DARK.with(custom: { accent: Tuile::Color::DARK_ORANGE })
298
+ theme[:accent] # => Color — e.g. for StyledString#with_fg
299
+ theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
300
+ ```
301
+
302
+ `Color::DARK_ORANGE` is `Color.palette(208)` — the 256-color palette
303
+ carries a constant per standard xterm chart name (`CADET_BLUE`,
304
+ `DODGER_BLUE1`, `GREY37`, …; see `Color::PALETTE_NAMES`), so a theme
305
+ declaration can say which color it means instead of citing a bare index.
306
+
307
+ The recommended shape is a `Theme` subclass that implements one coloring
308
+ function per custom token, mirroring the built-in helpers (`hint`,
309
+ `active_bg`, …) — call sites then read `theme.added("+42")` instead of
310
+ `theme.fg(:added, "+42")`. `Data#with` preserves the subclass, so an
311
+ `AppTheme` stays an `AppTheme` through `with`:
312
+
313
+ ```ruby
314
+ class AppTheme < Tuile::Theme
315
+ # one coloring function per custom token
316
+ def added(text) = fg(:added, text)
317
+ def removed(text) = fg(:removed, text)
318
+ end
319
+ ```
320
+
321
+ Build both appearance variants and pair them in a `Tuile::ThemeDef`
322
+ assigned to `screen.theme_def=`. This is the durable way to theme an app:
323
+ the screen picks the member matching the detected background at startup
324
+ and re-picks on every OS appearance flip, so your definition survives
325
+ light/dark toggles where a bare `theme=` assignment would be replaced.
326
+ `ThemeDef.new` enforces that both members declare the same custom key
327
+ set — a token present in only one variant fails at construction instead
328
+ of raising `KeyError` at the unpredictable moment the user flips
329
+ appearance:
330
+
331
+ ```ruby
332
+ APP_THEME = Tuile::ThemeDef.new(
333
+ dark: AppTheme.new(**Tuile::Theme::DARK.to_h,
334
+ custom: { added: Tuile::Color::DARK_SEA_GREEN,
335
+ removed: Tuile::Color::LIGHT_PINK3 }),
336
+ light: AppTheme.new(**Tuile::Theme::LIGHT.to_h,
337
+ custom: { added: Tuile::Color::SPRING_GREEN4,
338
+ removed: Tuile::Color::INDIAN_RED })
339
+ )
340
+ screen.theme_def = APP_THEME
341
+ ```
342
+
343
+ In tests, a fresh `Screen.fake` per example starts from the built-in
344
+ definition, so a component reading `theme[:added]` would `KeyError`.
345
+ Instead of repeating `Screen.instance.theme_def = APP_THEME` in every
346
+ `before` block, point the construction-time default at your definition
347
+ once, in `spec_helper.rb`:
348
+
349
+ ```ruby
350
+ Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
351
+ ```
352
+
353
+ ### Reacting to theme changes
354
+
355
+ Built-in components read `screen.theme` at paint time, so their accents
356
+ restyle automatically. Content you rendered yourself does not: a
357
+ `StyledString` stored in `Label#text` / `List#lines` / `TextView#text`
358
+ has its colors baked in at construction, and only your app knows which of
359
+ those were theme-derived (as opposed to inherent to the data — log-level
360
+ colors, say). `Component#on_theme_changed` fires on every attached
361
+ component when the theme changes (assignment or appearance flip); rebuild
362
+ theme-derived content there by re-running the code that rendered it
363
+ initially. Consume it either way:
364
+
365
+ ```ruby
366
+ # composition style — assembling stock components:
367
+ label.on_theme_changed = -> { label.text = render_status_line }
368
+
369
+ # subclass style — call `super` so an assigned listener keeps firing:
370
+ class DiffView < Tuile::Component::TextView
371
+ def on_theme_changed
372
+ super
373
+ self.text = render_diff # screen.theme already returns the new theme
374
+ end
375
+ end
376
+ ```
377
+
378
+ The hook runs on the UI thread and repaint coalesces per tick, so
379
+ mutating content inside it is safe. Don't assign `screen.theme=` from
380
+ inside the hook.
381
+
238
382
  ## Components
239
383
 
240
384
  All components live under `Tuile::Component::*`. Each one is documented below
@@ -244,11 +388,13 @@ YARD output (`bundle exec rake yard`).
244
388
  ### `Component::Label`
245
389
 
246
390
  Static text. No word-wrapping; long lines are clipped to `rect.width`. Lines
247
- may contain Rainbow ANSI formatting.
391
+ may contain ANSI SGR formatting — theme helper output, a `StyledString`,
392
+ or any SGR-emitting library (e.g. Rainbow, which is no longer a Tuile
393
+ dependency — add it to your own Gemfile if you use it).
248
394
 
249
395
  ```ruby
250
396
  label = Tuile::Component::Label.new
251
- label.text = "Hello, #{Rainbow('world').green}!"
397
+ label.text = "Hello, #{screen.theme.hint('world')}!"
252
398
  ```
253
399
 
254
400
  Key API: `text=`, `content_size`.
@@ -14,6 +14,7 @@
14
14
  #
15
15
  # Press q or ESC to exit.
16
16
 
17
+ require "rainbow"
17
18
  require "tuile"
18
19
 
19
20
  module FileCommanderExample
@@ -111,9 +112,9 @@ module FileCommanderExample
111
112
  # land in one place.
112
113
  class PaneWindow < Tuile::Component::Window
113
114
  def keyboard_hint
114
- "Tab #{Rainbow("Switch").cadetblue} " \
115
- "Enter #{Rainbow("Open").cadetblue} " \
116
- "Bksp #{Rainbow("Up").cadetblue}"
115
+ "Tab #{screen.theme.hint("Switch")} " \
116
+ "Enter #{screen.theme.hint("Open")} " \
117
+ "Bksp #{screen.theme.hint("Up")}"
117
118
  end
118
119
  end
119
120
 
data/examples/sampler.rb CHANGED
@@ -11,6 +11,7 @@
11
11
  #
12
12
  # Keys (global): q or ESC to quit.
13
13
 
14
+ require "rainbow"
14
15
  require "tuile"
15
16
 
16
17
  module SamplerExample
data/lib/tuile/ansi.rb CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Tuile
4
4
  # ANSI escape sequence constants. Tuile emits colors and text attributes
5
- # via Rainbow, which produces **SGR** sequences ("Select Graphic
6
- # Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m` bold,
7
- # `\e[0m` reset).
5
+ # via {StyledString} / {Color}, which produce **SGR** sequences ("Select
6
+ # Graphic Rendition", `ESC [ <params> m` — e.g. `\e[31m` red, `\e[1m`
7
+ # bold, `\e[0m` reset). Host apps may also use Rainbow, which emits the
8
+ # same form.
8
9
  module Ansi
9
10
  # SGR reset (`ESC [ 0 m`). Restores the terminal's default foreground,
10
11
  # background, and text attributes.
@@ -0,0 +1,249 @@
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
+ # The 256-color palette gets the same treatment via {PALETTE_NAMES}:
14
+ # `Color::CADET_BLUE`, `Color::DODGER_BLUE1`, `Color::GREY37`, … — the
15
+ # standard xterm chart names for indices 16..255, each an exact palette cell.
16
+ # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
17
+ # an existing {Color} (returned as-is), so APIs that accept colors typically
18
+ # take `[Color, nil]` and pass through {.coerce}.
19
+ #
20
+ # ```ruby
21
+ # Color.new(:red) # named
22
+ # Color.new(42) # 256-color palette
23
+ # Color.new([255, 100, 0]) # RGB
24
+ # Color::RED # constant
25
+ # Color.palette(42) # 256-color palette, explicit
26
+ # Color.rgb(255, 100, 0) # 24-bit RGB, explicit
27
+ # Color.hex("#ff6400") # 24-bit RGB from a CSS-style hex string
28
+ # Color.coerce(:red) # accepts raw forms, returns Color
29
+ # Color.coerce(nil) # nil → nil
30
+ # ```
31
+ #
32
+ # Which entry point to use is a deliberate policy split. High-traffic
33
+ # call sites ({StyledString} and friends) stay lenient and {.coerce} raw
34
+ # forms — you don't want factory ceremony on every styled span.
35
+ # Declaration sites ({Theme}, defined once per app) are strict and take
36
+ # only {Color} instances, where `Color.palette(130)` documents itself in
37
+ # a way the bare `130` (palette index? RGB channel?) does not.
38
+ #
39
+ # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
40
+ # raw numeric codes so callers (notably {StyledString}) can combine them with
41
+ # other SGR attributes in a single sequence.
42
+ class Color
43
+ # Symbolic color names. Order is significant: indices 0..7 map to the
44
+ # standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to
45
+ # bright variants (SGR 90..97 / 100..107).
46
+ # @return [Array<Symbol>]
47
+ COLOR_SYMBOLS = %i[
48
+ black red green yellow blue magenta cyan white
49
+ bright_black bright_red bright_green bright_yellow
50
+ bright_blue bright_magenta bright_cyan bright_white
51
+ ].freeze
52
+
53
+ # Coerces the input to a {Color}. `nil` passes through unchanged (callers
54
+ # use `nil` for the terminal default); an existing {Color} is returned
55
+ # as-is; otherwise the value is fed to {.new}.
56
+ #
57
+ # @param value [Color, Symbol, Integer, Array<Integer>, nil]
58
+ # @return [Color, nil]
59
+ # @raise [ArgumentError] when `value` is not one of the accepted forms.
60
+ def self.coerce(value)
61
+ case value
62
+ when nil, Color then value
63
+ else new(value)
64
+ end
65
+ end
66
+
67
+ # A color from the 256-color palette (SGR 38;5;N / 48;5;N). Same as
68
+ # `Color.new(index)`, but the name says what the bare integer is.
69
+ #
70
+ # @param index [Integer] palette index, 0..255.
71
+ # @return [Color]
72
+ # @raise [ArgumentError] when `index` is not an Integer in 0..255.
73
+ def self.palette(index)
74
+ raise ArgumentError, "invalid palette index: #{index.inspect}" unless index.is_a?(Integer)
75
+
76
+ new(index)
77
+ end
78
+
79
+ # A 24-bit RGB color (SGR 38;2;R;G;B / 48;2;R;G;B). Same as
80
+ # `Color.new([r, g, b])`, but with the channels spelled out.
81
+ #
82
+ # @param red [Integer] 0..255.
83
+ # @param green [Integer] 0..255.
84
+ # @param blue [Integer] 0..255.
85
+ # @return [Color]
86
+ # @raise [ArgumentError] when a channel is not an Integer in 0..255.
87
+ def self.rgb(red, green, blue)
88
+ new([red, green, blue])
89
+ end
90
+
91
+ # A 24-bit RGB color from a CSS-style hex string — for when the value
92
+ # comes from a hex source (a designer's palette, a CSS variable). The
93
+ # leading `#` is optional, digits are case-insensitive, and the CSS
94
+ # 3-digit shorthand expands as in CSS (`"#345"` → `"#334455"`).
95
+ # 4/8-digit alpha forms are rejected: SGR has no alpha channel, and
96
+ # silently dropping it would lie about the rendered color.
97
+ #
98
+ # @param string [String] e.g. `"#333333"`, `"5F9EA0"`, `"#333"`.
99
+ # @return [Color] same value form as {.rgb} — `Color.hex("#333") ==
100
+ # Color.rgb(51, 51, 51)`.
101
+ # @raise [ArgumentError] when `string` is not 3 or 6 hex digits with
102
+ # an optional leading `#`.
103
+ def self.hex(string)
104
+ digits = string.delete_prefix("#") if string.is_a?(String)
105
+ raise ArgumentError, "invalid hex color: #{string.inspect}" unless digits&.match?(/\A(\h{3}|\h{6})\z/)
106
+
107
+ digits = digits.gsub(/\h/) { |d| d * 2 } if digits.length == 3
108
+ new(digits.scan(/\h{2}/).map { |channel| channel.to_i(16) })
109
+ end
110
+
111
+ # @param value [Symbol, Integer, Array<Integer>] see class-level docs for
112
+ # the three accepted forms.
113
+ # @raise [ArgumentError] when `value` is not one of the accepted forms.
114
+ def initialize(value)
115
+ unless COLOR_SYMBOLS.include?(value) ||
116
+ (value.is_a?(Integer) && value.between?(0, 255)) ||
117
+ (value.is_a?(Array) && value.length == 3 &&
118
+ value.all? { |v| v.is_a?(Integer) && v.between?(0, 255) })
119
+ raise ArgumentError, "invalid color: #{value.inspect}"
120
+ end
121
+
122
+ @value = value.is_a?(Array) ? value.dup.freeze : value
123
+ freeze
124
+ end
125
+
126
+ # The underlying raw representation — a Symbol, Integer, or frozen
127
+ # Array<Integer>.
128
+ # @return [Symbol, Integer, Array<Integer>]
129
+ attr_reader :value
130
+
131
+ # SGR parameter codes for emitting this color as either a foreground
132
+ # (`target: :fg`) or background (`target: :bg`). Returned as an array so
133
+ # callers can splice them into a multi-attribute SGR (e.g. bold + color).
134
+ #
135
+ # @param target [Symbol] `:fg` or `:bg`.
136
+ # @return [Array<Integer>]
137
+ # @raise [ArgumentError] when `target` is neither `:fg` nor `:bg`.
138
+ def sgr_codes(target = :fg)
139
+ base, ext = case target
140
+ when :fg then [30, 38]
141
+ when :bg then [40, 48]
142
+ else raise ArgumentError, "target must be :fg or :bg, got #{target.inspect}"
143
+ end
144
+ case @value
145
+ when Symbol
146
+ idx = COLOR_SYMBOLS.index(@value)
147
+ idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
148
+ when Integer then [ext, 5, @value]
149
+ when Array then [ext, 2, *@value]
150
+ end
151
+ end
152
+
153
+ # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
154
+ # `print`-style direct emission; for composing with other attributes use
155
+ # {#sgr_codes} instead.
156
+ #
157
+ # @param target [Symbol] `:fg` or `:bg`.
158
+ # @return [String]
159
+ def to_ansi(target = :fg)
160
+ "\e[#{sgr_codes(target).join(";")}m"
161
+ end
162
+
163
+ # @param other [Object]
164
+ # @return [Boolean]
165
+ def ==(other)
166
+ other.is_a?(Color) && @value == other.value
167
+ end
168
+ alias eql? ==
169
+
170
+ # @return [Integer]
171
+ def hash
172
+ [self.class, @value].hash
173
+ end
174
+
175
+ # @return [String]
176
+ def inspect
177
+ "#<#{self.class.name} #{@value.inspect}>"
178
+ end
179
+
180
+ COLOR_SYMBOLS.each do |sym|
181
+ const_set(sym.upcase, new(sym))
182
+ end
183
+
184
+ # Names for the 256-color palette indices 16..255, from the standard
185
+ # xterm chart (<https://www.ditig.com/256-colors-cheat-sheet>). A constant
186
+ # per entry is pre-defined, an exact palette cell — no quantization:
187
+ # `Color::CADET_BLUE == Color.palette(72)`. The chart names some cells
188
+ # identically (`DeepSkyBlue4` covers 23, 24 *and* 25); the first
189
+ # occurrence wins the constant and the remaining cells stay reachable via
190
+ # {.palette}. Indices 0..15 are covered by the {COLOR_SYMBOLS} constants
191
+ # instead — the symbolic SGR form respects the user's terminal scheme,
192
+ # which a hard palette cell would not.
193
+ # @return [Hash{Symbol => Integer}]
194
+ PALETTE_NAMES = {
195
+ GREY0: 16, NAVY_BLUE: 17, DARK_BLUE: 18, BLUE3: 19, BLUE1: 21,
196
+ DARK_GREEN: 22, DEEP_SKY_BLUE4: 23, DODGER_BLUE3: 26, DODGER_BLUE2: 27,
197
+ GREEN4: 28, SPRING_GREEN4: 29, TURQUOISE4: 30, DEEP_SKY_BLUE3: 31,
198
+ DODGER_BLUE1: 33, GREEN3: 34, SPRING_GREEN3: 35, DARK_CYAN: 36,
199
+ LIGHT_SEA_GREEN: 37, DEEP_SKY_BLUE2: 38, DEEP_SKY_BLUE1: 39,
200
+ SPRING_GREEN2: 42, CYAN3: 43, DARK_TURQUOISE: 44, TURQUOISE2: 45,
201
+ GREEN1: 46, SPRING_GREEN1: 48, MEDIUM_SPRING_GREEN: 49, CYAN2: 50,
202
+ CYAN1: 51, DARK_RED: 52, DEEP_PINK4: 53, PURPLE4: 54, PURPLE3: 56,
203
+ BLUE_VIOLET: 57, ORANGE4: 58, GREY37: 59, MEDIUM_PURPLE4: 60,
204
+ SLATE_BLUE3: 61, ROYAL_BLUE1: 63, CHARTREUSE4: 64, DARK_SEA_GREEN4: 65,
205
+ PALE_TURQUOISE4: 66, STEEL_BLUE: 67, STEEL_BLUE3: 68,
206
+ CORNFLOWER_BLUE: 69, CHARTREUSE3: 70, CADET_BLUE: 72, SKY_BLUE3: 74,
207
+ STEEL_BLUE1: 75, PALE_GREEN3: 77, SEA_GREEN3: 78, AQUAMARINE3: 79,
208
+ MEDIUM_TURQUOISE: 80, CHARTREUSE2: 82, SEA_GREEN2: 83, SEA_GREEN1: 84,
209
+ AQUAMARINE1: 86, DARK_SLATE_GRAY2: 87, DARK_MAGENTA: 90, DARK_VIOLET: 92,
210
+ PURPLE: 93, LIGHT_PINK4: 95, PLUM4: 96, MEDIUM_PURPLE3: 97,
211
+ SLATE_BLUE1: 99, YELLOW4: 100, WHEAT4: 101, GREY53: 102,
212
+ LIGHT_SLATE_GREY: 103, MEDIUM_PURPLE: 104, LIGHT_SLATE_BLUE: 105,
213
+ DARK_OLIVE_GREEN3: 107, DARK_SEA_GREEN: 108, LIGHT_SKY_BLUE3: 109,
214
+ SKY_BLUE2: 111, DARK_SEA_GREEN3: 115, DARK_SLATE_GRAY3: 116,
215
+ SKY_BLUE1: 117, CHARTREUSE1: 118, LIGHT_GREEN: 119, PALE_GREEN1: 121,
216
+ DARK_SLATE_GRAY1: 123, RED3: 124, MEDIUM_VIOLET_RED: 126, MAGENTA3: 127,
217
+ DARK_ORANGE3: 130, INDIAN_RED: 131, HOT_PINK3: 132, MEDIUM_ORCHID3: 133,
218
+ MEDIUM_ORCHID: 134, MEDIUM_PURPLE2: 135, DARK_GOLDENROD: 136,
219
+ LIGHT_SALMON3: 137, ROSY_BROWN: 138, GREY63: 139, MEDIUM_PURPLE1: 141,
220
+ GOLD3: 142, DARK_KHAKI: 143, NAVAJO_WHITE3: 144, GREY69: 145,
221
+ LIGHT_STEEL_BLUE3: 146, LIGHT_STEEL_BLUE: 147, YELLOW3: 148,
222
+ DARK_SEA_GREEN2: 151, LIGHT_CYAN3: 152, LIGHT_SKY_BLUE1: 153,
223
+ GREEN_YELLOW: 154, DARK_OLIVE_GREEN2: 155, DARK_SEA_GREEN1: 158,
224
+ PALE_TURQUOISE1: 159, DEEP_PINK3: 161, MAGENTA2: 165, HOT_PINK2: 169,
225
+ ORCHID: 170, MEDIUM_ORCHID1: 171, ORANGE3: 172, LIGHT_PINK3: 174,
226
+ PINK3: 175, PLUM3: 176, VIOLET: 177, LIGHT_GOLDENROD3: 179, TAN: 180,
227
+ MISTY_ROSE3: 181, THISTLE3: 182, PLUM2: 183, KHAKI3: 185,
228
+ LIGHT_GOLDENROD2: 186, LIGHT_YELLOW3: 187, GREY84: 188,
229
+ LIGHT_STEEL_BLUE1: 189, YELLOW2: 190, DARK_OLIVE_GREEN1: 191,
230
+ HONEYDEW2: 194, LIGHT_CYAN1: 195, RED1: 196, DEEP_PINK2: 197,
231
+ DEEP_PINK1: 198, MAGENTA1: 201, ORANGE_RED1: 202, INDIAN_RED1: 203,
232
+ HOT_PINK: 205, DARK_ORANGE: 208, SALMON1: 209, LIGHT_CORAL: 210,
233
+ PALE_VIOLET_RED1: 211, ORCHID2: 212, ORCHID1: 213, ORANGE1: 214,
234
+ SANDY_BROWN: 215, LIGHT_SALMON1: 216, LIGHT_PINK1: 217, PINK1: 218,
235
+ PLUM1: 219, GOLD1: 220, NAVAJO_WHITE1: 223, MISTY_ROSE1: 224,
236
+ THISTLE1: 225, YELLOW1: 226, LIGHT_GOLDENROD1: 227, KHAKI1: 228,
237
+ WHEAT1: 229, CORNSILK1: 230, GREY100: 231, GREY3: 232, GREY7: 233,
238
+ GREY11: 234, GREY15: 235, GREY19: 236, GREY23: 237, GREY27: 238,
239
+ GREY30: 239, GREY35: 240, GREY39: 241, GREY42: 242, GREY46: 243,
240
+ GREY50: 244, GREY54: 245, GREY58: 246, GREY62: 247, GREY66: 248,
241
+ GREY70: 249, GREY74: 250, GREY78: 251, GREY82: 252, GREY85: 253,
242
+ GREY89: 254, GREY93: 255
243
+ }.freeze
244
+
245
+ PALETTE_NAMES.each do |name, index|
246
+ const_set(name, new(index))
247
+ end
248
+ end
249
+ end
@@ -20,6 +20,7 @@ module Tuile
20
20
  super()
21
21
  @caption = caption.to_s
22
22
  @on_click = on_click
23
+ self.content_size = natural_size
23
24
  end
24
25
 
25
26
  # @return [String] the button's label.
@@ -38,16 +39,13 @@ module Tuile
38
39
 
39
40
  @caption = new_caption
40
41
  invalidate
42
+ self.content_size = natural_size
41
43
  end
42
44
 
43
45
  def focusable? = true
44
46
 
45
47
  def tab_stop? = true
46
48
 
47
- # @return [Size] natural width is `caption.length + 4` to fit
48
- # `[ caption ]`; height is 1.
49
- def content_size = Size.new(@caption.length + 4, 1)
50
-
51
49
  # @param key [String]
52
50
  # @return [Boolean]
53
51
  def handle_key(key)
@@ -78,9 +76,15 @@ module Tuile
78
76
  return if rect.empty?
79
77
 
80
78
  label = "[ #{@caption} ]"[0, rect.width]
81
- styled = active? ? Rainbow(label).bg(:darkslategray) : label
79
+ styled = active? ? screen.theme.active_bg(label) : label
82
80
  screen.print TTY::Cursor.move_to(rect.left, rect.top), styled
83
81
  end
82
+
83
+ private
84
+
85
+ # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
86
+ # @return [Size]
87
+ def natural_size = Size.new(@caption.length + 4, 1)
84
88
  end
85
89
  end
86
90
  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
@@ -30,23 +36,26 @@ module Tuile
30
36
  return if @text == new_text
31
37
 
32
38
  @text = new_text
33
- @content_size = nil
34
39
  update_clipped_lines
35
40
  invalidate
41
+ self.content_size = compute_content_size
36
42
  end
37
43
 
38
- # @return [Size] longest hard-line's display width × number of hard
39
- # lines. Reported on the *unclipped* text sizing is intrinsic to
40
- # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
41
- def content_size
42
- @content_size ||=
43
- if @text.empty?
44
- Size::ZERO
45
- else
46
- hard_lines = @text.lines
47
- width = hard_lines.map(&:display_width).max || 0
48
- Size.new(width, hard_lines.size)
49
- end
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
50
59
  end
51
60
 
52
61
  # Paints the text into {#rect}.
@@ -75,16 +84,35 @@ module Tuile
75
84
 
76
85
  private
77
86
 
87
+ # Natural size: longest hard-line's display width × number of hard
88
+ # lines. Computed on the *unclipped* text — sizing is intrinsic to the
89
+ # content, not the viewport. Empty text yields {Size::ZERO}.
90
+ # @return [Size]
91
+ def compute_content_size
92
+ return Size::ZERO if @text.empty?
93
+
94
+ hard_lines = @text.lines
95
+ width = hard_lines.map(&:display_width).max || 0
96
+ Size.new(width, hard_lines.size)
97
+ end
98
+
78
99
  # Recomputes {@clipped_lines} for the current text and rect width.
79
100
  # Each line is ellipsized to fit, padded with trailing spaces out to
80
101
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
81
102
  # lookup + screen.print per row. {@blank_line} covers rows past the
82
- # last text line.
103
+ # last text line. When {#bg} is set, every produced line (and the
104
+ # blank row) has the bg applied uniformly.
83
105
  # @return [void]
84
106
  def update_clipped_lines
85
107
  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 }
108
+ @blank_line = apply_bg(StyledString.plain(" " * width)).to_ansi
109
+ @clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)).to_ansi }
110
+ end
111
+
112
+ # @param line [StyledString]
113
+ # @return [StyledString]
114
+ def apply_bg(line)
115
+ @bg ? line.with_bg(@bg) : line
88
116
  end
89
117
 
90
118
  # @param line [StyledString]