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.
data/sig/tuile.rbs CHANGED
@@ -188,6 +188,70 @@ module Tuile
188
188
  attr_reader height: Integer
189
189
  end
190
190
 
191
+ # An immutable terminal color. Accepts the three forms ANSI/SGR understands:
192
+ #
193
+ # - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
194
+ # (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
195
+ # - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
196
+ # - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
197
+ #
198
+ # A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
199
+ # …) so callers can reach for `Color::RED` instead of building one each time.
200
+ # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
201
+ # an existing {Color} (returned as-is), so APIs that accept colors typically
202
+ # take `[Color, nil]` and pass through {.coerce}.
203
+ #
204
+ # ```ruby
205
+ # Color.new(:red) # named
206
+ # Color.new(42) # 256-color palette
207
+ # Color.new([255, 100, 0]) # RGB
208
+ # Color::RED # constant
209
+ # Color.coerce(:red) # accepts raw forms, returns Color
210
+ # Color.coerce(nil) # nil → nil
211
+ # ```
212
+ #
213
+ # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
214
+ # raw numeric codes so callers (notably {StyledString}) can combine them with
215
+ # other SGR attributes in a single sequence.
216
+ class Color
217
+ COLOR_SYMBOLS: ::Array[Symbol]
218
+
219
+ # Coerces the input to a {Color}. `nil` passes through unchanged (callers
220
+ # use `nil` for the terminal default); an existing {Color} is returned
221
+ # as-is; otherwise the value is fed to {.new}.
222
+ #
223
+ # _@param_ `value`
224
+ def self.coerce: ((Color | Symbol | Integer | ::Array[Integer])? value) -> Color?
225
+
226
+ # _@param_ `value` — see class-level docs for the three accepted forms.
227
+ def initialize: ((Symbol | Integer | ::Array[Integer]) value) -> void
228
+
229
+ # SGR parameter codes for emitting this color as either a foreground
230
+ # (`target: :fg`) or background (`target: :bg`). Returned as an array so
231
+ # callers can splice them into a multi-attribute SGR (e.g. bold + color).
232
+ #
233
+ # _@param_ `target` — `:fg` or `:bg`.
234
+ def sgr_codes: (?Symbol target) -> ::Array[Integer]
235
+
236
+ # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
237
+ # `print`-style direct emission; for composing with other attributes use
238
+ # {#sgr_codes} instead.
239
+ #
240
+ # _@param_ `target` — `:fg` or `:bg`.
241
+ def to_ansi: (?Symbol target) -> String
242
+
243
+ # _@param_ `other`
244
+ def ==: (Object other) -> bool
245
+
246
+ def hash: () -> Integer
247
+
248
+ def inspect: () -> String
249
+
250
+ # The underlying raw representation — a Symbol, Integer, or frozen
251
+ # Array<Integer>.
252
+ attr_reader value: (Symbol | Integer | ::Array[Integer])
253
+ end
254
+
191
255
  # A point with `x` and `y` integer coordinates, both 0-based.
192
256
  #
193
257
  # @!attribute [r] x
@@ -1109,9 +1173,13 @@ module Tuile
1109
1173
  # Each line is ellipsized to fit, padded with trailing spaces out to
1110
1174
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
1111
1175
  # lookup + screen.print per row. {@blank_line} covers rows past the
1112
- # last text line.
1176
+ # last text line. When {#bg} is set, every produced line (and the
1177
+ # blank row) has the bg applied uniformly.
1113
1178
  def update_clipped_lines: () -> void
1114
1179
 
1180
+ # _@param_ `line`
1181
+ def apply_bg: (StyledString line) -> StyledString
1182
+
1115
1183
  # _@param_ `line`
1116
1184
  #
1117
1185
  # _@param_ `width`
@@ -1120,6 +1188,11 @@ module Tuile
1120
1188
  # _@return_ — the current text. Defaults to an empty
1121
1189
  # {StyledString}.
1122
1190
  attr_accessor text: (StyledString | String)?
1191
+
1192
+ # _@return_ — background color applied uniformly across every
1193
+ # painted row (including padding past the text). `nil` (default)
1194
+ # leaves whatever bg the text's own styling carries.
1195
+ attr_accessor bg: (Color | Symbol | Integer | ::Array[Integer])?
1123
1196
  end
1124
1197
 
1125
1198
  # A modal overlay that wraps any {Component} as its content. Popup itself
@@ -1513,9 +1586,27 @@ module Tuile
1513
1586
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
1514
1587
  # empty {StyledString}.
1515
1588
  #
