Class: Tuile::Component

Inherits:
Object
  • Object
show all
Extended by:
Final
Defined in:
lib/tuile/component.rb,
lib/tuile/component/list.rb,
lib/tuile/component/slot.rb,
lib/tuile/component/tabs.rb,
lib/tuile/component/label.rb,
lib/tuile/component/popup.rb,
lib/tuile/component/button.rb,
lib/tuile/component/layout.rb,
lib/tuile/component/select.rb,
lib/tuile/component/window.rb,
lib/tuile/component/overlay.rb,
lib/tuile/component/checkbox.rb,
lib/tuile/component/menu_bar.rb,
lib/tuile/component/combo_box.rb,
lib/tuile/component/has_value.rb,
lib/tuile/component/tab_sheet.rb,
lib/tuile/component/text_area.rb,
lib/tuile/component/text_view.rb,
lib/tuile/component/date_field.rb,
lib/tuile/component/layout/box.rb,
lib/tuile/component/log_window.rb,
lib/tuile/component/text_field.rb,
lib/tuile/component/time_field.rb,
lib/tuile/component/float_field.rb,
lib/tuile/component/has_caption.rb,
lib/tuile/component/has_content.rb,
lib/tuile/component/info_window.rb,
lib/tuile/component/radio_group.rb,
lib/tuile/component/notification.rb,
lib/tuile/component/progress_bar.rb,
lib/tuile/component/has_bad_input.rb,
lib/tuile/component/integer_field.rb,
lib/tuile/component/list_dropdown.rb,
lib/tuile/component/log_text_view.rb,
lib/tuile/component/picker_window.rb,
lib/tuile/component/checkbox_group.rb,
lib/tuile/component/confirm_window.rb,
lib/tuile/component/has_validation.rb,
lib/tuile/component/password_field.rb,
lib/tuile/component/date_time_field.rb,
lib/tuile/component/has_placeholder.rb,
lib/tuile/component/layout/vertical.rb,
lib/tuile/component/menu_bar/cascade.rb,
lib/tuile/component/big_decimal_field.rb,
lib/tuile/component/layout/horizontal.rb,
lib/tuile/component/abstract_string_field.rb,
lib/tuile/component/text_area/wrapped_text.rb,
lib/tuile/component/abstract_wrapping_field.rb,
sig/tuile.rbs

Overview

