tuile 0.8.0 → 0.9.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
@@ -535,7 +535,7 @@ module Tuile
535
535
  # string needed to bring a terminal — one that already matches the buffer's
536
536
  # state as of the previous flush — up to date. Only cells that actually
537
537
  # changed are emitted, so nothing flickers regardless of terminal/multiplexer
538
- # synchronized-output support. See `ideas/back-buffer.md`.
538
+ # synchronized-output support.
539
539
  #
540
540
  # Coordinates are 0-based `(x, y)` = `(column, row)`, matching
541
541
  # {Component#rect} and `TTY::Cursor.move_to`.
@@ -567,8 +567,30 @@ module Tuile
567
567
  # nothing for, since the glyph itself advances the cursor two columns).
568
568
  # Overwriting either half of a wide glyph blanks the orphaned half, so the
569
569
  # grid never holds a dangling continuation or a headless one.
570
+ #
571
+ # ## Future direction
572
+ #
573
+ # Components paint through this drawing surface ({#set_line} / {#set_char})
574
+ # without knowing whether it is the one global buffer or a private one — that
575
+ # indirection is deliberate, so per-component back buffers plus a z-order
576
+ # compositor could drop in without touching component code. It is not worth
577
+ # doing yet: the diff already drops unchanged cells from the wire, and an
578
+ # occluded component that didn't change is never repainted at all, so a
579
+ # compositor would only save residual `repaint` CPU. It pays off in exactly
580
+ # one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
581
+ # component on a large screen, where re-rendering the content each repeat is
582
+ # the dominant cost.
570
583
  class Buffer
571
584
  DEFAULT_STYLE: StyledString::Style
585
+ WIDTH_CACHE: ::Hash[String, Integer]
586
+
587
+ # Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
588
+ # lookup instead of calling the gem directly.
589
+ #
590
+ # _@param_ `grapheme` — one grapheme cluster.
591
+ #
592
+ # _@return_ — its display width in columns (0 for combining marks).
593
+ def self.display_width: (String grapheme) -> Integer
572
594
 
573
595
  # _@param_ `size` — grid dimensions in columns × rows.
574
596
  def initialize: (Size size) -> void
@@ -693,6 +715,29 @@ module Tuile
693
715
  # tests asserting styled output.
694
716
  def region_ansi: (Rect rect) -> ::Array[String]
695
717
 
718
+ # Core of {#set_char} with the grapheme's display width already known.
719
+ # {#set_line} computes each width once while advancing the column and passes
720
+ # it straight through, so the paint hot path measures every grapheme exactly
721
+ # once (and that once is a {.display_width} memo read). See {#set_char} for
722
+ # the wide-glyph / clipping / out-of-bounds contract.
723
+ #
724
+ # _@param_ `x` — column.
725
+ #
726
+ # _@param_ `y` — row.
727
+ #
728
+ # _@param_ `grapheme` — one grapheme cluster.
729
+ #
730
+ # _@param_ `w` — `grapheme`'s display width (0, 1, or 2).
731
+ #
732
+ # _@param_ `style`
733
+ def put_char: (
734
+ Integer x,
735
+ Integer y,
736
+ String grapheme,
737
+ Integer w,
738
+ StyledString::Style style
739
+ ) -> void
740
+
696
741
  # (Re)allocates a blank grid of `size` with clean dirty state. Callers
697
742
  # follow with {#mark_all_dirty} when the terminal doesn't match the new
698
743
  # grid — construction and {#resize} both do.
@@ -748,13 +793,26 @@ module Tuile
748
793
  StyledString::Style style
749
794
  ) -> void
750
795
 
751
- # If `(x, y)` is half of a wide glyph, blanks the *other* half, so a write
752
- # that lands on either half doesn't strand the remaining one.
796
+ # If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
797
+ # orphaned left half at `x - 1`. Called before a write lands on `x`, so the
798
+ # wide glyph to the left isn't left headless.
753
799
  #
754
800
  # _@param_ `x` — column
755
801
  #
756
802
  # _@param_ `y` — row
757
- def repair_orphans: (Integer x, Integer y) -> void
803
+ def blank_left_partner: (Integer x, Integer y) -> void
804
+
805
+ # If the cell just right of `(x, y)` is a continuation (the right half of a
806
+ # wide glyph whose origin is `(x, y)`), blanks it. Called before a write
807
+ # lands on `x`, so overwriting a wide origin doesn't strand its continuation.
808
+ # A continuation can only ever belong to the wide glyph immediately to its
809
+ # left, so the empty-grapheme test is exact — and cheaper than re-measuring
810
+ # the origin's width.
811
+ #
812
+ # _@param_ `x` — column
813
+ #
814
+ # _@param_ `y` — row
815
+ def blank_right_partner: (Integer x, Integer y) -> void
758
816
 
