tuile 0.9.0 → 0.11.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -3,14 +3,11 @@
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`, `strikethrough`). Spans are non-overlapping and fully tile the string — every
7
- # character has exactly one resolved style, no overlay layers to merge.
8
- #
9
- # Where this differs from threading SGR escapes through a plain `String`:
10
- # slicing, wrapping, and concatenation operate on the structured spans, so
11
- # they never have to "figure out what SGR state is active at column N" —
12
- # the answer is just the containing span's `style`. The flip side is one
13
- # extra type to construct (or parse) before doing styled-text math.
6
+ # `underline`, `strikethrough`). Spans are non-overlapping and fully tile
7
+ # the string — every character has exactly one resolved style, no overlay
8
+ # layers to merge, so the style at any column is just its span's `style`
9
+ # rather than a replay of the SGR state machine. The book's chapter 9 is
10
+ # the long-form *why* (spans vs. a `String` full of escape codes).
14
11
  #
15
12
  # ## Constructors
16
13
  #
@@ -34,30 +31,15 @@ module Tuile
34
31
  # ss.each_char_with_style { |ch, style| ... }
35
32
  # ```
36
33
  #
37
- # ## Rendering
38
- #
39
- # - `#to_s` — plain text, no SGR.
40
- # - `#to_ansi` — minimal-diff SGR rendering, ending with `\e[0m` only when
41
- # the last span carried a non-default style. Transitions to the default
42
- # style emit `\e[0m` (shorter than re-emitting every off-code).
43
- #
44
34
  # ## Parser
45
35
  #