A UI component which is positioned on the screen and draws characters into its bounding rectangle (in #repaint).

Painting is gated by attachment: a detached component (one whose #root isn't Screen#pane) is never enqueued for repaint via #invalidate, and any stale invalidation entries are filtered out at drain time. Subclasses can paint freely in #repaint without re-asserting attachment.

Handlers and listener slots

Two families, told apart by the =:

class Trimmed < Component::TextField
def handle_blur                  # handle_ — the override point
  super
  self.text = text.strip
end
end

label.on_theme_changed = -> {  }   # on_…= — the listener slot

What a handler returns is per hook, declared in its own rdoc. Only the ones a dispatcher routes answer at all — #handle_key?, #handle_text_input_key?, MenuBar#handle_mnemonic? — where true means "I took this, stop bubbling". The rest, #handle_paste included, return void.

An override calls super, even where the base body is empty: that is what lets a hook grow an on_foo= slot without breaking you. The one carve-out is #handle_child_removed, whose base does real work and whose overrides replace it. D_handler_naming carries the argument.

Direct Known Subclasses

ScreenPane

Defined Under Namespace

Modules: HasBadInput, HasCaption, HasContent, HasPlaceholder, HasValidation, HasValue Classes: AbstractStringField, AbstractWrappingField, BigDecimalField, Button, Checkbox, CheckboxGroup, ComboBox, ConfirmWindow, DateField, DateTimeField, FloatField, InfoWindow, IntegerField, Label, Layout, List, ListDropdown, LogTextView, LogWindow, MenuBar, Notification, Overlay, PasswordField, PickerWindow, Popup, ProgressBar, RadioGroup, Select, Slot, TabSheet, Tabs, TextArea, TextField, TextView, TimeField, Window

Constant Summary collapse

BG_STATES =

The states a background may be keyed by. Closed and framework-defined: a key is added when Tuile grows the state, never to let an app invent one.

Returns:

  • (Array<Symbol>)
%i[normal active].freeze
BG_INHERIT =

Assign to #bg_color to say "I contribute no background of my own" — resolution skips this component's #default_bg_color and takes whatever surrounds it. CSS's background: inherit, and the reason a widget with a well can be made to sit flush in a tinted panel:

field.bg_color = Component::BG_INHERIT   # no well; take the pane's tint

Distinct from nil, which falls through to #default_bg_color first. There is deliberately no counterpart forcing the terminal default despite a tinted ancestor (D_bg_inherit).

Returns:

  • (Symbol)
:inherit

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Final

final, final_methods, verify_final!

Constructor Details

#initializeComponent

Returns a new instance of Component.



42
43
44
45
46
47
48
49
50
51
52
# File 'lib/tuile/component.rb', line 42

def initialize
  Component.verify_final!(self.class)
  @rect = Rect.new(0, 0, 0, 0)
  @visible = true
  @active = false
  @on_theme_changed = nil
  @on_locale_changed = nil
  @bg_color = nil
  @children = []
  @id = nil
end

Instance Attribute Details

#bg_colorColor, ...

@return — this component's own background — the value as set, so a Theme::Ref comes back unresolved and a state map comes back a Hash; nil when unset, in which case the component falls back to #default_bg_color and then to its parent. #effective_bg_color is the resolved Tuile::Color to paint.

Returns:



238
239
240
# File 'lib/tuile/component.rb', line 238

def bg_color
  @bg_color
end

#children::Array[Component] (readonly)

Child components in paint order (siblings left to right, earlier ones painted under later ones), maintained by #add_child / #remove_child.

Final: a container that computed this from its own slots would disagree with the parent pointers #attached? walks, silently (D_final_tree). Named slots are readers over this array (Window#footer), never a second copy of it; for a swappable region hold a Slot.

@return — child components. Must not be mutated by callers! May be empty.

Returns:



489
490
491
# File 'lib/tuile/component.rb', line 489

def children
  @children
end

#idSymbol?

A tag for finding this component again — nothing paints it, and the framework never reads it:

field.id = :name
Testing.get(id: :name).value = "Zaphod"

Nothing enforces uniqueness, here or anywhere in production; two components may carry the same id, and only Testing.get — which raises on an ambiguous match — will ever say so.

Returns:

  • (Symbol, nil)


64
65
66
# File 'lib/tuile/component.rb', line 64

def id
  @id
end

#on_locale_changedProc?

Optional zero-arg listener fired by the base #handle_locale_changed — the composition-style alternative to overriding the method, for an app that rendered a date or a number into a stock component:

label.on_locale_changed = -> { label.text = due_date.strftime(fmt) }

Returns:

  • (Proc, nil)


550
551
552
# File 'lib/tuile/component.rb', line 550

def on_locale_changed
  @on_locale_changed
end

#on_theme_changedProc?

Optional zero-arg listener fired by the base #handle_theme_changed — the composition-style alternative to overriding the method, for apps that assemble stock components rather than subclass:

label.on_theme_changed = -> { label.text = render_status_line }

Returns:

  • (Proc, nil)


541
542
543
# File 'lib/tuile/component.rb', line 541

def on_theme_changed
  @on_theme_changed
end

#parentComponent?

Final: the parent chain is one half of the tree's single source of truth — #attached? walks it while every subtree walk uses #children, so a derived pointer leaves a component attached but never painted, with nothing raising (D_final_tree). Reparent through #add_child / #remove_child / #detach_child.

@return — the parent component or nil if the component has no parent.

Returns:



471
472
473
# File 'lib/tuile/component.rb', line 471

def parent
  @parent
end

#rectRect

@return — the rectangle the component occupies on screen.

Returns:



78
79
80
# File 'lib/tuile/component.rb', line 78

def rect
  @rect
end

Class Method Details

.finalSymbol, ::Array[Symbol]

Marks each of names non-overridable by subclasses. Declare them in one call near the top of the class; the names may be forward references, since nothing is resolved until Final#verify_final! runs.

final :parent, :children, :add_child

It reads better as a keyword on the definition (final def foo, which parses — def hands back its name), but don't: YARD has no handler for the macro, so a decorated def loses its parameter list and sord then generates def foo: () -> void into sig/, silently.

@param names — method names.

@return — the names — a bare Symbol when exactly one was given.

Parameters:

  • names (::Array[(Symbol | ::Array[Symbol])])

Returns:

  • (Symbol, ::Array[Symbol])


3659
# File 'sig/tuile.rbs', line 3659

def self.final: (*::Array[(Symbol | ::Array[Symbol])] names) -> (Symbol | ::Array[Symbol])

.final_methods::Array[Symbol]

@return — the methods marked Final#final on this class.

Returns:

  • (::Array[Symbol])


3662
# File 'sig/tuile.rbs', line 3662

def self.final_methods: () -> ::Array[Symbol]

.verify_final!void

This method returns an undefined value.

Raises unless klass inherits every Final#final_methods entry from this class. Memoized per class, so a construction-time call costs one hash lookup after the first instance.

@param klass — the class being instantiated.

Parameters:

  • klass (Class)


3669
# File 'sig/tuile.rbs', line 3669

def self.verify_final!: (Class klass) -> void

Instance Method Details

#active=(active) ⇒ void

This method returns an undefined value.

@param active — true if active. Set by Screen#focused= as it marks the focus chain (root → focused); not meant to be called directly.

Parameters:

  • active (Boolean)


438
439
440
441
442
443
444
# File 'lib/tuile/component.rb', line 438

def active=(active)
  active = active ? true : false
  return unless @active != active

  @active = active
  invalidate
end

#active?Boolean

@return — true if the component is on the active chain — i.e. it is the focused component or an ancestor of it. Set by Screen#focused=.

Returns:

  • (Boolean)


433
# File 'lib/tuile/component.rb', line 433

def active? = @active

#add_child(child, at: nil) ⇒ void

This method returns an undefined value.

Adopts child: places it in #children and wires its parent pointer.

add_child(content, at: 0)   # the tiled layer, painted beneath …
add_child(@footer)          # … and chrome appended, painted over it

Final: one of the three mutators that write #children and the parent pointer in the same call, which is what keeps them in agreement.

@param child — must not already have a parent.

@param at — index to insert at; appends when nil.

Parameters:

  • child (Component)
  • at: (Integer, nil) (defaults to: nil)


650
651
652
653
654
655
656
# File 'lib/tuile/component.rb', line 650

def add_child(child, at: nil)
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
  raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?

  at.nil? ? @children.push(child) : @children.insert(at, child)
  child.parent = self
end

#ambient_bg_colorColor?

What surrounds this component — an app-set #bg_color, else whatever the parent paints where this component is not. Skips #default_bg_color, the one thing that colors this widget's own surface, which is what makes it the right answer for the dead tail outside #extent.

Returns:



1089
1090
1091
1092
1093
1094
# File 'lib/tuile/component.rb', line 1089

def ambient_bg_color
  own = resolve_bg_color(@bg_color)
  return parent&.effective_bg_color if own.nil? || own == BG_INHERIT

  own
end

#attached?Boolean

Whether this component's tree is mounted on a UI, ScreenPane being the root of every displayed tree.

A property of the parent chain alone — no Screen is consulted, so assembling a tree needs no screen in the process at all:

layout = Component::Layout::Absolute.new
layout.add(label)      # legal with no Screen; neither is attached yet
screen.content = layout # now both are

@return — true if #root is a ScreenPane.

Returns:

  • (Boolean)


563
# File 'lib/tuile/component.rb', line 563

def attached? = root.is_a?(ScreenPane)

#children_tile_rect?Boolean

Whether direct children fully tile #rect. Used by the default #repaint to decide whether the framework needs to wipe gaps.

Approximated by area: sum of (non-empty) child areas vs the parent's area. Cheap, and correct as long as siblings don't overlap each other — which Tuile already requires (no clipping in the tiled tree). Children with empty rects contribute zero, since they paint nothing.

A hidden child contributes zero for the same reason, and that is what erases it: its cells become a gap this component then blanks.

Returns:

  • (Boolean)


908
909
910
911
# File 'lib/tuile/component.rb', line 908

def children_tile_rect?
  total = children.sum { |c| c.rect.empty? || !c.visible? ? 0 : c.rect.width * c.rect.height }
  total >= rect.width * rect.height
end

#clear_background(area = rect, bg = effective_bg_color) ⇒ void

This method returns an undefined value.

Clears the background: fills every cell with a blank in the #effective_bg_color (the terminal default when none is inherited).

A component that paints part of its #rect itself passes just the part it doesn't — blanking a cell it is about to overwrite anyway makes that cell dirty, and Buffer#flush then re-emits it even though nothing visibly changed.

@param area — the region to blank; defaults to the whole #rect.

@param bg — the color to blank with; defaults to #effective_bg_color, i.e. this component's own surface.

Parameters:

  • area (Rect) (defaults to: rect)
  • bg (Color, nil) (defaults to: effective_bg_color)


1035
1036
1037
# File 'lib/tuile/component.rb', line 1035

def clear_background(area = rect, bg = effective_bg_color)
  screen.buffer.fill(area, bg ? StyledString::Style.new(bg:) : StyledString::Style::DEFAULT)
end

#clear_inside_extentvoid

This method returns an undefined value.

Blanks the #extent itself, for a container whose children don't cover it — a Tuile::Component::Layout::Box's spacing column, the slack past the last child, the span a child abandoned by going hidden or by a narrowing resize.

In the ambient background, the same answer #clear_outside_extent gives the dead tail: a gap between two children is not this widget's ink, so an app's #bg_color tint covers it but a well of its own — a field's, a validation error's — must not bleed into it.

Called by the default #repaint for a container only; a leaf paints its extent itself, and blanking that first is the re-emit D_progress_bar bought back. Override it to decline when you paint your own ink into a face cell no child covers.



947
948
949
# File 'lib/tuile/component.rb', line 947

def clear_inside_extent
  clear_background(extent_rect, ambient_bg_color)
end

#clear_outside_extentvoid

This method returns an undefined value.

Blanks the part of #rect outside #extent — the dead tail a widget that paints less than it was given must not leave stale. Up to two regions, since a narrowed extent leaves an L: the columns right of it, and the rows below it. A nil extent declares nothing, so the whole rect is blanked. Called by the default #repaint; a self-painter that skips super calls it directly.



920
921
922
923
924
925
926
927
928
929
930
931
# File 'lib/tuile/component.rb', line 920

def clear_outside_extent
  e = extent
  return clear_background if e.nil? # nothing declared: all of it is fair game

  right = Rect.new(rect.left + e.width, rect.top, rect.width - e.width, e.height)
  below = Rect.new(rect.left, rect.top + e.height, rect.width, rect.height - e.height)
  # Not this widget's own surface: a one-row Select handed a 25-row rect
  # would otherwise flood the other 24 with its field well.
  bg = ambient_bg_color
  clear_background(right, bg) unless right.empty?
  clear_background(below, bg) unless below.empty?
end

#coerce_bg_color(value) ⇒ Color, ...

Validates and normalizes what #bg_color= was handed, so a bad token or a misspelled state raises at the assignment rather than deep in a repaint.

@param value

Parameters:

  • value (Object)

Returns:



1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
# File 'lib/tuile/component.rb', line 1118

def coerce_bg_color(value)
  case value
  when nil, Color, BG_INHERIT then value
  when Theme::Ref then value.tap { _1.resolve(screen.theme) }
  when Hash
    unknown = value.keys - BG_STATES
    raise ArgumentError, "unknown background state(s) #{unknown.join(", ")}; known: #{BG_STATES.join(", ")}" \
      unless unknown.empty?

    value.to_h { |state, color| [state, coerce_bg_color(color)] }.freeze
  else Color.coerce(value)
  end
end

#cursor_positionPoint?

Where the hardware terminal cursor should sit when this component is the cursor owner. Returns nil to indicate the cursor should be hidden. The Screen positions the hardware cursor after each repaint cycle by consulting the Screen#focused component only.

@return — absolute screen coordinates, or nil to hide.

Returns:



603
# File 'lib/tuile/component.rb', line 603

def cursor_position = nil

#default_bg_colorColor, ...

The background this component paints when the app has set no #bg_colornil by default, meaning "I have no surface of my own; whatever is behind me shows through". A widget that paints an opaque surface overrides it, and inheritance stops there: that is what keeps a form's fields looking like fields inside a tinted panel. Declare it unconditionally — a widget owned by a bigger one is told so with BG_INHERIT, and must not try to work it out from where it sits in the tree.

# a field: its own well, brighter while focused
def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color

Return whatever #bg_color accepts — a Tuile::Color, a Theme::Ref or a state Hash. Branching on #active? and handing back one Tuile::Color, as above, is the cheap form and allocates nothing on the paint path.

Read the theme here rather than in an ivar: this runs at paint time, so a Screen#theme= restyles the widget with no #handle_theme_changed hook.

Returns:



969
# File 'lib/tuile/component.rb', line 969

def default_bg_color = nil

#depthInteger

@return — the distance from the root component; 0 if #parent is nil.

Returns:

  • (Integer)


475
# File 'lib/tuile/component.rb', line 475

def depth = parent.nil? ? 0 : parent.depth + 1

#detach_child(child) ⇒ void

This method returns an undefined value.

Drops child without notifying — for a container swapping a named slot, which owes the #handle_child_removed call once the new occupant is wired:

detach_child(old)
@content = new
add_child(new, at: 0)
handle_child_removed(old)   # focus repair cascades into the *new* content

The child leaves #children before its pointer is cleared, so nothing observes a child whose parent has disowned it while still listing it. Final: one of the three mutators that write #children and the parent pointer in the same call, which is what keeps them in agreement.

@param child

Parameters:



686
687
688
689
690
691
# File 'lib/tuile/component.rb', line 686

def detach_child(child)
  raise ArgumentError, "#{child} is not a child of #{self}" unless @children.include?(child)

  @children.delete(child)
  child.parent = nil
end

#draw_char(x, y, grapheme, style = StyledString::Style::DEFAULT) ⇒ Object

#draw_text's single-grapheme counterpart: writes grapheme at (x, y), filling #effective_bg_color when style carries no background of its own.

@param x — column.

@param y — row.

@param grapheme — one grapheme cluster.

@param style



1061
1062
1063
1064
1065
# File 'lib/tuile/component.rb', line 1061

def draw_char(x, y, grapheme, style = StyledString::Style::DEFAULT)
  bg = effective_bg_color
  style = style.merge(bg:) if bg && style.bg.nil?
  screen.buffer.set_char(x, y, grapheme, style)
end

#draw_text(x, y, styled) ⇒ void

This method returns an undefined value.

Buffer#set_text wrapper that fills #effective_bg_color behind any span with no bg of its own (via StyledString#under_bg), so an inherited #bg_color — or an invalid field's error well — shows through the content a component paints. A no-op layer when none is inherited. Self-painters (those skipping the #repaint auto-clear) paint through this instead of Screen#buffer directly.

@param x — starting column.

@param y — row.

@param styled

Parameters:



1049
1050
1051
# File 'lib/tuile/component.rb', line 1049

def draw_text(x, y, styled)
  screen.buffer.set_text(x, y, styled.under_bg(effective_bg_color))
end

#effective_bg_colorColor?

Final, and protected: it answers what the framework paints with, and an app never needs it — #clear_background / #draw_text / #draw_char apply it already. A component states its own opinion by overriding #default_bg_color, an app by setting #bg_color; neither takes this over. Protected rather than private because the chain below is an explicit-receiver call, which Ruby forbids for a private method.

@return — the background actually painted, for the state this component is in right now: its #error_bg_color, else its #bg_color, else its #default_bg_color, else the nearest ancestor answering one of those, else nil (terminal default). Resolved at paint time — never cached, so the subtree tracks an ancestor's #bg_color=, a Screen#theme=, a focus change and a validation verdict on its next repaint.

Returns:



984
985
986
987
988
989
# File 'lib/tuile/component.rb', line 984

def effective_bg_color
  own = resolve_bg_color(error_bg_color) || resolve_bg_color(@bg_color) || resolve_bg_color(default_bg_color)
  return parent&.effective_bg_color if own.nil? || own == BG_INHERIT

  own
end

#error_bg_colorColor, ...

The background a component paints while it is in an error state — nil by default, meaning "I am not signalling one". HasValidation overrides it, so every field has it and nothing else does.

It sits above #bg_color in #effective_bg_color rather than under it, unlike #default_bg_color. That is deliberate: an app tinting a panel would otherwise switch the validation signal off on the fields inside it, silently. An app that wants different error colors changes the Theme#error_bg_color tokens.

Read the theme here rather than in an ivar, and hand back one Tuile::Color rather than a state Hash — #default_bg_color's reasons, and this runs one level earlier than that on the same paint path.

Returns:



1005
# File 'lib/tuile/component.rb', line 1005

def error_bg_color = nil

#extentSize?

The size of the region this component paints, or nil (the default) to declare nothing — in which case the whole #rect is treated as fair game and the default #repaint blanks all of it. Override it when you paint less: a one-row Checkbox handed a tall column, or a Select used as a Popup's content and assigned the whole inner box.

It always sits at #rect's top-left — which is why this is a Size and not a Rect: an offset extent is not merely unsupported, it is unrepresentable. Use #extent_rect where coordinates are wanted.

nil is not the same as rect.size. nil says "I have not declared what I paint, so clear everything before I do", which is what a Label with short text needs. A declared extent — even one that happens to equal the rect, as a one-row Select in a one-row rect does — says "I paint this in full, don't blank it", which is what keeps the default #repaint from dirtying cells it is about to redraw (D_progress_bar). The base cannot tell those apart from the value alone; that is what the nil carries.

It flows downward only: no container consults it when dividing space, so #rect still means exactly what the parent assigned (D_extent). Three things read it, all of them this component or the framework painting it: #clear_outside_extent blanks the dead tail, Mouse::Router hit-tests against it so a click on that tail doesn't activate the widget, and a dropdown anchors under it rather than under unused space.

An override promises to paint the extent in full, so super in #repaint blanks only what is outside it. The arithmetic is each widget's own — caption width, painted strip, one row — and must not vary with #bg_color (D_boolean_fields).

Returns:



127
# File 'lib/tuile/component.rb', line 127

def extent = nil

#extent_rectRect

#extent placed at #rect's top-left, for the consumers that need coordinates: extent_rect.contains?(point) in Mouse::Router, and the anchor a dropdown hangs from. Total — an undeclared #extent yields the whole #rect, so a generic caller never sees nil.

Returns:



134
135
136
137
# File 'lib/tuile/component.rb', line 134

def extent_rect
  e = extent
  e.nil? ? rect : Rect.new(rect.left, rect.top, e.width, e.height)
end

#fire_lifecycle(attached) ⇒ void

This method returns an undefined value.

Walks self-then-children calling one lifecycle hook, delivering at most one call per component per transition however the hooks mutate the tree. Two guards, because a hook runs before its own children are visited:

  • the snapshot covers a child a hook adds — it isn't in kids, and fires exactly once through its own parent=;
  • the state re-check covers a child a hook removes. Matching on current attachedness rather than on parent.equal?(self): a child pulled out during a detach walk is already detached, so its own parent= saw no transition and stayed silent — a parentage check would skip it too and it would never hear handle_detached at all. The reverse case (pulled out during an attach walk) gets handle_detached from its own parent= and no handle_attached, which is why the hooks are required to be idempotent: an unpaired detach releases nothing, whereas firing handle_attached at a component that is no longer attached would start a ticker nothing stops.

@param attached — true to fire #handle_attached, false for #handle_detached.

Parameters:

  • attached (Boolean)


766
767
768
769
770
# File 'lib/tuile/component.rb', line 766

def fire_lifecycle(attached)
  kids = children.dup
  attached ? handle_attached : handle_detached
  kids.each { _1.fire_lifecycle(attached) if _1.attached? == attached }
end

#focusvoid

This method returns an undefined value.

Focuses this component. Equivalent to screen.focused = self.



211
212
213
# File 'lib/tuile/component.rb', line 211

def focus
  screen.focused = self
end

#focusable?Boolean

Whether this component is a valid focus target. false by default — passive components like Label are decoration and don't accept focus. The flag gates click-to-focus and the container focus-cascade. Independent from #active?: every component carries the active flag, but only focusable ones can become a focus target that puts themselves and their ancestors on the active chain. Focusable is broader than #tab_stop? — a Window is focusable (a click on chrome lands focus) but not a tab stop.

@return — true if this component can be focused.

Returns:

  • (Boolean)


454
# File 'lib/tuile/component.rb', line 454

def focusable? = false

#handle_attachedvoid

This method returns an undefined value.

Called once this component's tree has been mounted on a ScreenPane, i.e. when #attached? flips to true — the place to acquire whatever is supposed to live for exactly as long as the component is on screen:

def handle_attached
@ticker = screen.event_queue.tick_fps(10) { advance }
end

def handle_detached
@ticker&.cancel
@ticker = nil
end

handle_attached starts what handle_detached stops; both must be cheap and idempotent, since a component moved between parents is genuinely detached in between and gets both, in that order. Whatever you acquire here you must release in #handle_detached — nothing else will. Not a destructor: process teardown does not fire #handle_detached.

#invalidate needs no guard: #attached? is already true here (and already false in #handle_detached, where it no-ops). Do not read #rect — a parent assigns it after wiring, so it is still stale. Runs on the thread that owns the UI.



717
# File 'lib/tuile/component.rb', line 717

def handle_attached; end

#handle_blurvoid

This method returns an undefined value.

Mirror of #handle_focus: the component just lost focus, to another component or to nothing. The commit point a Tab-away still reaches — Tab is unconditional, so Tuile::Component::TextField#on_enter never fires for a user who tabs out of a half-typed field:

class TrimmedField < Component::TextField
protected def handle_blur
  super
  self.text = text.strip
  false
end
end

Edge-triggered, and fired on the blurred component alone — never on the ancestors leaving the active chain with it, so a composed widget asking "did focus leave me and my children" overrides #active= instead (ComboBox closes its dropdown from there). Focus that merely passes through does blur: a container forwarding focus from #handle_focus is blurred by its own forward.

A notification, not a veto — focus has already moved, and the active-flag cascade has already run. Reassigning Screen#focused from here is honored: that assignment wins, and the one that blurred you abandons the rest of its work.

Implementation details

It fires wherever focus is dropped, not only where a user moved it, so two paths reach it with the tree mid-flight: the popup-close repair blurs an already-detached component, where #invalidate is the same silent no-op as in #handle_detached, and Screen#close blurs on its way out. Keep it cheap; a raise propagates out of Screen#focused=. Protected because the framework calls it and an app never does — Screen reaches it with __send__, so an override may declare any visibility (D_hook_visibility).



832
# File 'lib/tuile/component.rb', line 832

def handle_blur; end

#handle_child_removed(child) ⇒ void

This method returns an undefined value.

Called by container components after child has been detached from self.children (its parent is already nil and it is no longer in the children list). Default behavior repairs dangling focus: if the focused component lived inside the removed subtree, focus shifts to self so the cursor doesn't dangle on a detached component. No-op if self is not attached to the screen — focus state in a detached subtree is moot.

#visible= reuses this when it hides a subtree holding focus — the same question, answered once so hide and remove can't drift apart. It passes the hidden child, which is still in children with self as its parent: an override may repair focus however it likes, but must not assume the child is gone. Removal bookkeeping belongs in the remover.

The one hook whose base body does real work, so an override replaces it (as Slot and ScreenPane do) instead of calling super.

@param child — the just-detached, or just-hidden, child.

Parameters:



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
# File 'lib/tuile/component.rb', line 582

def handle_child_removed(child)
  return unless attached?

  f = screen.focused
  return if f.nil?

  cursor = f
  until cursor.nil?
    if cursor == child
      screen.focused = self
      break
    end
    cursor = cursor.parent
  end
end

#handle_child_visibility_changed(_child) ⇒ void

This method returns an undefined value.

Called on the parent after a direct child's #visible= flipped, so a container that divides space can re-divide it:

def handle_child_visibility_changed(_child)
super
relayout
end

A container with layout arithmetic owes this override, or a hidden child keeps its slot and its gap — the hole the flag exists to close. Tuile::Component::Layout::Absolute owes nothing: its rect= is app arithmetic, and an app wanting the space back reads visible? there.

Fires on the flip only, before the subtree is invalidated, never for a grandchild. #visible= repairs focus itself, so an override has nothing to inherit — it still calls super, per the class doc. Reached through __send__, so it may declare any visibility (D_hook_visibility).

@param _child — the direct child whose flag changed.

Parameters:



795
# File 'lib/tuile/component.rb', line 795

def handle_child_visibility_changed(_child); end

#handle_detachedvoid

This method returns an undefined value.

Mirror of #handle_attached, called once the tree has been unmounted — see there for the contract. Two things are still mid-flight when it runs, both deliberate: Screen#focused may still point into this subtree (repair happens after), and the ex-parent's own bookkeeping may not be finished. So release resources here and don't inspect the tree around you.



725
# File 'lib/tuile/component.rb', line 725

def handle_detached; end

#handle_focusvoid

This method returns an undefined value.

Called when the component receives focus — on this component alone, never on the ancestors that light up with it. #handle_blur is the other half.

Unlike handle_blur it is not edge-triggered: it fires on every Screen#focused=, re-assigning the component that already has focus included, which is what lets a container forward focus into its content from here.



532
# File 'lib/tuile/component.rb', line 532

def handle_focus; end

#handle_key?(_key) ⇒ Boolean

Called when a key is pressed; override to act on keys you care about (the default reports every key unhandled). A component only receives keys while it's on the focus chain — or when app code hands it one directly — so act on the key alone and never gate on your own #active? state. See book ch5 for how a keystroke is routed to reach here.

The ? reads like Set#add?: calling it delivers the key and reports whether it was taken, so it is never a "would you handle this?" probe.

@param _key — a key.

@return — true if the key was handled, false if not.

Parameters:

  • _key (String)

Returns:

  • (Boolean)


336
337
338
# File 'lib/tuile/component.rb', line 336

def handle_key?(_key)
  false
end

#handle_locale_changedvoid

This method returns an undefined value.

Called on every attached component (pre-order, popups included) when Screen#locale changes — for state derived from the old conventions and pushed somewhere, such as a date already rendered into a Label's text. Anything read at paint or parse time needs no override: the locale change invalidates the whole tree.

Runs on the UI thread with Screen#locale already updated. Subclasses overriding it must call super so an assigned #on_locale_changed= listener keeps firing.

Plumbing an app overrides and never calls, hence protected — Screen fans it out through __send__, so an override may declare any visibility (D_hook_visibility).

@return — is whatever the app's lambda happened to return.



870
871
872
# File 'lib/tuile/component.rb', line 870

def handle_locale_changed
  @on_locale_changed&.call
end

#handle_mouse_down?(_event) ⇒ Boolean

Called when a mouse button goes down over this component; answer true to claim the press. The default claims nothing.

def handle_mouse_down?(event)
return false unless event.button == :left

@on_click&.call
true
end

Mouse::Router delivers it to the innermost component under the pointer and bubbles it up the ancestors until one answers true; the claimant then holds the grab, and the button's #handle_mouse_drag and #handle_mouse_up go to it alone. The press has already moved focus by the time it arrives, and it only arrives where #extent_rect contains the point, so an override needs neither super nor a hit test of its own.

Activate here, on the press: Tuile synthesizes no click, because a release is losable over ssh and tmux (D_mouse_dispatch).

@param _event

@return — whether this component claimed the press.

Parameters:

Returns:

  • (Boolean)


382
# File 'lib/tuile/component.rb', line 382

def handle_mouse_down?(_event) = false

#handle_mouse_drag(_event) ⇒ void

This method returns an undefined value.

Called on the component that claimed a press whenever the pointer moves while the button is held, wherever the pointer is. Needs capture_mouse: :drag or :hover.

@param _event — its point may lie outside #rect.

Parameters:



413
# File 'lib/tuile/component.rb', line 413

def handle_mouse_drag(_event); end

#handle_mouse_entervoid

This method returns an undefined value.

Called when the pointer comes over this component or any of its descendants — down the chain, root first, and after every #handle_mouse_exit the same move fires. Needs capture_mouse: :hover, and is suspended while a press is grabbed.

Never a commit point: no terminal reports the pointer leaving the window, so the matching #handle_mouse_exit may arrive late or not at all. Anything done here must be cosmetic and survive that.



424
# File 'lib/tuile/component.rb', line 424

def handle_mouse_enter; end

#handle_mouse_exitvoid

This method returns an undefined value.

The other half of #handle_mouse_enter; also fires when this component is detached or hidden while hovered, innermost first.



429
# File 'lib/tuile/component.rb', line 429

def handle_mouse_exit; end

#handle_mouse_move?(_event) ⇒ Boolean

Called when the pointer moves over this component with nothing grabbed; answer true to consume the move. Bubbles as #handle_mouse_down? does. Arrives only under run_event_loop(capture_mouse: :hover), at up to ~84 events a second, which is why the default passes it on untouched.

@param _event

@return — whether this component consumed the move.

Parameters:

Returns:

  • (Boolean)


398
# File 'lib/tuile/component.rb', line 398

def handle_mouse_move?(_event) = false

#handle_mouse_scroll?(_event) ⇒ Boolean

Called when the wheel turns over this component; answer true to consume the notch. Bubbles exactly as #handle_mouse_down? does, but grabs nothing — so a scroller already at its limit answers false and its ancestor scrolls instead.

@param _event

@return — whether this component consumed the notch.

Parameters:

Returns:

  • (Boolean)


390
# File 'lib/tuile/component.rb', line 390

def handle_mouse_scroll?(_event) = false

#handle_mouse_up(_event) ⇒ void

This method returns an undefined value.

Called on the component that claimed a press when the button comes up, ending the grab. For press feedback and for ending a drag — never for activation: a release may never arrive, and any key or the next press ends the grab without it.

@param _event

Parameters:



406
# File 'lib/tuile/component.rb', line 406

def handle_mouse_up(_event); end

#handle_paste(_text) ⇒ void

This method returns an undefined value.

Called when text is pasted while this component is Screen#focused; override to accept it. The default drops the text. It arrives whole and \n-normalized, so text.lines.size is the paste's line count and a single mutation can absorb it:

def handle_paste(text)
self.caption = "[Pasted #{text.lines.size} lines]"
end

No verdict, unlike #handle_key?: a paste reaches the focused component and stops, so one that declines has nowhere to hand it on to (D_bracketed_paste).

Reaching here means the terminal said "this came from the clipboard" — AbstractStringField inserts it at the caret, which is why a subclass that rebinds ENTER to submit needs no paste handling of its own to stop firing once per pasted line.

@param _text — the pasted text.

Parameters:

  • _text (String)


359
# File 'lib/tuile/component.rb', line 359

def handle_paste(_text); end

#handle_theme_changedvoid

This method returns an undefined value.

Called on every attached component (pre-order, popups included) when Screen#theme changes — at Screen#theme= / Screen#theme_def= and on OS appearance flips. The hook exists for app content whose colors were baked in from the old theme (a Tuile::Component::Label#text / Tuile::Component::List#lines= StyledString styled with theme[:accent]); rebuild it here by re-running the code that rendered it. See book ch6 for why built-in accents need no such handling.

Runs on the UI thread with Screen#theme already updated, so mutating content (text=, lines=, …) is safe. Do not assign Screen#theme= here. Subclasses overriding this must call super so an assigned #on_theme_changed= listener keeps firing.

Plumbing an app overrides and never calls, hence protected — and Screen, not being a Tuile::Component, fans it out through __send__, so an override is free to declare any visibility (D_hook_visibility).

@return — is whatever the app's lambda happened to return.



851
852
853
# File 'lib/tuile/component.rb', line 851

def handle_theme_changed
  @on_theme_changed&.call
end

#handle_width_changedvoid

This method returns an undefined value.

Called whenever the component width changes. Does nothing by default.



774
# File 'lib/tuile/component.rb', line 774

def handle_width_changed; end

#heightInteger

@returnrect.height.

Returns:

  • (Integer)


93
# File 'lib/tuile/component.rb', line 93

def height = rect.height

#inspectString

One line naming the component, its #id and its rect, plus whatever #inspect_details adds:

#<Tuile::Component::Button id=:save rect=(2,3 8x1) caption="Save">

Deliberately shallow — it never walks #parent or #children, so inspecting one component does not dump the whole UI.

Returns:

  • (String)


613
614
615
616
617
618
# File 'lib/tuile/component.rb', line 613

def inspect
  parts = [self.class.to_s] # not .name — an anonymous class has none
  parts << "id=#{@id.inspect}" unless @id.nil?
  parts << "rect=(#{rect})"
  "#<#{(parts + inspect_details).join(" ")}>"
end

#inspect_details::Array[String]

What this component adds to its #inspect, as key=value strings.

def inspect_details = super + ["items=#{items.size}"]

Always super — this is the seam several mixins share, and each one appends to what the last returned. Override #inspect instead of this and you drop whichever details the mixins contribute. They come out in reverse include order, the last-included module running first.

The base contributes a bare hidden when #visible? is false, and nothing when it is true — so in a Testing.dump the marker sits on the hidden ancestor, not on each component under it.

Returns:

  • (::Array[String])


635
# File 'lib/tuile/component.rb', line 635

def inspect_details = @visible ? [] : ["hidden"]

#invalidatevoid

This method returns an undefined value.

Invalidates the component: Screen records this component as needs-repaint and once all events are processed, will call #repaint.

No-op when the component is not #attached? — a detached component has no place on the screen to paint to, so Screen must never end up repainting it. Callers don't need to guard their own invalidate calls; mutating a detached component (e.g. setting lines= on a List sitting inside a closed Popup) is silent.



891
892
893
894
895
# File 'lib/tuile/component.rb', line 891

def invalidate
  return unless attached?

  screen.invalidate(self)
end

#invalidate_childrenvoid

This method returns an undefined value.

Passes the repaint cascade on to the direct children — the one thing a container may never skip, whatever else its #repaint does. Named so a self-painting container can drop the default's blanket clear without also dropping this by accident (Window is the case):

def repaint
return if rect.empty?

invalidate_children      # never optional
paint_my_own_chrome
end


1020
1021
1022
# File 'lib/tuile/component.rb', line 1020

def invalidate_children
  children.each { |c| screen.invalidate(c) }
end

#localeLocale

The formatting conventions to render and parse by (Screen#locale), or Locale::ISO when there is no screen in the process — which a tree assembled outside a UI legitimately is, and Screen.instance raises rather than answering. Read it here, at use time; never cache it, since Screen#locale= can replace it.

Returns:



880
# File 'lib/tuile/component.rb', line 880

def locale = Screen.instance? ? screen.locale : Locale::ISO

#remove_child(child) ⇒ void

This method returns an undefined value.

Drops child and notifies #handle_child_removed. Final: one of the three mutators that write #children and the parent pointer in the same call, which is what keeps them in agreement.

@param child

Parameters:



665
666
667
668
# File 'lib/tuile/component.rb', line 665

def remove_child(child)
  detach_child(child)
  handle_child_removed(child)
end

#repaintvoid

This method returns an undefined value.

Repaints the component. The default does the bookkeeping most components need: it clears the background — unless the direct children already tile #rect, in which case there is no gap to wipe and blanking cells they are about to repaint would only make them dirty — and then re-invalidates those children so they paint over the cleared area. That is what makes mixed-width form layouts safe.

Call super from your own repaint to inherit this. Skip it only if you paint the whole #rect yourself (Window's border, List's row-by-row paint). Never draw outside #rect. Only called when attached.

A widget that paints less than its rect declares an #extent rather than skipping super. The clear then covers only what is outside it, so the cells it is about to repaint are not blanked first — blanking them would mark them dirty and make Buffer#flush re-emit them (D_progress_bar). That saving is a leaf's: a container's children paint its extent for it, so a cell among them that none covers still gets blanked — an extent narrows which cells are yours, never whether your gaps are wiped.

The children are re-invalidated whether or not they tile. A container that paints nothing of its own can only redraw its area through them, so a tiling container that skipped this would be a dead end in the cascade: an ancestor's clear_background wipes the whole ancestor rect — siblings and grandchildren included — and re-invalidates only its direct children, so the notice has to keep travelling down or the cleared cells are never repainted. Cheap by construction: repainting the same glyphs leaves Buffer::Cell unchanged, so nothing extra reaches the wire.

A container that skips super because it paints its own rect must still call #invalidate_children — that is the half of this that cannot be dropped.



316
317
318
319
320
321
322
323
324
# File 'lib/tuile/component.rb', line 316

def repaint
  return if rect.empty?

  unless children.any? && children_tile_rect?
    clear_outside_extent
    clear_inside_extent if extent && children.any?
  end
  invalidate_children
end

#repair_focus_after_hidingvoid

This method returns an undefined value.

Hands focus out of the subtree just hidden, if it was in there, through the parent's #handle_child_removed — see there for why hiding reuses the removal repair instead of growing a second one.

The parent is necessarily showing (focus was inside it a moment ago, and Screen#focused= refuses a hidden target), so its assignment can't bounce.



1076
1077
1078
1079
1080
1081
1082
# File 'lib/tuile/component.rb', line 1076

def repair_focus_after_hiding
  return unless attached?

  cursor = screen.focused
  cursor = cursor.parent until cursor.nil? || cursor.equal?(self)
  parent.handle_child_removed(self) unless cursor.nil?
end

#resolve_bg_color(value) ⇒ Color?

Collapses one level of the background chain to the Tuile::Color it means right now: picks the entry for this component's current state out of a state Hash, and resolves a Theme::Ref against the live theme. An absent state key yields nil, so resolution falls through to the next level — which is what lets bg_color = { active: … } keep the widget's own normal well.

@param value

Parameters:

Returns:



1103
1104
1105
1106
1107
1108
1109
1110
# File 'lib/tuile/component.rb', line 1103

def resolve_bg_color(value)
  case value
  when nil then nil
  when Hash then resolve_bg_color(value[active? ? :active : :normal])
  when Theme::Ref then value.resolve(screen.theme)
  else value
  end
end

#rootComponent

@return — the root component of this component hierarchy.

Returns:



478
# File 'lib/tuile/component.rb', line 478

def root = parent.nil? ? self : parent.root

#screenScreen

@return — the screen which owns this component.

Returns:



207
# File 'lib/tuile/component.rb', line 207

def screen = Screen.instance

#sizeSize

@returnrect.size.

Returns:



87
# File 'lib/tuile/component.rb', line 87

def size = rect.size

#tab_stop?Boolean

Whether this component participates in Tab / Shift+Tab focus cycling. false by default. Only true on components that accept direct user input (e.g. TextField, List, Button). Implies #focusable? — Screen will skip non-focusable tab stops, but in practice every override should keep the two consistent.

@return — true if Tab / Shift+Tab should land on this component.

Returns:

  • (Boolean)


462
# File 'lib/tuile/component.rb', line 462

def tab_stop? = false

#visible=(value) ⇒ void

This method returns an undefined value.

Hides or shows the component: false means as if detached — but it stays in the tree.

company.visible = business_customer.checked?   # a conditional form field

Hidden, it paints nothing, takes no space in a Tuile::Component::Layout::Box (nor the spacing around it), and is unreachable by focus, Tab, keys, the cursor, the mouse and Testing.find. Unlike a detached component it keeps its #parent, #rect, box constraints, state and any resource it holds, and fires no lifecycle hook — so a hidden pane may go on running a job. When you want the hooks, remove it from the tree instead.

Two contracts worth knowing before you meet them as bugs:

  • Ancestor-inclusive. Hiding a container hides its whole subtree whatever those components' own flags say; the flags are remembered, so showing it restores exactly the subtree that was showing before.
  • Focus never stays on what the user cannot see. Hiding the subtree holding focus repairs it exactly as removing that subtree would (see #handle_child_removed), and does not hand it back on the way in.

For "invisible but still occupying its space", use a Slot with no content (D_slots).

@param value

Parameters:

  • value (Boolean)


193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/tuile/component.rb', line 193

def visible=(value)
  value = value ? true : false
  return if @visible == value

  screen.check_locked if attached?
  @visible = value
  # `__send__` for the same reason `Screen#theme=` uses it: the hook is
  # protected (`D_hook_visibility`).
  parent&.__send__(:handle_child_visibility_changed, self)
  repair_focus_after_hiding unless value
  walk_tree { |c| screen.invalidate(c) } if attached?
end

#visible?Boolean

This component's own flag — not whether the user can see it, which also depends on its ancestors: a shown field inside a hidden panel answers true.

Returns:

  • (Boolean)


163
# File 'lib/tuile/component.rb', line 163

def visible? = @visible

#walk_shown_tree(&block) ⇒ void

This method returns an undefined value.

#walk_tree, pruned: a hidden subtree is skipped whole, this component included when it is itself hidden (in which case nothing is yielded).

stops = []
scope.walk_shown_tree { |c| stops << c if c.tab_stop? }

Use this for anything asking "can the user reach it", #walk_tree for what the framework does to a component regardless — lifecycle, theme fan-out, invalidation — which a hidden component still gets. Writing the first as walk_tree plus a visible? test is the trap: that is this walk with the ancestor case missing, so a field under a hidden panel is back in the Tab cycle (D_visibility).



517
518
519
520
521
522
# File 'lib/tuile/component.rb', line 517

def walk_shown_tree(&block)
  return unless visible?

  block.call(self)
  children.each { _1.walk_shown_tree(&block) }
end

#walk_tree(&block) ⇒ void

This method returns an undefined value.

Calls block for this component and for every descendant component.



496
497
498
499
# File 'lib/tuile/component.rb', line 496

def walk_tree(&block)
  block.call(self)
  children.each { _1.walk_tree(&block) }
end

#widthInteger

@returnrect.width.

Returns:

  • (Integer)


90
# File 'lib/tuile/component.rb', line 90

def width = rect.width