759
817
  attr_reader width: Integer
760
818
 
@@ -1076,6 +1134,9 @@ module Tuile
1076
1134
  # _@return_ — the structural root of the component tree.
1077
1135
  attr_reader pane: ScreenPane
1078
1136
 
1137
+ # _@return_ — `:light` or `:dark`
1138
+ attr_reader color_scheme: Symbol
1139
+
1079
1140
  # _@return_ — the back buffer components paint into
1080
1141
  # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
1081
1142
  attr_reader buffer: Buffer
@@ -1141,50 +1202,39 @@ module Tuile
1141
1202
  end
1142
1203
  end
1143
1204
 
1144
- # A sizing policy for a slot whose position is managed by a parent
1145
- # component (e.g. {Component::Window#footer}). Resolves one dimension at a
1146
- # time via {#resolve}, so the same value works for widths and heights.
1205
+ # A width/height ratio, each a float in `0.0..1.0` the single relational
1206
+ # sizing primitive in Tuile. It exists for exactly one job: sizing a
1207
+ # {Component::Popup} against the screen. A popup has no siblings competing for
1208
+ # space and no rectangle in a tiled layout, so "half the screen, centered" is
1209
+ # the sensible default, and that wants a ratio rather than a hard-coded cell
1210
+ # count that would be wrong on the next terminal size.
1147
1211
  #
1148
- # Three policies exist:
1212
+ # Tiled components are *not* sized this way: their parent computes explicit
1213
+ # integer rects in its own `rect=` and hands them down. `Fraction` is
1214
+ # deliberately scoped to {Component::Popup#size=} and is not a general layout
1215
+ # primitive.
1149
1216
  #
1150
- # - {FILL} take everything the slot offers;
1151
- # - {WRAP_CONTENT} — take the component's natural extent (its
1152
- # {Component#content_size}), clamped to the slot;
1153
- # - {.fixed} — take exactly the given number of cells, clamped to the slot.
1217
+ # Resolve it against a reference {Size} (the screen) to get concrete integer
1218
+ # cells:
1154
1219
  #
1155
- # Note that {WRAP_CONTENT} only makes sense for components that report a
1156
- # natural {Component#content_size} ({Component::Label}, {Component::Button},
1157
- # {Component::List}, …). Input components ({Component::TextField} et al.)
1158
- # report {Size::ZERO}, so a wrap-content slot collapses to zero width —
1159
- # i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
1220
+ # Fraction::HALF.resolve(Size.new(80, 24)) # => 40x12
1160
1221
  #
1161
- # @!attribute [r] mode
1162
- # @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
1163
- # @!attribute [r] amount
1164
- # @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
1165
- class Sizing
1166
- FILL: Sizing
1167
- WRAP_CONTENT: Sizing
1222
+ # Integer arguments are coerced to float, so `Fraction.new(1, 1) == FULL`.
1223
+ class Fraction
1224
+ HALF: Fraction
1225
+ FULL: Fraction
1168
1226
 
1169
- # _@param_ `amount` — the number of cells to occupy; 0 or greater.
1227
+ # _@param_ `width` — fraction of the reference width, `0.0..1.0`.
1170
1228
  #
1171
- # _@return_a fixed-size policy.
1172
- def self.fixed: (Integer amount) -> Sizing
1229
+ # _@param_ `height` fraction of the reference height, `0.0..1.0`.
1230
+ def initialize: (width: Numeric, height: Numeric) -> void
1173
1231
 
1174
- # Resolves one dimension of a slot.
1175
- #
1176
- # _@param_ `available` cells the slot offers; 0 or greater.
1177
- #
1178
- # _@param_ `content` — the component's natural extent on this axis (one dimension of its {Component#content_size}).
1232
+ # Resolves this fraction against a reference size, rounding each axis to the
1233
+ # nearest cell and flooring at 1 — so a fraction never yields a zero-size
1234
+ # result on a tiny terminal.
1179
1235
  #
1180
- # _@return_ — the resolved extent, always in `0..available`.
1181
- def resolve: (Integer available, Integer content) -> Integer
1182
-
1183
- # _@return_ — `:fill`, `:wrap_content` or `:fixed`.
1184
- attr_reader mode: Symbol
1185
-
1186
- # _@return_ — the cell count for `:fixed`; `nil` otherwise.
1187
- attr_reader amount: Integer?
1236
+ # _@param_ `reference` — the size to take a fraction of (usually the screen).
1237
+ def resolve: (Size reference) -> Size
1188
1238
  end
