Orientation for coding agents working on Tuile. Read this before making changes; the architecture has invariants that are not obvious from any single file.
A small component-oriented terminal-UI framework built on top of the TTY
toolkit (tty-cursor, tty-screen, tty-logger). Apps build
a tree of {Tuile::Component}s under a singleton {Tuile::Screen}; the
screen runs an event loop, dispatches keys/mouse, and repaints
invalidated components in batch. The name is French for "roof tile" —
small pieces that compose into a larger whole.
The gem was extracted from
virtui's lib/ttyui/ in 0.1.0, so
references to virtui in commit history are expected.
The project is hosted at https://github.com/mvysny/tuile. The underlying philosophy — composing UIs from small, encapsulated components ("boxes within boxes") that talk via listeners and data providers — is described in https://mvysny.github.io/component-oriented-programming/. Tuile is that approach applied to a TTY.
Tuile's prose lives in five kinds of document, each with a distinct audience, length, and what it is allowed to own. Knowing which kind you're writing keeps any one file from becoming the mixed bag the README used to be (concepts + reference + quickstart fused). Match the target's kind before you write a line.
| Kind | Audience | Scope & length | Owns |
|---|---|---|---|
ideas/*.md |
you + the author | dense, technical, provisional | design rationale in flight; transient (see graduation below) |
book (book/, cover-to-cover) |
a learner, reading in order | verbose, narrative, order-dependent | concepts and the why |
| rdoc / YARD (source headers) | someone at the API | dense, per-symbol, standalone | the precise technical workings of each class/method |
| README | a prospective user at the front door | thin: positioning + quickstart + a couple of examples + pointers | luring the reader in and routing them onward |
| AGENTS.md (this file) | a contributor / coding agent | invariant-focused | "what you must not break" |
| DECISIONS.md | a contributor asking "why this way?" | one coherent, mutable entry per live decision | the why-we-chose, incl. roads not taken |
Rules that make six documents survivable:
- Single source of truth per fact. Each fact has one home; the others link to it rather than restating it. The book owns concepts; rdoc owns the per-symbol technical truth; the README owns pointers + quickstart; AGENTS.md owns invariants; DECISIONS.md owns the why we chose it and not the alternative. When tempted to explain something twice, link instead.
- But don't over-link into unreadability. A tiny, load-bearing restatement is fine when it saves the reader a jump — e.g. "Tuile is single-threaded by intent; see the book for why." The test: repeat the one-line fact, defer the explanation. Ten hops to assemble one mental picture is worse than one repeated sentence.
- rdoc defers only motivation to the book, never usage. It is browsed standalone on rubydoc.info by someone who won't click into a book, so it must carry the complete local technical truth of the symbol. "See the book for why layout is top-down" is fine; "see the book for what this method does" is not.
- The book grows organically — no target chapter count. Add a chapter when a concept has earned one (the threading model + background jobs; layout and why it's simple; theming), not to fill an outline. Tuile's conceptual surface is small; a short book is a finished book.
- README stays a front door. Positioning (what Tuile is, the alternatives comparison), install, one hello-world, a couple of example pointers, then links to the book and rdoc. Concepts migrate to the book; per-component API migrates to rdoc.
The graduation pipeline. ideas/*.md is transient by design — a
scratchpad "for the two of us," not user docs. It is still a vital part
of the mechanism: it's where rationale is born. On graduation — once the
idea is implemented and stable — it moves to its final destinations and
the ideas/ note is retired: the user-facing half graduates into the
book (rewritten for the reader), the invariant / must-not-break half
graduates into AGENTS.md, and the decision half — the choice made and
the alternatives rejected — graduates into DECISIONS.md (which may already
carry an entry recorded when the decision was made, ahead of
implementation). The worked example is the top-down
layout overhaul (the C64 "why simple layouting is enough" argument): it
was designed in ideas/simpler-layouting.md, then on completion its
reader-half graduated into book chapter 3, its invariant-half into the
"Layout is top-down" section below, and the idea note was retired — the
pipeline run start to finish.
lib/tuile.rb gem entry point: requires, Zeitwerk loader
lib/tuile/version.rb VERSION constant
lib/tuile/keys.rb Tuile::Keys (key constants + .getkey)
lib/tuile/{point,size,rect}.rb geometry value types (Data.define)
lib/tuile/mouse_event.rb Tuile::MouseEvent (parses xterm sequences)
lib/tuile/ansi.rb Tuile::Ansi (SGR constants — RESET)
lib/tuile/color.rb Tuile::Color (named/256-palette/RGB; .palette/.rgb/.hex factories, .coerce, xterm-named palette constants)
lib/tuile/styled_string.rb Tuile::StyledString (span-based styled text: parse/slice/wrap/truncate)
lib/tuile/theme.rb Tuile::Theme (semantic color tokens; DARK/LIGHT, current one at Screen#theme)
lib/tuile/theme_def.rb Tuile::ThemeDef (app theme definition: dark/light Theme pair at Screen#theme_def; ThemeDef.default seeds new screens)
lib/tuile/terminal_background.rb Tuile::TerminalBackground.detect (OSC 11 + COLORFGBG light/dark probe)
lib/tuile/event_queue.rb Tuile::EventQueue + nested events
lib/tuile/fake_event_queue.rb synchronous test double
lib/tuile/component.rb Tuile::Component base
lib/tuile/component/has_content.rb mixin for one-child containers
lib/tuile/component/has_value.rb mixin: the value seam (value/empty?/clear/on_value_change) + focusable? default; included by AbstractStringField & ComboBox
lib/tuile/component/label.rb Tuile::Component::Label
lib/tuile/component/button.rb Tuile::Component::Button
lib/tuile/component/layout.rb Tuile::Component::Layout (+ Absolute)
lib/tuile/component/list.rb Tuile::Component::List (+ Cursor / None / Limited)
lib/tuile/component/abstract_string_field.rb Tuile::Component::AbstractStringField (abstract; String-valued base of TextField/TextArea)
lib/tuile/component/text_field.rb Tuile::Component::TextField
lib/tuile/component/text_area.rb Tuile::Component::TextArea (multi-line editor)
lib/tuile/component/text_view.rb Tuile::Component::TextView (read-only scrollable wrapped prose)
lib/tuile/component/combo_box.rb Tuile::Component::ComboBox (+ Menu) — filtering dropdown; typed value via items + item_label
lib/tuile/component/window.rb Tuile::Component::Window (border + content slot)
lib/tuile/component/popup.rb modal overlay, self-sizing from content, ESC/q closes
lib/tuile/component/info_window.rb window-of-static-lines convenience (tiled or popup)
lib/tuile/component/picker_window.rb single-keystroke option picker
lib/tuile/component/log_window.rb Tuile::Component::LogWindow + IO adapter for tty-logger
lib/tuile/vertical_scroll_bar.rb character-grid scrollbar (rendering helper, not a Component)
lib/tuile/screen.rb Tuile::Screen (singleton runtime)
lib/tuile/fake_screen.rb in-memory test double
lib/tuile/screen_pane.rb structural root of the component tree (kept at root, owned by Screen)
spec/tuile/**/<file>_spec.rb mirrors lib/tuile/**/<file>.rb — one spec
per source file (mostly; version.rb has none,
and a few internals like has_content / fake_*
are still uncovered)
spec/examples/<file>_spec.rb PTY-based system tests for examples/ scripts
spec/spec_helper.rb requires "tuile", uses minitest assertions
sig/tuile.rbs RBS signatures (sord-generated; `rake sig` regenerates)
Zeitwerk loads everything from lib/. Source files are wrapped in
module Tuile and don't require_relative each other — Zeitwerk
resolves constants on first reference.
Tuile::Screen is a process-singleton. It owns the event queue, the
"UI lock", invalidation set, terminal IO, and a single
{Tuile::ScreenPane}. All UI lives under that pane:
ScreenPane (structural root, never paints anything)
├── content (tiled Component, optional — usually a Layout::Absolute)
├── popups[0..n] (modal stack, last is topmost)
└── status_bar (Component::Label, bottom row)
Putting popups under the same parent as content means focus traversal,
Component#attached?, and on_child_removed work uniformly without
special-casing popups.
Every UI piece is a {Tuile::Component} with parent / children,
rect, active?, focused, key_shortcut. Two derived APIs:
depth/root— distance to root and root pointeron_tree { |c| … }— pre-order traversal of self + descendantsattached?— true iffroot == screen.pane
children is read-only by convention (the array must not be mutated by
callers; containers expose add / remove / content= / footer= to
swap and reparent).
Components do not paint immediately, and they do not write
escape sequences to the terminal. They call invalidate (a protected
method that records self in Screen#@invalidated), and when they do
paint they write styled cells into Screen#buffer (a {Tuile::Buffer}
back buffer) via set_line / fill / set_char — never screen.print.
After an event-loop tick drains the queue, Screen#repaint walks the
invalidated set:
- Partition into tiled-tree and popup-tree (popup-tree = anything
reachable from
pane.popups). - Sort tiled by depth (parent before child).
- If any tiled were invalidated, re-paint all popup subtrees on top in stacking order — popups deliberately overdraw content, no clipping. Overdraw into the buffer is free: only net-visible changes reach the wire.
- Flush the buffer —
Buffer#flushemits the minimal diff (only cells that changed since the last flush) plus the cursor position, wrapped in one synchronized-output batch ({Ansi::SYNC_BEGIN}). This is what makes repaint flicker-free on any terminal regardless of mode-2026 support: an unchanged cell is never rewritten. The cursor lands on the focused component'scursor_position(hidden when none).
Screen#emit is the single sink for the assembled frame; {Tuile::FakeScreen}
overrides it (and print) to capture into prints instead of stdout, and
exposes the populated buffer for assertions (row_text / row_ansi /
region_text / region_ansi / cell).
Invariants you must preserve:
- A component must not draw outside its
rect. - It is not required to
fully tile its rect: {Tuile::Component#repaint}'s default clears the
background and re-invalidates children whenever the direct children
leave gaps in
rect(e.g. a form layout with mixed-width fields). Subclasses shouldsuperfrom their ownrepaintto inherit that behavior; only components that paint their entire rect themselves (currently {Tuile::Component::Window} for border-plus-content, and {Tuile::Component::List} for explicit row-by-row paint) opt out. - Don't call
Screen#repaintdirectly from a component; justinvalidateand let the loop coalesce.
The event queue is single-threaded. All UI mutations — rect=,
active=, content=, add_line, invalidate, screen.focused= —
must run on the thread that owns Screen#run_event_loop.
Background threads must marshal work back via
screen.event_queue.submit { … }. Most UI methods call
screen.check_locked, which raises "UI lock not held" if you violate
this. {Tuile::FakeScreen} short-circuits the check so tests can mutate
freely.
Screen#@@instance is a class variable — the singleton survives
sub-classing (FakeScreen < Screen).
screen.focused = component walks parent upward and marks the entire
chain root → focused as active?, deactivating everything else. The
flag is universal: every component carries it, but only components on
the current focus chain ever have it set true. Then component.on_focus
fires and the status-bar hint is rebuilt. Setting nil deactivates
everything.
Component#focusable? is independent of the active flag: it gates
becoming a focus target. Click-to-focus (Component#handle_mouse) and
the on_focus cascade in HasContent / Layout only forward focus to
focusable components, so clicking a {Tuile::Component::Label} doesn't
hijack focus from the surrounding window.
Component#handle_key first checks for a key_shortcut match anywhere
in its subtree — unless the focused component owns the hardware cursor
(i.e. its cursor_position is non-nil, e.g. a {Tuile::Component::TextField}
the user is typing into). That suppression is what lets text fields
swallow printable keys without sibling shortcuts hijacking them.
{Tuile::Component::Layout#handle_key} falls back to dispatching to its
active child. {Tuile::Component::Window} delegates to content when content is
active, and to footer when footer is active.
When a popup closes, focus must land somewhere reasonable. The order implemented in {Tuile::ScreenPane#on_child_removed}:
- The now-topmost remaining popup, if any.
- The focus snapshotted just before this popup was added — if it's
still attached. Snapshots are stored in
@popup_prior_focus. - The tiled
content. nil.
If a non-topmost popup closes while focus is in the topmost, focus is
left untouched, but @popup_prior_focus is rewritten so any popup that
remembered a focus inside the just-closed popup forwards to the
closing popup's own prior. This prevents stranded references to
detached components when popups close out of order. {Tuile::ScreenPane}
spec has the regression cases — read them before refactoring this.
Terminal resize is plumbed through the event queue, not handled
directly off the signal. EventQueue#trap_winch installs the sole
SIGWINCH handler and posts an EventQueue::TTYSizeEvent (carrying
the new width / height). Screen#event_loop catches it, assigns
the event to Screen#size, and runs layout, which resizes
pane to (0, 0, size.width, size.height), invalidates the entire
tree, and repaints.
React to resize via the normal invalidation path — i.e. let your
parent reassign your rect, and recompute child layout in rect=.
Do not add your own Signal.trap("WINCH") in component code; only
one handler can win, and EventQueue owns it. If a component needs to
read the current viewport directly, use Screen.instance.size (seeded
at construction from TTYSizeEvent.create, so it's valid before the
first WINCH ever fires).
A component never advertises how big it wants to be; its parent
assigns its rect. There is no content_size, no Sizing policy
type, no min/preferred/max, and no shrink-to-fit. A container
computes its children's rectangles in plain Ruby (in its rect=
override) and hands them down; content fills or scrolls within the rect
it's given. The book's chapter 3 is the long-form why.
This was an overhaul (v0.9.0): the earlier eager bottom-up
content_size channel — the reader, the protected content_size=
setter, and the on_child_content_size_changed parent hook — was
deleted along with Sizing and Popup content-auto-sizing. Do not
reintroduce it. Re-grow rule: if a genuine need to size against
content returns, bring it back as an optional, read-only, caller-side
query — "measure this so I can compute a rect and set it top-down" —
never as an automatic channel the framework consults. That keeps
measurement opt-in and top-down, which is what stops it re-becoming
min/preferred/max. (TextView specs probe @hard_lines.size
directly for this reason — there is deliberately no public size getter.)
Two consumers that used to sit on that channel are now top-down:
- {Tuile::Component::Popup} sizes itself from
Popup#size=(Size | Fraction, defaultFraction::HALF, resolved against the screen each layout) — never from its content. - The {Tuile::Component::Window} bottom border carries one of two
purpose-fit members rather than one sized slot:
footer_text=(a {Tuile::StyledString}, border chrome embedded into the bottom border line at its own width with dashes filling the remainder, clipped to the inner width — mirrorscaptionon top, not a component, not focusable) andfooter=(a focusable component always spanning the full inner width of the bottom row — the search-field case). Precedence: afooter=component present occupies the row and hidesfooter_text; absent,footer_textembeds. A bottom-row widget is always FILL by construction; the footer is decoration overlaying the border and never drives window size (one that doesn't fit is clipped).
Built-in components read semantic colors from Screen#theme
({Tuile::Theme}, a frozen value type). The concepts and usage —
accents-only, dark/light, Color-only construction, custom tokens,
ThemeDef pairing, live OS flips — are the book (ch6) and the Theme /
Screen#theme= / #theme_def= / #detect_scheme rdoc. Invariants:
- Read theme values at paint time; never cache them in an ivar. A
theme=restyles everything through one invalidate-all pass, so a cached accent strands on the old scheme. (Also why the inherent-bg widgets re-read their well each paint — see Background color.) - No global bg/fg token. Non-accent cells inherit the terminal default
(the light-theme strategy); a theme carries accents only (
D-bg-inherit). - Startup scheme detection must stay in
Screen#initialize. The OSC 11 reply lands on stdin, which the key thread owns once the loop runs — so it cannot move later. {FakeScreen} overrides the privatedetect_schemeto pin:dark, keeping specs deterministic and off the test runner's TTY. - A custom
ThemeDefsurvives OS appearance flips; a baretheme=is transient. Live flips ride mode 2031 and re-picktheme_def.for(scheme); a one-offtheme=doesn't participate and reverts on the next flip. on_theme_changedis for app-rendered content. A {Tuile::StyledString} inLabel#text/List#lines/TextView#textbakes its colors at construction, and only the app knows which were theme-derived (vs. inherent to the data, e.g. log-level colors) — so the app rebuilds them in the hook (subclassessuper; stock assemblies set theon_theme_changed=proc). Built-in chrome andTheme::Refbackgrounds skip it — they resolve live.- Don't make {Tuile::StyledString} theme-aware to dodge that hook. It's a
pure frozen value type with a
parse(to_ansi(x)) == xround-trip and zeroScreendependency; a theme ref would break all three. - Specs: an app's spec_helper reassigns
ThemeDef.defaultonce so everyScreen.fakeresolves its custom tokens; gem specs that touch it must restoreThemeDef::DEFAULTinafter.
Component#bg_color (a Color, a Theme::Ref, or nil; default nil)
is an opt-in background, inherited down the tree and resolved at paint
time: effective_bg_color reads @bg_color (resolving a Theme::Ref
against screen.theme), else the parent's, else nil (terminal default).
Never cache it — same reason as theme accents. The rationale and
roads-not-taken live in DECISIONS.md (D-bg-inherit, D-theme-ref); the
invariants that must not break:
- Terminal cells are opaque; inheritance is resolve-at-paint, not
paint-order.
Buffer#write_cellstores a span'sStylewholesale, so a glyph withbg: nilwrites terminal-default and clobbers any fill underneath — "parent fills, child paints on top" does not yield inherited text. The effective bg must be baked into every painted cell. - Self-painters paint through
Component#draw_line/#draw_char, notscreen.buffer.set_*. Those wrappers applyeffective_bg_colorviaStyledString#under_bg(fill-unset: sets bg only on spans that have none — distinct fromwith_bg, which overrides every span). This is the single choke point; bypassing it drops inheritance. Current self-painters routed through it: {Component::List}, {Component::Label}, {Component::TextView}, {Component::Button}, {Component::Window}'s border. - Three camps, don't mix them. (1) Gap-leavers (default
repaint→clear_background): served automatically — the fill useseffective_bg_color. (2) Content self-painters: route throughdraw_line/draw_char(above). (3) Inherent-bg widgets ({Component::TextField}/{Component::TextArea} wells): opt out — they paint an explicit bg over their whole rect, sounder_bgno-ops on them and the tint can't bleed in. They must not setbg_color, and must keep reading their well from the theme at paint time — storing it would cache a theme value in an ivar (see Theme). bg_color=invalidates the whole subtree (on_tree), not just self — inheriting descendants must re-resolve. Over-invalidation is free on the wire (the flush emits only changed cells); pruning the invalidation set is a deferred optimization, not a correctness need.- A
Theme::Refbg is live-resolved and rides the theme-change repaint.bg_color = Theme.ref(:token)stores the ref and re-resolves it againstscreen.themeeach paint, so it tracks flips with noon_theme_changedhook. It reaches a built-in chrome or acustomtoken (chrome wins a name clash) but never adds one, so it can't reintroduce the banned global bg/fg token (D-theme-ref); the setter validates eagerly (KeyError at assignment). It stays current only becausetheme=invalidates the whole tree — if that is ever pruned,Theme::Refbackgrounds must still be invalidated on theme change (guarded inscreen_spec). nilmeans inherit-upward, not a sentinel. NoINHERITconstant; the terminal default is the root of the chain. There is deliberately no opt-out ("force terminal-default despite a tinted ancestor") — add a:default/Color::TERMINAL_DEFAULTsentinel only if a real need appears.Label#bgpredates this and is a distinct knob (override-all viawith_bg, vsbg_color's fill-unset inheritance). They compose (a set#bgbakes explicit span bgs thatunder_bgleaves alone, so it wins locally); the overlap is a known wart pending a consolidation decision.
Input components share the {Component::HasValue} value seam
(value / value= / empty? / clear + on_value_change). Why it's
deliberately thin, and typed rather than String-only: DECISIONS.md
D-has-value. Per-symbol usage and the AbstractStringField aliasing: their rdoc.
Invariants:
- A component's value is typed, not stringly.
ComboBox#valueis the selected item (of whatever typeitemsholds), never the display string; a text input's value is its text. Model-mapping is a layer above, never field state. - ComboBox keeps two values, never conflated.
valueis the committed selection (changes only on Enter/click — the soleon_value_changetrigger); the field'stextis a transient query that filters and reverts to the value's label on ESC/blur. Selection is by list index (items[idx]) so identity survives duplicate labels. - The
@suppressing_filterguard. Any programmatic write to the field's text (avalue=, a commit's label write-back, a revert) must set it behind this flag, or the field'son_changerefill springs the dropdown open; it also parks the caret at the end. ComboBox::Menuis non-focusable. Focus and caret stay in the field; the combo forwards keys and a click selects without stealing focus.
Point, Size, Rect are Data.define value types (frozen,
structural equality). Rect#contains? uses half-open edges
(x >= left && x < left + width — right/bottom are exclusive).
Rect#empty? includes width==0 and width<0.
spec/tuile/**/<file>_spec.rb mirrors lib/tuile/**/<file>.rb (so
lib/tuile/component/window.rb ↔ spec/tuile/component/window_spec.rb).
Specs are
wrapped in module Tuile so unqualified references (Component,
Screen, …) resolve via lexical scope. Assertions are minitest-style
(assert, assert_equal, assert_raises, refute_*) wired through
rspec-core via config.expect_with :minitest.
spec/examples/ holds end-to-end tests for the runnable scripts under
examples/: each spawns its target script in a pseudo-TTY via
PTY.spawn, waits for a known glyph to confirm the first paint landed,
sends a key, and asserts a clean exit. Linux/macOS only — Ruby's stdlib
PTY isn't on Windows. They run as part of rake spec.
Pace the keys in a PTY test — never write a burst. {Keys.getkey}
reads one key, and on a leading \e gulps a fixed 5 bytes to complete
an escape sequence (see its rdoc). So bytes that arrive in the same
read burst get merged into one bogus "key": write("\eq") is read as one
unknown sequence, not ESC then q; write("\e[B\e[B") glues two Down
arrows; a trailing write("q") sent with others can be swallowed into a
partial sequence. A real human types with millisecond gaps, so this only
bites tests (and pasted input). The fix is to send one key/sequence at a
time and force a round-trip between them — readpartial a frame, or a
short sleep — exactly as the sampler PTY test walks the nav list. In
particular ESC-then-key: write "\e", drain the repaint it triggers,
then write the next key. This is inherent terminal ESC-ambiguity, not a
bug to "fix" in getkey — a timeout-based reader would add latency to
every ESC; don't.
The Screen.fake / Screen.close before/after pair is the standard
setup — it installs a {Tuile::FakeScreen} (160×50, in-memory prints
buffer, no terminal IO, no UI lock) and resets the singleton between
examples. Without it, code that touches Screen.instance will see
state leaked from the previous test.
For painted content, assert against Screen.instance.buffer: after a
component.repaint (or Screen#repaint), the painted cells live in the
buffer. Use buffer.region_text(rect) (Array of plain rows) /
buffer.region_ansi(rect) (Array of ANSI-rendered rows, byte-identical to
the old per-row print) scoped to the component's rect, or cell(x, y)
for a single cell's grapheme / style. Screen.instance.prints now holds
only cursor/housekeeping escapes and the assembled frame string (cursor +
sync wrapper) — assert prints.join against it for cursor behavior, not
for content. Screen.instance.invalidated?(c) and invalidated_clear are
the test-only hooks for verifying invalidation.
FakeEventQueue runs submitted blocks synchronously and discards
posted events; it lets specs drive the system without a real loop.
bundle exec rake check # full pre-commit suite: spec + rubocop + sig (also the default task)
bundle exec rake spec # run all specs (unit + examples)
bundle exec rspec spec/tuile/list_spec.rb # run one file
bundle exec rspec spec/tuile/list_spec.rb:42 # run a specific example
COVERAGE=true bundle exec rake spec # specs + SimpleCov report at coverage/index.html
bundle exec rubocop # lint (Metrics/* size cops violate freely; we accept those)
bundle exec rake sig # regenerate + validate sig/tuile.rbs via sord (commit it if it changes)
bundle exec rake benchmark # display-width / repaint micro-benchmarksrake check (== the default rake) is what to run before committing —
it is the same spec + rubocop + sig the release gate re-runs. rake sig can dirty the tree by regenerating sig/tuile.rbs; commit the result.
The release procedure itself lives in RELEASING.md.
Coverage at 0.1.0 sits at ~97% line / ~88% branch. The remaining gap is
in real-terminal runtime paths (Screen#run_event_loop,
EventQueue#start_key_thread, the WINCH trap) that need raw-mode stdin
and a real signal handler — not worth mocking. There is no CI gate;
treat the number as a signal, not a target.
- Calling UI from a background thread. Use
screen.event_queue.submit { … }. Thecheck_lockedraise is a guardrail, not a feature — fix the call site, don't bypass it. - Mutating
children/popupsarrays. Always go throughadd/remove/add_popup/remove_popup/content=/footer=. They handle parent pointers, focus repair, and invalidation. - Expecting
repaintto happen synchronously. It happens once per event-loop tick (whenEmptyQueueEventfires). Specs trigger it viaScreen#repaintdirectly; production code should not. - Adding
require 'tuile/foo'inside source files. Zeitwerk resolves it; explicit requires bypass the loader and create dual-load hazards. The onlyrequires that belong insidelib/tuile/files are gem-level deps you genuinely need at file-load time — and most of those are already hoisted intolib/tuile.rb. - Adding a second top-level constant to a
lib/tuile/foo.rbfile. Zeitwerk expectsfoo.rbto define exactly one top-levelTuile::Foo. Nested constants inside it (Foo::Bar) are fine. If you have a sibling top-level class, give it its own file. - Logging from gem code. Use
Tuile.logger, not$logorTTY::Logger. The default isLogger.new(IO::NULL), so the gem is silent unless the host app setsTuile.logger = .... The accessor targets the stdlibLoggerinterface —TTY::Loggerduck-types it, so virtui can pass its existing logger straight in. To route logs into a {Tuile::Component::LogWindow}, construct the host's logger withComponent::LogWindow::IO.new(window)as its output. - Touching
@@instancedirectly. UseScreen.instance/Screen.close/Screen.fake. The class variable is part of the singleton-survives-subclassing contract.