46
- # {.parse} is strict by default: it recognizes only the SGR codes
47
- # corresponding to {Style}'s supported attributes (fg/bg/bold/italic/
48
- # underline/strikethrough). Anything else — unmodeled attributes (dim, blink,
49
- # reverse, conceal, double-underline, overline, ...), unknown SGR codes, or
50
- # non-SGR escapes (cursor moves, OSC) — raises {ParseError}. This keeps the
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`.
36
+ # {.parse} is strict by default — it recognizes only the SGR codes for
37
+ # {Style}'s attributes (fg/bg/bold/italic/underline/strikethrough) and
38
+ # raises {ParseError} on anything else, keeping the `parse(to_ansi(x)) == x`
39
+ # round-trip honest. Pass `lenient: true` to instead discard everything it
40
+ # can't model (unmodeled SGR, cursor moves, OSC/DCS, stray escapes) and keep
41
+ # only the recognized colors — lossy by design, for piping in colored output
42
+ # you don't control. See the book for the full rationale.
61
43
  class StyledString
62
44
  # Raised by {.parse} on malformed or unsupported escape sequences.
63
45
  class ParseError < Error; end
@@ -111,10 +93,6 @@ module Tuile
111
93
  # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
112
94
  # (`\e[0m`, one code) when `other` is the default style — shorter than
113
95
  # turning each attribute off individually.
114
- #
115
- # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
116
- # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
117
- # terminal currently holds), so both emit identical minimal sequences.
118
96
  # @param other [Style] the style to transition to.
119
97
  # @return [String]
120
98
  def sgr_to(other)
@@ -419,6 +397,19 @@ module Tuile
419
397
  end
420
398
  end
421
399
 
400
+ # The framework's single emoji-width policy, passed to every
401
+ # `Unicode::DisplayWidth.of` call in Tuile.
402
+ #
403
+ # `:rgi` credits width 2 only to [RGI](https://www.unicode.org/reports/tr51/)
404
+ # sequences — the ones vendors actually ship a single glyph for — and *sums*
405
+ # the parts of anything else. That is the one setting never wrong in the
406
+ # dangerous direction: under-measuring lets a glyph overrun its cell, which
407
+ # shifts the rest of the row, desyncs the cursor and escapes the component's
408
+ # rect, while over-measuring leaves a blank column. `D-cluster-width` has the
409
+ # per-setting reasoning.
410
+ # @return [Symbol]
411
+ EMOJI_WIDTH = :rgi
412
+
422
413
  # @return [Array<Span>] the frozen, normalized span list — no empty-text
423
414
  # entries, no two adjacent entries sharing a style.
424
415
  attr_reader :spans
@@ -430,10 +421,9 @@ module Tuile
430
421
 
431
422
  # Total display width in terminal columns, accounting for Unicode wide
432
423
  # characters (fullwidth CJK = 2 columns, combining marks = 0, etc.).
433
- # Memoized — safe because spans are frozen and immutable.
434
424
  # @return [Integer]
435
425
  def display_width
436
- @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text) }
426
+ @display_width ||= @spans.sum { |s| Unicode::DisplayWidth.of(s.text, emoji: EMOJI_WIDTH) }
437
427
  end
438
428
 
439
429
  # @return [Boolean]
@@ -450,7 +440,7 @@ module Tuile
450
440
  # emits `\e[0m` (one code) instead of the longer "turn each attribute
451
441
  # off" form. Always closes with `\e[0m` when the last span carried a
452
442
  # non-default style, so the styled run doesn't bleed into subsequent
453
- # output. Memoized — safe because spans are frozen and immutable.
443
+ # output.
454
444
  # @return [String]
455
445
  def to_ansi
456
446
  @to_ansi ||= build_ansi
@@ -552,11 +542,20 @@ module Tuile
552
542
  # wrapped continuations, hard `"\n"` breaks preserved as separate output
553
543
  # lines.
554
544
  #
545
+ # An indent is content, so it survives onto the first row — but there is no
546
+ # hanging indent:
547
+ #
548
+ # StyledString.plain(" read config").wrap(20).map(&:to_s)
549
+ # # => [" read config"] indent kept; the line never wrapped
550
+ # StyledString.plain(" read config").wrap(6).map(&:to_s)
551
+ # # => [" read", "config"] ...but a continuation starts at column 0
552
+ #
555
553
  # Whitespace runs are space or tab; other characters are treated as word
556
554
  # content. When a single character is wider than `width` (e.g. a 2-column
557
555
  # CJK character with `width = 1`), it is still emitted on its own line at
558
556
  # its natural width. The "no line exceeds `width`" guarantee therefore
559
- # holds whenever every character is at most `width` columns wide.
557
+ # holds whenever every character is at most `width` columns wide. An indent
558
+ # that alone exceeds `width` is dropped rather than given a row of its own.
560
559
  #
561
560
  # @param width [Integer, nil] target column width. `nil` or `<= 0` skips
562
561
  # wrapping and returns each hard-line as-is, so callers can pass a
@@ -600,6 +599,24 @@ module Tuile
600
599
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
601
600
  end
602
601
 
602
+ # Returns a copy with `bg` set **only on spans that have none**; a span with
603
+ # an explicit bg is left untouched. The fill-unset counterpart of {#with_bg}
604
+ # (which overrides every span) — it slides a background *under* the content,
605
+ # so a log line keeps its red error-level bg while its plain text picks up an
606
+ # inherited panel tint.
607
+ #
608
+ # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background color,
609
+ # coerced via {Color.coerce}. `nil` returns `self` unchanged.
610
+ # @return [StyledString]
611
+ def under_bg(bg)
612
+ return self if bg.nil?
613
+
614
+ bg = Color.coerce(bg)
615
+ self.class.new(@spans.map do |span|
616
+ span.style.bg.nil? ? Span.new(text: span.text, style: span.style.merge(bg: bg)) : span
617
+ end)
618
+ end
619
+
603
620
  # Returns a new {StyledString} with `fg` applied to every span, preserving
604
621
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
605
622
  # `underline`, `strikethrough`). The new fg overlays without dropping background colors or
@@ -683,7 +700,7 @@ module Tuile
683
700
  out = []
684
701
  col = 0
685
702
  @spans.each do |span|
686
- span_width = Unicode::DisplayWidth.of(span.text)
703
+ span_width = Unicode::DisplayWidth.of(span.text, emoji: EMOJI_WIDTH)
687
704
  span_end = col + span_width
688
705
 
689
706
  next col = span_end if span_end <= start
@@ -707,74 +724,93 @@ module Tuile
707
724
  return [hard_line] if hard_line.empty?
708
725
 
709
726
  result = []
710
- line_chars = []
727
+ line_glyphs = []
711
728
  line_w = 0
712
729
 
713
- tokenize_for_wrap(hard_line).each do |type, chars, w|
730
+ tokenize_for_wrap(hard_line).each do |type, glyphs, w|
714
731
  if type == :space
715
- if line_w.zero?
716
- # leading whitespace on a wrapped continuation: drop
732
+ if line_w.zero? && (!result.empty? || w > width)
733
+ # Nothing to emit: a continuation's leading run was consumed by the
734
+ # break, and an indent wider than the viewport conveys no nesting.
717
735
  elsif line_w + w <= width
718
- line_chars.concat(chars)
736
+ line_glyphs.concat(glyphs)
719
737
  line_w += w
720
738
  else
721
- result << chars_to_styled(line_chars)
722
- line_chars = []
739
+ result << glyphs_to_styled(line_glyphs)
740
+ line_glyphs = []
723
741
  line_w = 0
724
742
  end
725
743
  elsif line_w + w <= width
726
- line_chars.concat(chars)
744
+ line_glyphs.concat(glyphs)
727
745
  line_w += w
728
746
  elsif w > width
729
- result << chars_to_styled(line_chars) unless line_w.zero?
730
- chunks = hard_break_chars(chars, width)
731
- chunks[0..-2].each { |chunk| result << chars_to_styled(chunk) }
732
- line_chars = chunks.last
733
- line_w = line_chars.sum { |triple| triple[2] }
747
+ result << glyphs_to_styled(line_glyphs) unless line_w.zero?
748
+ chunks = hard_break_glyphs(glyphs, width)
749
+ chunks[0..-2].each { |chunk| result << glyphs_to_styled(chunk) }
750
+ line_glyphs = chunks.last
751
+ line_w = line_glyphs.sum { |triple| triple[2] }
734
752
  else
735
- result << chars_to_styled(line_chars)
736
- line_chars = chars
753
+ result << glyphs_to_styled(line_glyphs)
754
+ line_glyphs = glyphs
737
755
  line_w = w
738
756
  end
739
757
  end
740
- result << chars_to_styled(line_chars)
758
+ result << glyphs_to_styled(line_glyphs)
741
759
  result
742
760
  end
743
761
 
762
+ # Splits into whitespace/word tokens by **grapheme cluster**, not character:
763
+ # a cluster is the unit a terminal draws, so measuring its parts separately
764
+ # would both mis-total an emoji sequence and let a wrap break a letter away
765
+ # from its combining mark.
744
766
  # @param hard_line [StyledString]
745
- # @return [Array<Array>] tokens shaped `[type, chars, w]` where `type` is
746
- # `:space` or `:word`, `chars` is an `Array<[String, Style, Integer]>`
747
- # (char, style, display width), and `w` is the token's total width.
767
+ # @return [Array<Array>] tokens shaped `[type, glyphs, w]` where `type` is
768
+ # `:space` or `:word`, `glyphs` is an `Array<[String, Style, Integer]>`
769
+ # (grapheme cluster, style, display width), and `w` is the token's total
770
+ # width.
748
771
  def tokenize_for_wrap(hard_line)
749
772
  tokens = []
750
- current_chars = []
773
+ current_glyphs = []
751
774
  current_w = 0
752
775
  current_type = nil
753
776
 
754
- hard_line.each_char_with_style do |c, s|
755
- type = [" ", "\t"].include?(c) ? :space : :word
756
- cw = Unicode::DisplayWidth.of(c)
777
+ each_glyph_with_style(hard_line) do |g, s|
778
+ type = [" ", "\t"].include?(g) ? :space : :word
779
+ gw = Buffer.display_width(g)
757
780
  if current_type && current_type != type
758
- tokens << [current_type, current_chars, current_w]
759
- current_chars = []
781
+ tokens << [current_type, current_glyphs, current_w]
782
+ current_glyphs = []
760
783
  current_w = 0
761
784
  end
762
785
  current_type = type
763
- current_chars << [c, s, cw]
764
- current_w += cw
786
+ current_glyphs << [g, s, gw]
787
+ current_w += gw
765
788
  end
766
- tokens << [current_type, current_chars, current_w] unless current_chars.empty?
789
+ tokens << [current_type, current_glyphs, current_w] unless current_glyphs.empty?
767
790
  tokens
768
791
  end
769
792
 
770
- # @param chars [Array<Array>] `[char, style, width]` triples.
793
+ # Like {#each_char_with_style} but per grapheme cluster. A cluster spanning a
794
+ # style boundary takes the style of its first span — pathological input, and
795
+ # splitting the cluster to honor both styles would paint a headless mark.
796
+ # @param styled [StyledString]
797
+ # @yieldparam glyph [String] one grapheme cluster.
798
+ # @yieldparam style [Style]
799
+ # @return [void]
800
+ def each_glyph_with_style(styled)
801
+ styled.spans.each do |span|
802
+ span.text.each_grapheme_cluster { |g| yield g, span.style }
803
+ end
804
+ end
805
+
806
+ # @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
771
807
  # @param width [Integer]
772
- # @return [Array<Array<Array>>] each inner Array is a `chars`-shaped chunk.
773
- def hard_break_chars(chars, width)
808
+ # @return [Array<Array<Array>>] each inner Array is a `glyphs`-shaped chunk.
809
+ def hard_break_glyphs(glyphs, width)
774
810
  chunks = []
775
811
  current = []
776
812
  current_w = 0
777
- chars.each do |triple|
813
+ glyphs.each do |triple|
778
814
  cw = triple[2]
779
815
  if current_w + cw > width && current_w.positive?
780
816
  chunks << current
@@ -788,20 +824,20 @@ module Tuile
788
824
  chunks
789
825
  end
790
826
 
791
- # @param chars [Array<Array>] `[char, style, width]` triples.
827
+ # @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
792
828
  # @return [StyledString]
793
- def chars_to_styled(chars)
794
- return self.class.new if chars.empty?
829
+ def glyphs_to_styled(glyphs)
830
+ return self.class.new if glyphs.empty?
795
831
 
796
832
  spans = []
797
833
  current_text = +""
798
- current_style = chars.first[1]
799
- chars.each do |c, s, _|
834
+ current_style = glyphs.first[1]
835
+ glyphs.each do |g, s, _|
800
836
  if s == current_style
801
- current_text << c
837
+ current_text << g
802
838
  else
803
839
  spans << Span.new(text: current_text, style: current_style)
804
- current_text = +c
840
+ current_text = +g
805
841
  current_style = s
806
842
  end
807
843
  end
@@ -809,6 +845,9 @@ module Tuile
809
845
  self.class.new(spans)
810
846
  end
811
847
 
848
+ # Walks **grapheme clusters**, so a slice boundary can never fall inside one:
849
+ # cutting a cluster would strand a combining mark with no base, which the
850
+ # painter drops outright, silently losing the accent off a letter.
812
851
  # @param text [String]
813
852
  # @param start_col [Integer]
814
853
  # @param len_col [Integer]
@@ -816,18 +855,18 @@ module Tuile
816
855
  def slice_text_by_columns(text, start_col, len_col)
817
856
  out = +""
818
857
  col = 0
819
- text.each_char do |c|
820
- cw = Unicode::DisplayWidth.of(c)
821
- char_end = col + cw
822
- if char_end <= start_col
858
+ text.each_grapheme_cluster do |g|
859
+ gw = Buffer.display_width(g)
860
+ glyph_end = col + gw
861
+ if glyph_end <= start_col
823
862
  # entirely before slice — skip
824
863
  elsif col >= start_col + len_col
825
864
  break
826
- elsif col >= start_col && char_end <= start_col + len_col
827
- out << c
865
+ elsif col >= start_col && glyph_end <= start_col + len_col
866
+ out << g
828
867
  end
829
- # any other case = partial overlap with a wide char — drop
830
- col = char_end
868
+ # any other case = partial overlap with a wide glyph — drop
869
+ col = glyph_end
831
870
  end
832
871
  out
833
872
  end
data/lib/tuile/theme.rb CHANGED
@@ -1,67 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tuile
4
- # A set of semantic colors the built-in components read when painting.
5
- # The current theme lives at {Screen#theme}; components must look it up
6
- # at paint time (inside `repaint`) rather than caching values, so that
7
- # assigning {Screen#theme=} restyles everything via a single
8
- # invalidate-everything pass.
4
+ # A set of semantic colors the built-in components read when painting. The
5
+ # current theme lives at {Screen#theme}; components must look it up at paint
6
+ # time (inside `repaint`) rather than caching values, so a {Screen#theme=}
7
+ # restyles everything via one invalidate-everything pass. Book ch6 is the
8
+ # concept in full (why accents-only, dark/light, live OS flips).
9
9
  #
10
- # The primary API is the rendering helpers — {#active_bg},
11
- # {#active_border}, {#input_bg}, {#hint} — which wrap a plain string in
12
- # the token's SGR color (on the channel appropriate for the token's
13
- # role) and reset:
10
+ # The rendering helpers — {#active_bg}, {#active_border}, {#input_bg},
11
+ # {#hint} — wrap a plain string in the token's SGR color (on the channel
12
+ # appropriate for the token's role) and reset:
14
13
  #
15
14
  # screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
16
15
  # screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
17
16
  #
18
- # The helpers pass content through verbatim, so input may carry other
19
- # escape sequences (e.g. {Component::Window} feeds its border string,
20
- # cursor moves included). For span-aware styling — applying a token to a
21
- # {StyledString} while preserving per-span colors — use the `*_color`
22
- # readers instead (e.g. {Component::List} highlights its cursor row via
23
- # `with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome text →
24
- # helper; structured text → `*_color` reader + {StyledString}.
17
+ # Content passes through verbatim (so it may carry other escapes). For
18
+ # span-aware styling — a token applied to a {StyledString} without flattening
19
+ # its per-span colors — use the `*_color` readers instead
20
+ # (`with_bg(theme.active_bg_color)`). Rule of thumb: plain chrome → helper;
21
+ # structured text → `*_color` reader + {StyledString}.
25
22
  #
26
- # Two built-in themes are provided: {DARK} (the default; the colors Tuile
27
- # has always used) and {LIGHT} (counterparts legible on light terminal
28
- # backgrounds). A custom theme is one `with` away:
23
+ # Two built-in themes ship: {DARK} (default) and {LIGHT}. A custom one is one
24
+ # `with` away, and every token must be a {Color} instance — not the lenient
25
+ # {Color.coerce} forms, since a theme is declared once so the verbosity
26
+ # self-documents:
29
27
  #
30
28
  # screen.theme = Theme::DARK.with(active_border_color: Color::CYAN)
31
29
  #
32
- # Tokens deliberately cover only the *accents* Tuile paints. Everything
33
- # else inherits the terminal's own default foreground/background, which
34
- # already matches the user's terminal theme perfectly — that's why there
35
- # is no global `bg`/`fg` token.
36
- #
37
- # Every token is a {Color} — and must be passed as one. Unlike the
38
- # lenient {Color.coerce} call sites elsewhere in the framework, a theme
39
- # is declared once per app, so it takes only {Color} instances: at a
40
- # declaration site `Color.palette(130)` documents itself in a way the
41
- # bare `130` does not (palette index? RGB channel?) — and the named
42
- # palette constants (`Color::DARK_ORANGE3` *is* 130; see
43
- # {Color::PALETTE_NAMES}) go one step further.
44
- #
45
30
  # ## App-specific tokens
46
31
  #
47
- # Beyond the built-in tokens, an app can carry its own colors in
48
- # {#custom} — a frozen `Hash{Symbol => Color}` member. Look them up with
49
- # {#[]} (fail-fast: a typo raises `KeyError`) and render with the
50
- # generic {#fg} / {#bg} helpers:
32
+ # An app carries its own colors in {#custom} (frozen `Hash{Symbol => Color}`).
33
+ # Look them up with {#[]} (fail-fast on typos) and render with the generic
34
+ # {#fg} / {#bg} helpers; subclass for semantic readers (`Data#with` keeps the
35
+ # subclass). Pair dark/light variants in a {ThemeDef} for {Screen#theme_def=}.
51
36
  #
52
37
  # theme = Theme::DARK.with(custom: { accent: Color::DARK_ORANGE })
53
38
  # theme[:accent] # => Color, e.g. for StyledString#with_fg
54
39
  # theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
55
40
  #
56
- # Apps wanting semantic readers can subclass — `Data#with` preserves the
57
- # subclass, so an `AppTheme` stays an `AppTheme` through `with`:
58
- #
59
41
  # class AppTheme < Tuile::Theme
60
42
  # def accent(text) = fg(:accent, text)
61
43
  # end
62
44
  #
63
- # Pair the dark and light variants in a {ThemeDef} and hand it to
64
- # {Screen#theme_def=} so OS appearance flips pick the right one.
45
+ # For a color slot resolved *live* at paint — currently
46
+ # {Component#bg_color=} — assign a {Ref} instead of reading + rebuilding
47
+ # the token in {Component#on_theme_changed}; it tracks theme swaps on its
48
+ # own. Baked content colors ({Component::Label} text and friends) can't:
49
+ # they live in a frozen {StyledString} and still need the hook.
65
50
  #
66
51
  # @!attribute [r] active_bg_color
67
52
  # Background highlight of the component the user is interacting with:
@@ -117,6 +102,58 @@ module Tuile
117
102
  # loudly, not paint in a default.
118
103
  def [](token) = custom.fetch(token)
119
104
 
105
+ # The built-in chrome color tokens — every {Data} member bar {#custom}. A
106
+ # {Ref} resolves a name in this set as the chrome color; anything else as a
107
+ # {#custom} token.
108
+ # @return [Array<Symbol>]
109
+ CHROME_TOKENS = (members - %i[custom]).freeze
110
+
111
+ # @param name [Symbol] a token name.
112
+ # @return [Boolean] true iff `name` is a built-in chrome token (see
113
+ # {CHROME_TOKENS}) rather than a {#custom} one.
114
+ def self.chrome_token?(name) = CHROME_TOKENS.include?(name)
115
+
116
+ # Builds a {Ref} — a live theme reference for a late-resolved color slot
117
+ # like {Component#bg_color=}. Sugar for `Theme::Ref.new(name)`.
118
+ # @param name [Symbol] a built-in chrome token ({#input_bg_color} etc.) or
119
+ # a {#custom} token name.
120
+ # @return [Ref]
121
+ def self.ref(name) = Ref.new(name)
122
+
123
+ # A live reference to a theme token, resolved against the current theme at
124
+ # paint time rather than baked to a concrete {Color}. Assign one where a
125
+ # slot is resolved late — currently {Component#bg_color=} — and it follows
126
+ # light/dark flips with no {Component#on_theme_changed} hook:
127
+ #
128
+ # panel.bg_color = Tuile::Theme.ref(:panel_bg) # a #custom token
129
+ # dropdown.bg_color = Tuile::Theme.ref(:input_bg_color) # built-in chrome
130
+ #
131
+ # The name may be a built-in chrome token ({CHROME_TOKENS}) or a {#custom}
132
+ # one; a chrome name takes precedence on the (pathological) collision. This
133
+ # does *not* add a global bg/fg token — it only lets a slot point at a
134
+ # color the theme *already* carries, resolved the same way framework chrome
135
+ # already resolves it.
136
+ #
137
+ # Distinct from {Color.coerce}'s symbol support, which names one of the 16
138
+ # ANSI colors and yields a fixed {Color}; a Ref names a *theme* token and
139
+ # re-reads it each paint.
140
+ #
141
+ # Immutable.
142
+ class Ref < Data.define(:name)
143
+ # Resolves to the concrete {Color} `name` maps to in `theme` — a built-in
144
+ # chrome reader when `name` is one ({Theme.chrome_token?}), else a
145
+ # {#custom} token.
146
+ # @param theme [Theme]
147
+ # @return [Color]
148
+ # @raise [KeyError] when `name` is neither a chrome token nor a {#custom}
149
+ # token in `theme`.
150
+ def resolve(theme)
151
+ return theme.public_send(name) if Theme.chrome_token?(name)
152
+
153
+ theme[name]
154
+ end
155
+ end
156
+
120
157
  # Renders `text` in the foreground color of the app-specific `token`
121
158
  # — the generic counterpart of {#hint} for {#custom} tokens.
122
159
  # @param token [Symbol]
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.9.0"
5
+ VERSION = "0.11.0"
6
6
  end
data/lib/tuile.rb CHANGED
@@ -32,5 +32,9 @@ module Tuile
32
32
  end
33
33
 
34
34
  loader = Zeitwerk::Loader.for_gem
35
+ # Keeps Tuile's one optional dependency optional: the file requires
36
+ # `bigdecimal` at load, so a host app calling Zeitwerk::Loader.eager_load_all
37
+ # would otherwise raise LoadError for a component it never names.
38
+ loader.do_not_eager_load("#{__dir__}/tuile/component/big_decimal_field.rb")
35
39
  loader.setup
36
40
  end