1189
1239
 
1190
1240
  # A UI component which is positioned on the screen and draws characters into
@@ -1324,20 +1374,6 @@ module Tuile
1324
1374
  # _@param_ `child` — the just-detached child.
1325
1375
  def on_child_removed: (Component child) -> void
1326
1376
 
1327
- # Called by a child component whose {#content_size} just changed (fired
1328
- # from the child's {#content_size=}). Does nothing by default — a plain
1329
- # container is not size-coupled to its children. Containers that derive
1330
- # their own natural size or child layout from a child's natural size
1331
- # override this (e.g. {Component::Window} re-lays-out a
1332
- # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
1333
- # {Component::Popup} re-self-sizes). If the receiver's own
1334
- # {#content_size} changes as a consequence, its {#content_size=} notifies
1335
- # *its* parent in turn — so the event bubbles exactly as far as geometry
1336
- # keeps changing, and stops where it doesn't.
1337
- #
1338
- # _@param_ `child` — the resized direct child.
1339
- def on_child_content_size_changed: (Component child) -> void
1340
-
1341
1377
  # Where the hardware terminal cursor should sit when this component is the
1342
1378
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
1343
1379
  # {Screen} positions the hardware cursor after each repaint cycle by
@@ -1351,18 +1387,6 @@ module Tuile
1351
1387
  # topmost popup. Empty by default; override to advertise shortcuts.
1352
1388
  def keyboard_hint: () -> String
1353
1389
 
1354
- # Advice to a wrapping {Component::Popup} on the minimum height this
1355
- # component prefers to occupy when shown in a popup. `nil` (the default)
1356
- # means no preference — the popup uses its own {Component::Popup#min_height}.
1357
- # Override in a content component that should not collapse to a couple of
1358
- # rows when sparse (e.g. {Component::LogWindow}).
1359
- def popup_min_height: () -> Integer?
1360
-
1361
- # Advice to a wrapping {Component::Popup} on the maximum height this
1362
- # component may grow to when shown in a popup. `nil` (the default) means
1363
- # no preference — the popup uses its own {Component::Popup#max_height}.
1364
- def popup_max_height: () -> Integer?
1365
-
1366
1390
  # Called whenever the component width changes. Does nothing by default.
1367
1391
  def on_width_changed: () -> void
1368
1392
 
@@ -1421,15 +1445,6 @@ module Tuile
1421
1445
  # {#on_theme_changed=} listener keeps firing.
1422
1446
  attr_accessor on_theme_changed: Proc?
1423
1447
 
1424
- # The {Size} big enough to show the entire component contents without
1425
- # scrolling. Plain components have no intrinsic content and report
1426
- # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
1427
- # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
1428
- # their mutators, so reads are O(1). Used by callers like
1429
- # {Component::Popup} to auto-size to whatever content was assigned,
1430
- # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
1431
- attr_accessor content_size: Size
1432
-
1433
1448
  # A scrollable list of items with cursor support.
1434
1449
  #
1435
1450
  # Items are modeled as {StyledString}s and painted directly into the
@@ -1540,19 +1555,6 @@ module Tuile
1540
1555
  # is one, so the list snaps to the bottom on first paint.
1541
1556
  def on_width_changed: () -> void
1542
1557
 
1543
- # Natural size from scratch: longest line's display width plus the two
1544
- # single-space gutters {#pad_to_row} adds, × line count. An empty list
1545
- # is {Size::ZERO} (no gutters for no content).
1546
- def compute_content_size: () -> Size
1547
-
1548
- # Incremental {#content_size} update for appends: folds just the
1549
- # appended lines into the running maximum, keeping {#add_lines}
1550
- # O(appended) instead of re-scanning the whole list (LogWindow appends
1551
- # a line per log statement).
1552
- #
1553
- # _@param_ `appended` — the just-appended lines (already concatenated onto {@lines}).
1554
- def grow_content_size: (::Array[StyledString] appended) -> void
1555
-
1556
1558
  # Coerces and flattens a list of input entries into trimmed
1557
1559
  # {StyledString} lines. Each entry becomes a {StyledString} (String
1558
1560
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -1850,7 +1852,8 @@ module Tuile
1850
1852
  # embedded ANSI is honored) or a {StyledString} directly. {#text}