1589
+ # Detaches every existing {Region} (including the original default)
1590
+ # and installs a fresh internal default region that owns all the new
1591
+ # hard lines. Any handle the caller was holding becomes detached and
1592
+ # raises on use — see {Region#attached?}. The no-op short-circuit
1593
+ # (matching value, same {StyledString}) preserves existing regions.
1594
+ #
1516
1595
  # _@param_ `value`
1517
1596
  def text=: ((String | StyledString)? value) -> void
1518
1597
 
1598
+ # Creates a new empty {Region} at the spatial tail of the document
1599
+ # and returns its handle. Subsequent {#append} / {#<<} / {#add_line}
1600
+ # calls route through this new region (since it is now the spatial
1601
+ # tail). Earlier regions keep their content and their handles stay
1602
+ # valid; their {Region#range} shifts as later regions grow.
1603
+ #
1604
+ # Apps streaming logically-distinct sections (e.g. an LLM's "thinking"
1605
+ # vs. "assistant" output) create one region per section, hold the
1606
+ # handles, and call `region.append` / `region.text=` directly when
1607
+ # they need to grow or rewrite an earlier section.
1608
+ def create_region: () -> Region
1609
+
1519
1610
  # _@return_ — true iff {#text} is empty (no hard lines).
1520
1611
  def empty?: () -> bool
1521
1612
 
@@ -1568,6 +1659,56 @@ module Tuile
1568
1659
  # _@param_ `n` — number of hard lines to drop; must be >= 0.
1569
1660
  def remove_last_n_lines: (Integer n) -> void
1570
1661
 
1662
+ # Replaces a contiguous range of hard lines with the parsed content
1663
+ # of `str`. The replacement is parsed exactly like {#text=} and
1664
+ # {#append}: a {String} is run through {StyledString.parse} (so
1665
+ # embedded ANSI is honored), a {StyledString} is used as-is, `nil`
1666
+ # behaves like an empty replacement (the range is deleted). Embedded
1667
+ # `"\n"` in the replacement produces multiple hard lines, so a single
1668
+ # `replace` can grow or shrink the buffer.
1669
+ #
1670
+ # `range` selects which hard lines to swap out:
1671
+ #
1672
+ # - an `Integer` `n` is shorthand for `n..n` (replace one existing
1673
+ # line — `n` must be in `[0, hard-line count)`);
1674
+ # - a non-empty `Range` of hard-line indices replaces those lines;
1675
+ # - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
1676
+ # `hard_lines.size...hard_lines.size`) is *insertion* at that
1677
+ # position — no lines are removed. {#insert} is a thin alias for
1678
+ # this case.
1679
+ #
1680
+ # Endpoints must be non-negative integers; `begin` may equal
1681
+ # `hard-line count` (insertion at the end), `end` may not exceed
1682
+ # `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
1683
+ # are not accepted.
1684
+ #
1685
+ # Cost is roughly `O(from + length + new content)`: the splice
1686
+ # updates only the affected slice of the physical-row buffer, using
1687
+ # the per-hard-line wrap-count cache to locate the starting offset
1688
+ # without re-wrapping preceding lines. Lines outside the splice are
1689
+ # never re-wrapped. {#top_line} is clamped if the new line count
1690
+ # puts it past the end; {#auto_scroll} pins it to the bottom as
1691
+ # usual. The call is a no-op (no invalidation) when the parsed
1692
+ # replacement equals the covered range (vacuously true for an empty
1693
+ # range plus empty replacement, so `replace(n...n, "")` is a cheap
1694
+ # no-op).
1695
+ #
1696
+ # _@param_ `range` — hard-line indices to replace.
1697
+ #
1698
+ # _@param_ `str` — replacement content.
1699
+ def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
1700
+
1701
+ # Inserts `str` at hard-line index `at`. Equivalent to
1702
+ # `replace(at...at, str)` — a no-removal splice that grows the buffer
1703
+ # by the parsed line count. `at == hard-line count` is allowed and
1704
+ # appends at the end; for that case {#append} / {#add_line} are
1705
+ # usually more idiomatic.
1706
+ #
1707
+ # _@param_ `at` — 0-based hard-line index in `[0, hard-line count]`.
1708
+ #
1709
+ # _@param_ `str` — content to insert.
1710
+ def insert: (Integer at, (String | StyledString)? str) -> void
1711
+
1571
1712
  # Clears the text. Equivalent to `text = ""`.
1572
1713
  def clear: () -> void
1573
1714
 
@@ -1593,41 +1734,164 @@ module Tuile
1593
1734
  # this hook.
1594
1735
  def on_width_changed: () -> void
1595
1736
 
