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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
|
7
|
-
# character has exactly one resolved style, no overlay
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
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.
|
|
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
|
-
|
|
727
|
+
line_glyphs = []
|
|
711
728
|
line_w = 0
|
|
712
729
|
|
|
713
|
-
tokenize_for_wrap(hard_line).each do |type,
|
|
730
|
+
tokenize_for_wrap(hard_line).each do |type, glyphs, w|
|
|
714
731
|
if type == :space
|
|
715
|
-
if line_w.zero?
|
|
716
|
-
#
|
|
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
|
-
|
|
736
|
+
line_glyphs.concat(glyphs)
|
|
719
737
|
line_w += w
|
|
720
738
|
else
|
|
721
|
-
result <<
|
|
722
|
-
|
|
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
|
-
|
|
744
|
+
line_glyphs.concat(glyphs)
|
|
727
745
|
line_w += w
|
|
728
746
|
elsif w > width
|
|
729
|
-
result <<
|
|
730
|
-
chunks =
|
|
731
|
-
chunks[0..-2].each { |chunk| result <<
|
|
732
|
-
|
|
733
|
-
line_w =
|
|
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 <<
|
|
736
|
-
|
|
753
|
+
result << glyphs_to_styled(line_glyphs)
|
|
754
|
+
line_glyphs = glyphs
|
|
737
755
|
line_w = w
|
|
738
756
|
end
|
|
739
757
|
end
|
|
740
|
-
result <<
|
|
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,
|
|
746
|
-
# `:space` or `:word`, `
|
|
747
|
-
# (
|
|
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
|
-
|
|
773
|
+
current_glyphs = []
|
|
751
774
|
current_w = 0
|
|
752
775
|
current_type = nil
|
|
753
776
|
|
|
754
|
-
hard_line
|
|
755
|
-
type = [" ", "\t"].include?(
|
|
756
|
-
|
|
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,
|
|
759
|
-
|
|
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
|
-
|
|
764
|
-
current_w +=
|
|
786
|
+
current_glyphs << [g, s, gw]
|
|
787
|
+
current_w += gw
|
|
765
788
|
end
|
|
766
|
-
tokens << [current_type,
|
|
789
|
+
tokens << [current_type, current_glyphs, current_w] unless current_glyphs.empty?
|
|
767
790
|
tokens
|
|
768
791
|
end
|
|
769
792
|
|
|
770
|
-
#
|
|
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 `
|
|
773
|
-
def
|
|
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
|
-
|
|
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
|
|
827
|
+
# @param glyphs [Array<Array>] `[grapheme cluster, style, width]` triples.
|
|
792
828
|
# @return [StyledString]
|
|
793
|
-
def
|
|
794
|
-
return self.class.new if
|
|
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 =
|
|
799
|
-
|
|
834
|
+
current_style = glyphs.first[1]
|
|
835
|
+
glyphs.each do |g, s, _|
|
|
800
836
|
if s == current_style
|
|
801
|
-
current_text <<
|
|
837
|
+
current_text << g
|
|
802
838
|
else
|
|
803
839
|
spans << Span.new(text: current_text, style: current_style)
|
|
804
|
-
current_text = +
|
|
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.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if
|
|
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 &&
|
|
827
|
-
out <<
|
|
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
|
|
830
|
-
col =
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
11
|
-
# {#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# {#
|
|
50
|
-
#
|
|
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
|
-
#
|
|
64
|
-
# {
|
|
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
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
|