1851
1853
  # always returns the {StyledString}.
1852
1854
  class Label < Component
1853
- def initialize: () -> void
1855
+ # _@param_ `text` — initial text, coerced the same way {#text=} coerces it (a `String` is parsed via {StyledString.parse}; `nil` is an empty label). Equivalent to constructing empty and assigning {#text=}.
1856
+ def initialize: (?(String | StyledString)? text) -> void
1854
1857
 
1855
1858
  # Paints the text into {#rect}.
1856
1859
  #
@@ -1862,11 +1865,6 @@ module Tuile
1862
1865
 
1863
1866
  def on_width_changed: () -> void
1864
1867
 
1865
- # Natural size: longest hard-line's display width × number of hard
1866
- # lines. Computed on the *unclipped* text — sizing is intrinsic to the
1867
- # content, not the viewport. Empty text yields {Size::ZERO}.
1868
- def compute_content_size: () -> Size
1869
-
1870
1868
  # Recomputes {@clipped_lines} for the current text and rect width.
1871
1869
  # Each line is ellipsized to fit and padded with trailing spaces out to
1872
1870
  # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
@@ -1895,18 +1893,30 @@ module Tuile
1895
1893
 
1896
1894
  # An overlay that wraps any {Component} as its content. Popup itself
1897
1895
  # paints nothing — it's a transparent host that handles its lifecycle
1898
- # ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
1899
- # wrapped content.
1896
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
1897
+ # {#size} the {Screen} applies.
1898
+ #
1899
+ # The popup does *not* size itself to its content. Its box is declared by
1900
+ # {#size} — a {Fraction} (resolved against the screen every layout pass, so
1901
+ # it tracks resize) or an absolute {Size} (clamped to the screen). The
1902
+ # default is {Fraction::HALF}: half the screen, centered. The wrapped
1903
+ # content then fills that box and handles its own overflow by wrapping and
1904
+ # scrolling, so use content that can — a {Component::TextView} or
1905
+ # {Component::TextArea} — for anything longer than fits. A
1906
+ # {Component::Label} only truncates.
1900
1907
  #
1901
1908
  # Modal by default: it centers on the screen, grabs focus, eats keys, and
1902
1909
  # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
1903
- # that floats above the content (still painted on top, still auto-sized)
1904
- # without taking focus or capturing input — the caller positions it (via
1905
- # {#rect=}) and drives it from app code. That is the building block for an
1906
- # autocomplete/slash-command list anchored to a {Component::TextField} or
1907
- # {Component::TextArea} caret: typing keeps focus (and the cursor) in the
1908
- # input, an {Component::TextInput#on_change} listener refills the list, and
1909
- # an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
1910
+ # that floats above the content (still painted on top) without taking focus
1911
+ # or capturing input — the caller positions it (via {#rect=}) and drives it
1912
+ # from app code. That is the building block for an autocomplete/slash-command
1913
+ # list anchored to a {Component::TextField} or {Component::TextArea} caret:
1914
+ # typing keeps focus (and the cursor) in the input, an
1915
+ # {Component::TextInput#on_change} listener refills the list, and an
1916
+ # {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
1917
+ # Such a caller owns the list data, so it sizes the overlay itself
1918
+ # (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
1919
+ # caller-decides, top-down.
1910
1920
  #
1911
1921
  # The wrapped content fills the popup's full {#rect}; if you want a frame
1912
1922
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -1926,10 +1936,12 @@ module Tuile
1926
1936
  class Popup < Component
1927
1937
  include Tuile::Component::HasContent
1928
1938
 
1929
- # _@param_ `content` — initial content; can be set later via {#content=}. When provided here, the popup auto-sizes to fit.
1939
+ # _@param_ `content` — initial content; can be set later via {#content=}. The content fills the popup's {#rect}; it does not determine the popup's size.
1930
1940
  #
1931
1941
  # _@param_ `modal` — true (default) for a centered, focus-grabbing, input-capturing modal; false for a non-modal overlay the caller positions and drives (see the class docs).
1932
- def initialize: (?content: Component?, ?modal: bool) -> void
1942
+ #
1943
+ # _@param_ `size` — the popup's size, applied top-down. A {Fraction} is resolved against the screen each layout pass; a {Size} is clamped to the screen. Defaults to {Fraction::HALF}.
1944
+ def initialize: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> void
1933
1945
 
1934
1946
  # _@return_ — whether this popup is modal. See {#initialize}.
1935
1947
  def modal?: () -> bool
@@ -1947,17 +1959,20 @@ module Tuile
1947
1959
  # _@param_ `new_rect`