1737
+ # Validates and unpacks a {#replace}-style range argument into
1738
+ # inclusive `[from, to]` line indices. An `Integer` `n` becomes
1739
+ # `[n, n]` (which must point at an existing line — `Integer` is
1740
+ # never insertion sugar). A `Range` is normalized for
1741
+ # `exclude_end?`; `to == from - 1` is a valid empty range
1742
+ # (insertion at `from`), and `from` may equal `size` for
1743
+ # end-insertion. Shared by {#replace} and {Region#replace};
1744
+ # `size` is the buffer or region line count, and `what` is the
1745
+ # entity name woven into error messages.
1746
+ #
1747
+ # _@param_ `range`
1748
+ #
1749
+ # _@param_ `size`
1750
+ #
1751
+ # _@param_ `what`
1752
+ def normalize_replace_range: ((::Range[untyped] | Integer) range, ?Integer size, ?String what) -> [Integer, Integer]
1753
+
1754
+ # Hard-line index where `region` begins in {@hard_lines} — derived
1755
+ # by summing the line counts of all regions that precede it.
1756
+ #
1757
+ # _@param_ `region`
1758
+ def region_start_index: (Region region) -> Integer
1759
+
1760
+ # Joined {StyledString} of the hard lines that `region` owns. Mirrors
1761
+ # {#text} but scoped to one region.
1762
+ #
1763
+ # _@param_ `region`
1764
+ def text_for_region: (Region region) -> StyledString
1765
+
1766
+ # Replaces all of `region`'s hard lines with the parsed content of
1767
+ # `value`. Symmetric with {#text=}, scoped to one region. Empty/nil
1768
+ # content empties the region (no visible blank line). Works on
1769
+ # already-empty regions (insertion at the region's position).
1770
+ #
1771
+ # _@param_ `region`
1772
+ #
1773
+ # _@param_ `value`
1774
+ def set_region_text: (Region region, (String | StyledString)? value) -> void
1775
+
1776
+ # Region-scoped {#replace}. Validates `range` against
1777
+ # `region.line_count`, translates region-relative indices to
1778
+ # absolute buffer indices, splices, and updates the region's count.
1779
+ #
1780
+ # _@param_ `region`
1781
+ #
1782
+ # _@param_ `range`
1783
+ #
1784
+ # _@param_ `str`
1785
+ def replace_in_region: (Region region, (::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
1786
+
1787
+ # Verbatim append into `region`.
1788
+ #
1789
+ # _@param_ `region`
1790
+ #
1791
+ # _@param_ `str`
1792
+ def append_to_region: (Region region, (String | StyledString)? str) -> void
1793
+
1794
+ # Drops the last `n` hard lines from `region`'s tail via
1795
+ # {#splice_hard_lines}. `n` is clamped to the region's current
1796
+ # line count; callers guarantee `n > 0` and the region is
1797
+ # non-empty (the {Region#remove_last_n_lines} guard handles the
1798
+ # no-op cases).
1799
+ #
1800
+ # _@param_ `region`
1801
+ #
1802
+ # _@param_ `n`
1803
+ def remove_last_n_from_region: (Region region, Integer n) -> void
1804
+
1805
+ # Drops `region` from {@regions}: its hard lines are removed via
1806
+ # {#splice_hard_lines}, the handle is detached, and the always-one
1807
+ # default is restored if the removal would have left zero regions.
1808
+ # Skips the rewrap / invalidate work when the region was empty
1809
+ # (the buffer didn't change), but always detaches.
1810
+ #
1811
+ # _@param_ `region`
1812
+ def remove_region: (Region region) -> void
1813
+
1814
+ # Adjusts region line counts after a {@hard_lines} splice that
1815
+ # removed `removed_count` lines at index `from` and inserted
1816
+ # `added_count` in their place. Two passes:
1817
+ #
1818
+ # 1. Subtract each region's overlap with the removed range (uses
1819
+ # the original counts to compute positions). Remember the first
1820
+ # region that lost lines — that's the natural home for the
1821
+ # replacement content.
1822
+ # 2. Credit `added_count` to that region. For pure insertions (no
1823
+ # removal), there's no "first overlapping region" to pick from;
1824
+ # walk regions and credit the latest one starting at `from` (the
1825
+ # boundary tiebreaker matches the spatial-tail-routing of
1826
+ # {#append}). Past-the-end inserts fall back to the tail region.
1827
+ #
1828
+ # _@param_ `from`
1829
+ #
1830
+ # _@param_ `removed_count`
1831
+ #
1832
+ # _@param_ `added_count`
1833
+ def update_region_counts: (Integer from, Integer removed_count, Integer added_count) -> void
1834
+
1596
1835
  # _@return_ — number of visible lines.
1597
1836
  def viewport_lines: () -> Integer
1598
1837
 
1599
1838
  # _@return_ — the max value of {#top_line} for scroll-key clamping.
1600
1839
  def top_line_max: () -> Integer
1601
1840
 
1602
- # Recomputes {@physical_lines} for the current text and wrap width,
1603
- # pre-padding every line to `wrap_width` so {#paintable_line} is just
1604
- # a lookup + optional scrollbar-char append at paint time (and the
1605
- # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
1606
- # memoization, so re-painting on scroll is near-free). Clamps
1607
- # {@top_line} if the new line count puts it out of range.
1841
+ # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
1842
+ # from {@hard_lines}. Called when wrap width changes (which
1843
+ # invalidates every cached row count) and from {#text=} (which
1844
+ # replaces the whole logical model). Mid-buffer mutators splice
1845
+ # incrementally via {#splice_hard_lines} and do *not* go through
1846
+ # here. Clamps {@top_line} if the new line count puts it out of
1847
+ # range.
1608
1848
  def rewrap: () -> void
1609
1849
 
1610
- # Wraps `hard_line` at `width` and appends the padded physical lines
1611
- # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
1612
- # and degenerate `width <= 0` both emit a single {@blank_line} row,
1613
- # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
1614
- # would have produced for those cases.
1850
+ # Wraps `hard_line` at `width` and returns the padded physical rows
1851
+ # alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
1852
+ # run) and degenerate `width <= 0` both emit a single {@blank_line}
1853
+ # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
1854
+ # would have produced.
1615
1855
  #
1616
- # _@param_ `hard_line` — one hard-broken line (no embedded `"\n"`).
1856
+ # _@param_ `hard_line`
1617
1857
  #
1618
1858
  # _@param_ `width`
1619
- def append_physical_lines: (StyledString hard_line, Integer width) -> void
1859
+ def wrap_hard_line: (StyledString hard_line, Integer width) -> [::Array[StyledString], Integer]
1620
1860
 
1621
- # Pops from {@physical_lines} the rows that `hard_line` previously
1622
- # contributed (the inverse of {#append_physical_lines} for the same
1623
- # input). Used by {#append} when extending the last hard line: its
1624
- # old wrapped rows are dropped, then the extended hard line is
1625
- # re-wrapped and appended.
1861
+ # Appends `hard_line` to the tail of {@hard_lines}, updating the
1862
+ # wrap-count cache and {@physical_lines} in lockstep.
1626
1863
  #
1627
1864
  # _@param_ `hard_line`
1628
1865
  #
1629
1866
  # _@param_ `width`
1630
- def drop_physical_rows_for: (StyledString hard_line, Integer width) -> void
1867
+ def push_hard_line: (StyledString hard_line, Integer width) -> void
1868
+
1869
+ # Pops the last hard line, the corresponding cache entry, and the
1870
+ # physical rows that hard line contributed. Returns the popped
1871
+ # hard line.
1872
+ def pop_hard_line: () -> StyledString
1873
+
1874
+ # Splices `new_hard_lines` into the buffer in place of the `count`
1875
+ # hard lines starting at index `from`. Updates {@hard_lines},
1876
+ # {@hard_line_wrap_counts}, and {@physical_lines} consistently.
1877
+ # The starting physical-row offset is computed in O(`from`) integer
1878
+ # adds via the cache — no wraps of preceding hard lines. Wraps are
1879
+ # done only for the new content, so total cost is
1880
+ # `O(from + count + new_hard_lines.sum(&:display_width))`.
1881
+ #
1882
+ # _@param_ `from`
1883
+ #
1884
+ # _@param_ `count` — number of existing hard lines to remove.
1885
+ #
1886
+ # _@param_ `new_hard_lines`
1887
+ def splice_hard_lines: (Integer from, Integer count, ::Array[StyledString] new_hard_lines) -> void
1888
+
1889
+ # _@param_ `idx`
1890
+ #
1891
+ # _@return_ — the {@physical_lines} index where the hard line
1892
+ # at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
1893
+ # wrap-count cache.
1894
+ def phys_offset_at: (Integer idx) -> Integer
1631
1895
 
1632
1896
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
1633
1897
  # default-styled `"\n"` between hard lines. Called from the {#text}
@@ -1691,6 +1955,136 @@ module Tuile
1691
1955
  # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
1692
1956
  # {#append}, so reads are O(1).
1693
1957
  attr_reader content_size: Size
1958
+
1959
+ # A logical section of a {TextView}'s text — a contiguous run of
1960
+ # hard lines the app wants to address as a unit (e.g. an LLM's
1961
+ # "thinking" output vs. its assistant message). The view always
1962
+ # has at least one region, an internal default that owns whatever
1963
+ # hard lines aren't claimed by an app-created region.
1964
+ #
1965
+ # Apps don't construct regions directly; call {TextView#create_region}
1966
+ # to get one. The handle stays valid as long as the region is
1967
+ # attached — i.e. until {TextView#text=} (or {TextView#clear}) wipes
1968
+ # the slate and installs a fresh internal default. Detached regions
1969
+ # raise {RuntimeError} on every mutator and reader.
1970
+ #
1971
+ # A region's position is derived from its sibling order and counts,
1972
+ # so growing or shrinking an earlier region implicitly shifts the
1973
+ # ranges of all later regions. Empty regions occupy zero rows but
1974
+ # still hold a position in the sequence; `region.text = ""` collapses
1975
+ # a region's visible footprint without detaching it. Pre-creating
1976
+ # empty placeholder regions is supported and is the natural pattern
1977
+ # for "I'll fill this in later" layouts.
1978
+ class Region
1979
+ # _@param_ `view` — the owning view (never `nil` at construction).
1980
+ #
1981
+ # _@param_ `line_count` — number of hard lines this region owns.
1982
+ def initialize: (TextView view, ?Integer line_count) -> void
1983
+
1984
+ # _@return_ — `true` while the region is owned by its
1985
+ # {TextView}. Becomes `false` permanently once detached
1986
+ # (typically by {TextView#text=} / {TextView#clear}).
1987
+ def attached?: () -> bool
1988
+
1989
+ # _@return_ — true iff the region owns zero hard lines.
1990
+ # Empty regions render nothing — they still hold a position in
1991
+ # the sequence, so subsequent mutations route to them as usual.
1992
+ def empty?: () -> bool
1993
+
1994
+ # _@return_ — the joined content of just this region's
1995
+ # hard lines. Empty regions return {StyledString::EMPTY}.
1996
+ def text: () -> StyledString
1997
+
1998
+ # Replaces all of this region's hard lines with the parsed content
1999
+ # of `value`. Accepts the same inputs as {TextView#text=}; empty
2000
+ # or `nil` content collapses the region to zero hard lines.
2001
+ #
2002
+ # _@param_ `value`
2003
+ def text=: ((String | StyledString)? value) -> void
2004
+
2005
+ # Verbatim append into this region's tail. Same semantics as
2006
+ # {TextView#append} but scoped to the region: embedded `"\n"`
2007
+ # creates new hard lines within the region, no-leading-newline
2008
+ # input extends the region's last hard line. Empty / `nil` input
2009
+ # is a no-op (but still raises when detached). When the region is
2010
+ # the spatial tail of the view, this uses the incremental
2011
+ # {TextView#append} path; mid-document regions splice the affected
2012
+ # slice of the physical-row buffer (lines outside the region are
2013
+ # not re-wrapped).
2014
+ #
2015
+ # _@param_ `str`
2016
+ def append: ((String | StyledString)? str) -> void
2017
+
2018
+ # _@return_ — the hard-line indices this region currently
2019
+ # occupies — `start...(start + line_count)`. Empty regions
2020
+ # return a degenerate exclusive range at their position (e.g.
2021
+ # `5...5`). The result is computed on each call and so always
2022
+ # reflects sibling mutations.
2023
+ def range: () -> ::Range[untyped]
2024
+
2025
+ # Removes this region from its view. The region's hard lines (if
2026
+ # any) are deleted from the buffer — subsequent regions' ranges
2027
+ # shift up by `line_count` — and the handle detaches permanently.
2028
+ # The view keeps its always-≥1-region invariant: if this was the
2029
+ # only remaining region, a fresh internal default is installed
2030
+ # (the app doesn't get a handle to it; call
2031
+ # {TextView#create_region} again to start tracking).
2032
+ #
2033
+ # Idempotent: calling `remove` on an already-detached region is a
2034
+ # silent no-op (unlike the other mutators, which raise). This
2035
+ # lets cleanup paths blindly call `remove` without first checking
2036
+ # {#attached?}.
2037
+ def remove: () -> void
2038
+
2039
+ # Appends `str` as a new entry in this region: starts a fresh
2040
+ # hard line first (when the region is non-empty), then appends
2041
+ # `str`. Scoped equivalent of {TextView#add_line}. On an empty
2042
+ # region behaves like {#append}.
2043
+ #
2044
+ # _@param_ `str`
2045
+ def add_line: ((String | StyledString)? str) -> void
2046
+
2047
+ # Replaces a contiguous range of this region's hard lines with the
2048
+ # parsed content of `str`. Region-scoped counterpart of
2049
+ # {TextView#replace}: indices are 0-based **within the region**
2050
+ # (so `replace(0, "x")` rewrites the region's first line, not
2051
+ # the buffer's). Same range conventions apply — `Integer`,
2052
+ # inclusive/exclusive `Range`, empty range as insertion at
2053
+ # `begin`, and `begin == line_count` for end-insertion.
2054
+ #
2055
+ # _@param_ `range` — region-relative hard-line indices.
2056
+ #
2057
+ # _@param_ `str` — replacement content.
2058
+ def replace: ((::Range[untyped] | Integer) range, (String | StyledString)? str) -> void
2059
+
2060
+ # Inserts `str` at region-relative hard-line index `at`.
2061
+ # Equivalent to `replace(at...at, str)`. Region-scoped counterpart
2062
+ # of {TextView#insert}; `at == line_count` is allowed and appends
2063
+ # at the region's tail.
2064
+ #
2065
+ # _@param_ `at` — region-relative index in `[0, line_count]`.
2066
+ #
2067
+ # _@param_ `str`
2068
+ def insert: (Integer at, (String | StyledString)? str) -> void
2069
+
2070
+ # Drops the last `n` hard lines from this region's tail.
2071
+ # Subsequent regions' ranges shift up by the number actually
2072
+ # dropped. `n` is clamped to {#line_count}, so passing a large
2073
+ # `n` empties the region — the handle stays attached (use
2074
+ # {#remove} when the goal is to drop the region itself).
2075
+ # `n == 0` and an already-empty region are no-ops.
2076
+ #
2077
+ # _@param_ `n`
2078
+ def remove_last_n_lines: (Integer n) -> void
2079
+
2080
+ def detach!: () -> void
2081
+
2082
+ def check_attached: () -> void
2083
+
2084
+ # _@return_ — number of hard lines this region owns. Safe to
2085
+ # read on a detached region (no error raised).
2086
+ attr_accessor line_count: (Integer | untyped)
2087
+ end
1694
2088
  end
1695
2089
 
1696
2090
  # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
@@ -2032,13 +2426,39 @@ module Tuile
2032
2426
  # Awaits until the event queue is empty (all events have been processed).
2033
2427
  def await_empty: () -> void
2034
2428
 
2429
+ # Schedules `block` to fire on the event-loop thread roughly `fps` times
2430
+ # per second, passing a 0-based monotonically increasing tick counter. Use
2431
+ # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
2432
+ # periodic UI refresh from a background task.
2433
+ #
2434
+ # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
2435
+ # stop it.
2436
+ #
2437
+ # **Errors:** if `block` raises, the {Ticker} cancels itself and the
2438
+ # exception flows through the normal event-loop error path — i.e.
2439
+ # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
2440
+ # broken block from spamming `on_error` at the tick rate.
2441
+ #
2442
+ # Tickers reuse `concurrent-ruby`'s shared timer thread
2443
+ # ({Concurrent}.global_timer_set) — adding more tickers does not add more
2444
+ # threads, just more work on the shared scheduler.
2445
+ #
2446
+ # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`fps: 0.5` ⇒ one tick every two seconds).
2447
+ def tick: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
2448
+
2035
2449
  # Runs the event loop and blocks. Must be run from at most one thread at the
2036
2450
  # same time. Blocks until some thread calls {#stop}. Calls block for all
2037
- # events submitted via {#post}; the block is always called from the thread
2038
- # running this function.
2451
+ # events; the block is always called from the thread running this function.
2039
2452
  #
2040
- # Any exception raised by block is re-thrown, causing this function to
2041
- # terminate.
2453
+ # Any exception raised by the block is re-thrown, causing this function to
2454
+ # terminate. Wrap the block body in `rescue` if you want to handle errors
2455
+ # without tearing down the loop — see {Screen#event_loop} for an example.
2456
+ #
2457
+ # **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
2458
+ # the consumer is responsible for invoking it (typically `event.call`).
2459
+ # Yielding rather than dispatching inline means a raise inside the
2460
+ # submitted block flows through the consumer's `rescue` like any other
2461
+ # event-handler error, instead of bypassing it.
2042
2462
  def run_loop: () ?{ (Object event) -> void } -> void
2043
2463
 
2044
2464
  # _@return_ — true if this thread is running inside an event queue.
@@ -2109,6 +2529,36 @@ module Tuile
2109
2529
  class EmptyQueueEvent
2110
2530
  include Singleton
2111
2531
  end
2532
+
2533
+ # Handle returned by {EventQueue#tick}. Cancel a running ticker via
2534
+ # {#cancel}.
2535
+ #
2536
+ # Internally wraps a `Concurrent::TimerTask` whose firing posts a single
2537
+ # submit-block to the owning {EventQueue}; the user's block therefore
2538
+ # always runs on the event-loop thread and may freely mutate UI. If the
2539
+ # user block raises, the Ticker auto-cancels and the exception is
2540
+ # re-raised so it flows through the loop's normal error handling
2541
+ # ({Screen#on_error} for the default Tuile setup).
2542
+ class Ticker
2543
+ # _@param_ `event_queue` — queue to dispatch tick calls onto.
2544
+ #
2545
+ # _@param_ `fps` — firings per second (positive).
2546
+ #
2547
+ # _@param_ `block` — called as `block.call(tick_count)` on each fire.
2548
+ def initialize: (EventQueue event_queue, Numeric fps, Proc block) -> void
2549
+
2550
+ # _@return_ — true once {#cancel} has been called.
2551
+ def cancelled?: () -> bool
2552
+
2553
+ # Stops the ticker. Idempotent and safe to call from any thread,
2554
+ # including from inside the tick block. Any tick already queued on the
2555
+ # event loop at the moment of cancellation is dropped before the user
2556
+ # block runs.
2557
+ def cancel: () -> void
2558
+
2559
+ # Runs on the event-loop thread.
2560
+ def fire: () -> void
2561
+ end
2112
2562
  end
2113
2563
 
2114
2564
  # Testing only — a screen which doesn't paint anything and pretends that the
@@ -2456,16 +2906,16 @@ module Tuile
2456
2906
  # `underline`). Useful for row-level highlights — the new bg overlays
2457
2907
  # without dropping foreground colors the original styling carried.
2458
2908
  #
2459
- # _@param_ `bg` — background color, in any of the forms accepted by {Style.new}. `nil` clears bg back to the terminal default.
2460
- def with_bg: ((Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2909
+ # _@param_ `bg` — background color, coerced via {Color.coerce}. `nil` clears bg back to the terminal default.
2910
+ def with_bg: ((Color | Symbol | Integer | ::Array[Integer])? bg) -> StyledString
2461
2911
 
2462
2912
  # Returns a new {StyledString} with `fg` applied to every span, preserving
2463
2913
  # each span's text and other style attributes (`bg`, `bold`, `italic`,
2464
2914
  # `underline`). The new fg overlays without dropping background colors or
2465
2915
  # text attributes the original styling carried.
2466
2916
  #
2467
- # _@param_ `fg` — foreground color, in any of the forms accepted by {Style.new}. `nil` clears fg back to the terminal default.
2468
- def with_fg: ((Symbol | Integer | ::Array[Integer])? fg) -> StyledString
2917
+ # _@param_ `fg` — foreground color, coerced via {Color.coerce}. `nil` clears fg back to the terminal default.
2918
+ def with_fg: ((Color | Symbol | Integer | ::Array[Integer])? fg) -> StyledString
2469
2919
 
2470
2920
  def inspect: () -> String
2471
2921
 
@@ -2481,10 +2931,11 @@ module Tuile
2481
2931
 
2482
2932
  # _@param_ `color`
2483
2933
  #
2484
- # _@param_ `base` — base SGR code — 30 for fg, 40 for bg.
2934
+ # _@param_ `target` — `:fg` or `:bg`.
2485
2935
  #
2486
- # _@param_ `ext` extended-color SGR code 38 for fg, 48 for bg.
2487
- def color_codes: ((Symbol | Integer | ::Array[Integer])? color, base: Integer, ext: Integer) -> ::Array[Integer]
2936
+ # _@return_ — SGR codes; `[39]` / `[49]` for the "default" reset
2937
+ # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
2938
+ def color_codes: (Color? color, target: Symbol) -> ::Array[Integer]
2488
2939
 
2489
2940
  # _@param_ `start_or_range`
2490
2941
  #
@@ -2537,18 +2988,16 @@ module Tuile
2537
2988
  class ParseError < Tuile::Error
2538
2989
  end
2539
2990
 
2540
- # A frozen value type describing the visual style of a {Span}.
2541
- #
2542
- # `fg` and `bg` accept:
2543
- # - `nil` the terminal default (SGR 39 / 49)
2544
- # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
2545
- # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
2546
- # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
2991
+ # A frozen value type describing the visual style of a {Span}. Colors are
2992
+ # stored as {Color} instances (or `nil` for the terminal default); inputs
2993
+ # to {.new} and {#merge} are coerced via {Color.coerce}, so the four
2994
+ # accepted color forms — `nil`, Symbol, Integer 0..255, RGB Array work
2995
+ # transparently.
2547
2996
  #
2548
2997
  # @!attribute [r] fg
2549
- # @return [Symbol, Integer, Array<Integer>, nil]
2998
+ # @return [Color, nil]
2550
2999
  # @!attribute [r] bg
2551
- # @return [Symbol, Integer, Array<Integer>, nil]
3000
+ # @return [Color, nil]
2552
3001
  # @!attribute [r] bold
2553
3002
  # @return [Boolean]
2554
3003
  # @!attribute [r] italic
@@ -2556,12 +3005,11 @@ module Tuile
2556
3005
  # @!attribute [r] underline
2557
3006
  # @return [Boolean]
2558
3007
  class Style
2559
- COLOR_SYMBOLS: ::Array[Symbol]
2560
3008
  DEFAULT: Style
2561
3009
 
2562
- # _@param_ `fg`
3010
+ # _@param_ `fg` — coerced via {Color.coerce}.
2563
3011
  #
2564
- # _@param_ `bg`
3012
+ # _@param_ `bg` — coerced via {Color.coerce}.
2565
3013
  #
2566
3014
  # _@param_ `bold`
2567
3015
  #
@@ -2569,18 +3017,13 @@ module Tuile
2569
3017
  #
2570
3018
  # _@param_ `underline`
2571
3019
  def self.new: (
2572
- ?fg: (Symbol | Integer | ::Array[Integer])?,
2573
- ?bg: (Symbol | Integer | ::Array[Integer])?,
3020
+ ?fg: (Color | Symbol | Integer | ::Array[Integer])?,
3021
+ ?bg: (Color | Symbol | Integer | ::Array[Integer])?,
2574
3022
  ?bold: bool,
2575
3023
  ?italic: bool,
2576
3024
  ?underline: bool
2577
3025
  ) -> Style
2578
3026
 
2579
- # _@param_ `color`
2580
- #
2581
- # _@param_ `which`
2582
- def self.validate_color!: (Object color, Symbol which) -> void
2583
-
2584
3027
  def default?: () -> bool
2585
3028
 
2586
3029
  # Returns a new {Style} with the given attributes overridden.
@@ -2588,9 +3031,9 @@ module Tuile
2588
3031
  # _@param_ `overrides`
2589
3032
  def merge: (**::Hash[Symbol, Object] overrides) -> Style
2590
3033
 
2591
- attr_reader fg: (Symbol | Integer | ::Array[Integer])?
3034
+ attr_reader fg: Color?
2592
3035
 
2593
- attr_reader bg: (Symbol | Integer | ::Array[Integer])?
3036
+ attr_reader bg: Color?
2594
3037
 
2595
3038
  attr_reader bold: bool
2596
3039
 
@@ -2656,6 +3099,8 @@ module Tuile
2656
3099
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
2657
3100
  # away and submitted events are thrown away. Intended for testing only.
2658
3101
  class FakeEventQueue
3102
+ def initialize: () -> void
3103
+
2659
3104
  def locked?: () -> bool
2660
3105
 
2661
3106
  def stop: () -> void
@@ -2668,6 +3113,42 @@ module Tuile
2668
3113
 
2669
3114
  # _@param_ `event`
2670
3115
  def post: (Object event) -> void
3116
+
3117
+ # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
3118
+ # only fires when a test calls {#tick_once}. The `fps` argument is
3119
+ # validated the same way the real queue validates it, then discarded —
3120
+ # the fake has no clock, so frame cadence is up to the test.
3121
+ #
3122
+ # _@param_ `fps` — firings per second, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
3123
+ def tick: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
3124
+
3125
+ # Test helper: fires every live ticker's user block once and prunes
3126
+ # cancelled tickers. No-op when no tickers are registered. Pumps once
3127
+ # per call regardless of any ticker's fps — the fake has no clock, so
3128
+ # tests pump N frames by calling this N times.
3129
+ def tick_once: () -> void
3130
+
3131
+ # Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
3132
+ # {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
3133
+ # the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
3134
+ class FakeTicker
3135
+ # _@param_ `block` — called as `block.call(tick_count)` on each {#fire}.
3136
+ def initialize: (Proc block) -> void
3137
+
3138
+ # _@return_ — true once {#cancel} has been called.
3139
+ def cancelled?: () -> bool
3140
+
3141
+ # Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
3142
+ # no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
3143
+ # pass.
3144
+ def cancel: () -> void
3145
+
3146
+ # Invokes the user block with the current tick counter, then advances.
3147
+ # No-op when {#cancelled?}. Typically driven by
3148
+ # {FakeEventQueue#tick_once}; safe to call directly from a test that
3149
+ # wants to drive a single ticker.
3150
+ def fire: () -> void
3151
+ end
2671
3152
  end
2672
3153
 
2673
3154
  # A vertical scrollbar that computes which character to draw at each row.