1948
1960
  def rect=: (Rect new_rect) -> void
1949
1961
 
1950
- # Mounts this popup on the {Screen}. Recomputes the popup's size from
1951
- # the current content first, so reopening a popup whose content has
1952
- # grown or shrunk while closed picks up the new size.
1962
+ # Mounts this popup on the {Screen}, re-resolving its {#size} against the
1963
+ # current screen first.
1953
1964
  def open: () -> void
1954
1965
 
1955
1966
  # Constructs and opens a popup in one call.
1956
1967
  #
1957
1968
  # _@param_ `content`
1958
1969
  #
1970
+ # _@param_ `modal` — see {#initialize}.
1971
+ #
1972
+ # _@param_ `size` — see {#initialize}.
1973
+ #
1959
1974
  # _@return_ — the opened popup.
1960
- def self.open: (?content: Component?) -> Popup
1975
+ def self.open: (?content: Component?, ?modal: bool, ?size: (Size | Fraction)) -> Popup
1961
1976
 
1962
1977
  # Removes this popup from the {Screen}. No-op if not currently open.
1963
1978
  def close: () -> void
@@ -1965,38 +1980,21 @@ module Tuile
1965
1980
  # _@return_ — true if this popup is currently mounted on the screen.
1966
1981
  def open?: () -> bool
1967
1982
 
1968
- # Recenters the popup on the screen, preserving its current width/height.
1969
- # Called automatically by the screen's layout pass and by {#content=}
1970
- # when the popup is open.
1971
- def center: () -> void
1972
-
1973
- # _@return_ — max height the popup will grow to fit its content.
1974
- # Defers to the content's {Component#popup_max_height} advice when it
1975
- # gives one, else defaults to 12. Override in a subclass to allow
1976
- # taller popups regardless of content.
1977
- def max_height: () -> Integer
1978
-
1979
- # _@return_ — min height the popup occupies even when its content
1980
- # is shorter. Defers to the content's {Component#popup_min_height}
1981
- # advice when it gives one, else defaults to 0 (size purely to
1982
- # content) — so a {Component::LogWindow} stays readable while only a
1983
- # few lines are in without callers wiring up a subclass. Override in a
1984
- # subclass to keep any popup from collapsing to a couple of rows.
1985
- # Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
1986
- def min_height: () -> Integer
1987
-
1988
- # Sets the popup's content and auto-sizes the popup to fit.
1983
+ # Re-resolves {#size} against the current screen and repositions the popup
1984
+ # *itself* (this is not laying out content — the popup's own rect): a
1985
+ # modal popup recenters; a non-modal overlay keeps its caller-assigned
1986
+ # top-left (only its size follows the screen). Called on {#open}, on
1987
+ # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
1988
+ # SIGWINCH).
1989
1989
  #
1990
- # _@param_ `new_content`
1991
- def content=: (Component? new_content) -> void
1990
+ # The final rect is computed and assigned in one step rather than sizing
1991
+ # at the origin and then centering: the intermediate origin rect rarely
1992
+ # covers the previous one, which would make {#rect=}'s shrink/move
1993
+ # detection fire a full repaint on every resize.
1994
+ def reposition: () -> void
1992
1995
 
1993
- # Re-sizes (and recenters, when open) whenever the wrapped content's
1994
- # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
1995
- # `add_line`, or a nested {Window} whose own content grew (the window
1996
- # recomputes its {Component#content_size} and the change bubbles here).
1997
- #
1998
- # _@param_ `_child`
1999
- def on_child_content_size_changed: (Component _child) -> void
1996
+ # Recenters the popup on the screen, preserving its current width/height.
1997
+ def center: () -> void
2000
1998
 
2001
1999
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
2002
2000
  def keyboard_hint: () -> String
@@ -2015,21 +2013,15 @@ module Tuile
2015
2013
  # _@param_ `content`
2016
2014
  def layout: (Component content) -> void
2017
2015
 
2018
- # Recompute width/height from {#content}'s natural size and recenter
2019
- # if currently open. Called whenever content is (re)assigned.
2020
- #
2021
- # Computes the final (centered) rect and assigns it in one step rather
2022
- # than positioning at the origin and then centering: the intermediate
2023
- # origin rect rarely covers the previous one, which would make
2024
- # {#rect=}'s shrink/move detection fire a full repaint on every resize.
2025
- def update_rect: () -> void
2026
-
2027
2016
  # _@param_ `event`
2028
2017
  def handle_mouse: (MouseEvent event) -> void
2029
2018
 
2030
2019
  def children: () -> ::Array[Component]
2031
2020
 
2032
2021
  def on_focus: () -> void
2022
+
2023
+ # _@return_ — the popup's declared size. See {#size=}.
2024
+ attr_accessor size: (Size | Fraction)
2033
2025
  end
2034
2026
 
2035
2027
  # A clickable button. Activated by Enter, Space, or a left mouse click;
@@ -2042,7 +2034,7 @@ module Tuile
2042
2034
  # {Component#handle_mouse}.
2043
2035
  #
2044
2036
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
2045
- # show `[ caption ]`; {#content_size} reports that natural width.
2037
+ # show `[ caption ]` that natural width is `caption.length + 4`.
2046
2038
  class Button < Component
2047
2039
  # _@param_ `caption` — the button's label.
2048
2040
  def initialize: (?String caption) -> void
@@ -2059,9 +2051,6 @@ module Tuile
2059
2051
 
2060
2052
  def repaint: () -> void
2061
2053
 
2062
- # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
2063
- def natural_size: () -> Size
2064
-
2065
2054
  # _@return_ — the button's label.
2066
2055
  attr_accessor caption: String
2067
2056
 
@@ -2103,8 +2092,6 @@ module Tuile
2103
2092
  # _@param_ `child`
2104
2093
  def remove: (Component child) -> void
2105
2094
 
2106
- def content_size: () -> Size
2107
-
2108
2095
  # Dispatches the event to the child under the mouse cursor.
2109
2096
  #
2110
2097
  # _@param_ `event`
@@ -2146,22 +2133,6 @@ module Tuile
2146
2133
  # _@param_ `value`
2147
2134
  def scrollbar=: (bool value) -> void
2148
2135
 
2149
- # Sets the new content. Also recomputes the window's natural size.
2150
- #
2151
- # _@param_ `new_content`
2152
- def content=: (Component? new_content) -> void
2153
-
2154
- # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
2155
- # size changes, and folds a content resize into the window's own
2156
- # natural size (whose change then bubbles to the window's parent — e.g.
2157
- # a {Popup} re-self-sizes). The footer deliberately does *not*
2158
- # participate in the window's {#content_size}: it is decoration
2159
- # overlaying the border, and must not drive the window's size — if it
2160
- # doesn't fit, it is clipped to the inner width.
2161
- #
2162
- # _@param_ `child`
2163
- def on_child_content_size_changed: (Component child) -> void
2164
-
2165
2136
  # Fully repaints the window: both frame and contents.
2166
2137
  #
2167
2138
  # Window deliberately paints over its entire rect (border around the
@@ -2185,33 +2156,36 @@ module Tuile
2185
2156
  # active the whole border is drawn in {Theme#active_border_color}.
2186
2157
  def repaint_border: () -> void
2187
2158
 
2159
+ # Builds the bottom border line. The corners take the border color; the
2160
+ # interior is plain dashes when a {#footer} component occupies the row
2161
+ # (it overpaints them) or when there's no chrome, otherwise it carries
2162
+ # {#footer_text} embedded at its own width — keeping the text's own
2163
+ # styling — with dashes filling the remainder up to the inner width.
2164
+ #
2165
+ # _@param_ `inner_w` — the border's interior width.
2166
+ #
2167
+ # _@param_ `fg` — the active-border color, or nil when inactive.
2168
+ def bottom_border: (Integer inner_w, Color? fg) -> StyledString
2169
+
2188
2170
  # The caption text as it appears in the rendered border, including the
2189
2171
  # shortcut prefix when {#key_shortcut} is set.
2190
2172
  def frame_caption: () -> String
2191
2173
 
2192
- # Recomputes the window's natural size: content's natural size (or the
2193
- # caption, whichever is wider) plus the 2-character border. The footer
2194
- # is deliberately excluded — see {#on_child_content_size_changed}. A
2195
- # window with no content or caption sizes to `Size.new(2, 2)` (bare
2196
- # border).
2197
- def update_content_size: () -> void
2198
-
2199
- # Positions the footer over the bottom border row, with its width
2200
- # resolved by {#footer_sizing} against the inner width. A
2201
- # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
2202
- # rect — i.e. it is invisible, as if never assigned.
2174
+ # Positions the footer over the bottom border row, spanning the full
2175
+ # inner width (the only dimension a bottom-row widget needs — the window
2176
+ # already knows it).
2203
2177
  def layout_footer: () -> void
2204
2178
 
2205
2179
  def on_focus: () -> void
2206
2180
 
2207
- # _@return_ — optional component overlaying the bottom border
2208
- # row.
2181
+ # _@return_ — optional focusable component occupying the
2182
+ # bottom border row, always spanning the full inner width.
2209
2183
  attr_accessor footer: Component?
2210
2184
 
2211
- # _@return_ — how the footer's width is computed from the window's
2212
- # inner width; defaults to {Sizing::FILL} (the footer spans the full
2213
- # inner width). The footer's height is always 1 (the border row).
2214
- attr_accessor footer_sizing: Sizing
2185
+ # _@return_ — optional chrome embedded into the bottom border
2186
+ # line, mirroring {#caption} on the top line. Empty by default; hidden
2187
+ # whenever a {#footer} component is present.
2188
+ attr_accessor footer_text: (StyledString | String)?
2215
2189
 
2216
2190
  # _@return_ — the current caption, empty by default.
2217
2191
  attr_accessor caption: String
@@ -2660,9 +2634,6 @@ module Tuile
2660
2634
  # reader when the cache is cold. Cost is O(total spans).
2661
2635
  def build_text: () -> StyledString
2662
2636
 
2663
- # _@return_ — {#content_size} computed from {@hard_lines}.
2664
- def compute_content_size: () -> Size
2665
-
2666
2637
  # _@return_ — column width available for wrapped text — viewport
2667
2638
  # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
2668
2639
  # width is non-positive, which yields a degenerate "no wrap" result.
@@ -2721,13 +2692,6 @@ module Tuile
2721
2692
  # bottom and tailing resumes. Default `false`.
2722
2693
  attr_accessor auto_scroll: bool
2723
2694
 
2724
- # _@return_ — longest hard-line's display width × number of hard
2725
- # lines. Reported on the *unwrapped* text — wrap-aware sizing would
2726
- # be circular (width depends on width). Empty text returns
2727
- # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
2728
- # {#append}, so reads are O(1).
2729
- attr_reader content_size: Size
2730
-
2731
2695
  # A logical section of a {TextView}'s text — a contiguous run of
2732
2696
  # hard lines the app wants to address as a unit (e.g. an LLM's
2733
2697
  # "thinking" output vs. its assistant message). The view always
@@ -2872,18 +2836,6 @@ module Tuile
2872
2836
  # _@param_ `caption`
2873
2837
  def initialize: (?String caption) -> void
2874
2838
 
2875
- # Keep the log pane at least half the screen tall even when only a few
2876
- # lines have been logged: a {Component::Popup} sizes to its content, which
2877
- # would collapse a near-empty log to two or three rows. Advice consulted
2878
- # by {Component::Popup#min_height} when this window is a popup's content.
2879
- def popup_min_height: () -> Integer
2880
-
2881
- # Let a busy log grow past the popup's base 12-row cap (up to the
2882
- # 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
2883
- # diagnostic stream stays scrollable in a tall window. Advice consulted
2884
- # by {Component::Popup#max_height} when this window is a popup's content.
2885
- def popup_max_height: () -> Integer
2886
-
2887
2839
  # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
2888
2840
  #
2889
2841
  # _@param_ `string` — the line (or multiple lines) to log.
@@ -3142,8 +3094,10 @@ module Tuile
3142
3094
  #
3143
3095
  # _@param_ `lines` — the content, may contain formatting.
3144
3096
  #
3097
+ # _@param_ `size` — the popup's size, applied top-down; the list wraps and scrolls within it. Defaults to {Fraction::HALF}.
3098
+ #
3145
3099
  # _@return_ — the opened popup.
3146
- def self.open: (String caption, ::Array[String] lines) -> Popup
3100
+ def self.open: (String caption, ::Array[String] lines, ?size: (Size | Fraction)) -> Popup
3147
3101
  end
3148
3102
 
3149
3103
  # A {Window} that lists options identified by single keyboard keys, asks
@@ -3296,10 +3250,13 @@ module Tuile
3296
3250
  # Awaits until the event queue is empty (all events have been processed).
3297
3251
  def await_empty: () -> void
3298
3252
 
3299
- # Schedules `block` to fire on the event-loop thread roughly `fps` times
3300
- # per second, passing a 0-based monotonically increasing tick counter. Use
3301
- # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
3302
- # periodic UI refresh from a background task.
3253
+ # Schedules `block` to fire on the event-loop thread every `seconds`,
3254
+ # passing a 0-based monotonically increasing tick counter. The interval is
3255
+ # in **seconds** the conventional scheduling unit (`sleep`,
3256
+ # `Concurrent::TimerTask#execution_interval`, …) so `tick(0.2)` fires five
3257
+ # times a second. Use it for periodic UI refresh from a background task
3258
+ # (poll a status, redraw a clock). For animation, where frames-per-second
3259
+ # is the natural unit, {#tick_fps} reads better.
3303
3260
  #
3304
3261
  # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
3305
3262
  # stop it.
@@ -3313,8 +3270,15 @@ module Tuile
3313
3270
  # ({Concurrent}.global_timer_set) — adding more tickers does not add more
3314
3271
  # threads, just more work on the shared scheduler.
3315
3272
  #
3316
- # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`fps: 0.5` ⇒ one tick every two seconds).
3317
- def tick: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
3273
+ # _@param_ `seconds` — interval between firings, must be positive. Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
3274
+ def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> Ticker
3275
+
3276
+ # Frames-per-second convenience over {#tick}: `tick_fps(15)` is exactly
3277
+ # `tick(1.0 / 15)`. Reads naturally for animation (a `/-\|` spinner, a
3278
+ # progress pulse) where you think in frames, not intervals.
3279
+ #
3280
+ # _@param_ `fps` — firings per second, must be positive. Fractional values are fine (`tick_fps(0.5)` ⇒ one firing every two seconds).
3281
+ def tick_fps: (Numeric fps) ?{ (Integer tick) -> void } -> Ticker
3318
3282
 
3319
3283
  # Runs the event loop and blocks. Must be run from at most one thread at the
3320
3284
  # same time. Blocks until some thread calls {#stop}. Calls block for all
@@ -3435,10 +3399,10 @@ module Tuile
3435
3399
  class Ticker
3436
3400
  # _@param_ `event_queue` — queue to dispatch tick calls onto.
3437
3401
  #
3438
- # _@param_ `fps` — firings per second (positive).
3402
+ # _@param_ `interval` — seconds between firings (positive).
3439
3403
  #
3440
3404
  # _@param_ `block` — called as `block.call(tick_count)` on each fire.
3441
- def initialize: (EventQueue event_queue, Numeric fps, Proc block) -> void
3405
+ def initialize: (EventQueue event_queue, Numeric interval, Proc block) -> void
3442
3406
 
3443
3407
  # _@return_ — true once {#cancel} has been called.
3444
3408
  def cancelled?: () -> bool
@@ -3617,8 +3581,10 @@ module Tuile
3617
3581
  def rect=: (Rect new_rect) -> void
3618
3582
 
3619
3583
  # Lays out content (full pane minus the bottom row) and the status bar
3620
- # (bottom row). Modal popups self-recenter via {Component::Popup#center};
3621
- # non-modal overlays keep the position their owner assigned.
3584
+ # (bottom row). Each popup re-resolves its {Component::Popup#size} against
3585
+ # the new screen via {Component::Popup#reposition} so a {Fraction} size
3586
+ # tracks resize — repositioning itself (modal popups recenter; non-modal
3587
+ # overlays keep the top-left their owner assigned).
3622
3588
  def layout: () -> void
3623
3589
 
3624
3590
  # Pane paints nothing itself; its children paint over the entire rect.
@@ -4122,12 +4088,18 @@ module Tuile
4122
4088
  def post: (Object event) -> void
4123
4089
 
4124
4090
  # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
4125
- # only fires when a test calls {#tick_once}. The `fps` argument is
4091
+ # only fires when a test calls {#tick_once}. The `seconds` argument is
4126
4092
  # validated the same way the real queue validates it, then discarded —
4127
4093
  # the fake has no clock, so frame cadence is up to the test.
4128
4094
  #
4129
- # _@param_ `fps` — firings per second, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
4130
- def tick: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
4095
+ # _@param_ `seconds` — interval between firings, must be positive. Validated for parity with {EventQueue#tick}; otherwise unused.
4096
+ def tick: (Numeric seconds) ?{ (Integer tick) -> void } -> FakeTicker
4097
+
4098
+ # Mirrors {EventQueue#tick_fps}: validates `fps` for parity, then delegates
4099
+ # to {#tick} (the fake discards the interval regardless).
4100
+ #
4101
+ # _@param_ `fps` — firings per second, must be positive.
4102
+ def tick_fps: (Numeric fps) ?{ (Integer tick) -> void } -> FakeTicker
4131
4103
 
4132
4104
  # Test helper: fires every live ticker's user block once and prunes
4133
4105
  # cancelled tickers. No-op when no tickers are registered. Pumps once