Class: Tuile::Component

Inherits:
Object
  • Object
show all
Defined in:
lib/tuile/component.rb,
lib/tuile/component/list.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/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/layout/box.rb,
lib/tuile/component/log_window.rb,
lib/tuile/component/text_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/integer_field.rb,
lib/tuile/component/list_dropdown.rb,
lib/tuile/component/picker_window.rb,
lib/tuile/component/checkbox_group.rb,
lib/tuile/component/password_field.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,
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.

Direct Known Subclasses

ScreenPane

Defined Under Namespace

Modules: HasCaption, HasContent, HasValue Classes: AbstractStringField, BigDecimalField, Button, Checkbox, CheckboxGroup, ComboBox, FloatField, InfoWindow, IntegerField, Label, Layout, List, ListDropdown, LogWindow, MenuBar, Notification, PasswordField, PickerWindow, Popup, ProgressBar, RadioGroup, Select, TabSheet, Tabs, TextArea, TextField, TextView, Window

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeComponent

Returns a new instance of Component.



12
13
14
15
16
17
18
# File 'lib/tuile/component.rb', line 12

def initialize
  @rect = Rect.new(0, 0, 0, 0)
  @active = false
  @on_theme_changed = nil
  @bg_color = nil
  @children = []
end

Instance Attribute Details

#bg_colorColor, ...

@return — this component's own background — the value as set, so a Theme::Ref comes back unresolved; nil when unset, in which case it inherits from the parent (see #effective_bg_color), ultimately the terminal default. #effective_bg_color is the resolved Tuile::Color to paint.

Returns:



57
58
59
# File 'lib/tuile/component.rb', line 57

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.

Not meant to be overridden: a container that computed this from its own slots could disagree with the parent pointers, and #attached? walks the chain while subtree walks use this list. Named slots are readers over the array (Window#footer), never a second copy of it.

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

Returns:



216
217
218
# File 'lib/tuile/component.rb', line 216

def children
  @children
end

#on_theme_changedProc?

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.

Returns:

  • (Proc, nil)


253
254
255
# File 'lib/tuile/component.rb', line 253

def on_theme_changed
  @on_theme_changed&.call
end

#parentComponent?

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

Returns:



198
199
200
# File 'lib/tuile/component.rb', line 198

def parent
  @parent
end

#rectRect

@return — the rectangle the component occupies on screen.

Returns:



21
22
23
# File 'lib/tuile/component.rb', line 21

def rect
  @rect
end

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)


170
171
172
173
174
175
176
# File 'lib/tuile/component.rb', line 170

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)


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

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

@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)


313
314
315
316
317
318
319
# File 'lib/tuile/component.rb', line 313

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

#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)


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

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.

Returns:

  • (Boolean)


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

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

#clear_background(area = rect) ⇒ 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.

Parameters:

  • area (Rect) (defaults to: rect)


467
468
469
470
# File 'lib/tuile/component.rb', line 467

def clear_background(area = rect)
  bg = effective_bg_color
  screen.buffer.fill(area, bg ? StyledString::Style.new(bg:) : StyledString::Style::DEFAULT)
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:



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

def cursor_position = nil

#depthInteger

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

Returns:

  • (Integer)


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

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 #on_child_removed call once the new occupant is wired:

detach_child(old)
@content = new
add_child(new, at: 0)
on_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.

@param child

Parameters:



343
344
345
346
347
348
# File 'lib/tuile/component.rb', line 343

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 bg of its own.

@param x — column.

@param y — row.

@param grapheme — one grapheme cluster.

@param style



492
493
494
495
496
# File 'lib/tuile/component.rb', line 492

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 shows through the content a component paints. A no-op layer when nothing 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:



481
482
483
# File 'lib/tuile/component.rb', line 481

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

#effective_bg_colorColor?

@return — the background actually painted: this component's own #bg_color if set (a Theme::Ref resolved against the current theme), else the nearest ancestor's, else nil (terminal default). Resolved at paint time — never cached, so the subtree tracks both an ancestor's #bg_color= and a Screen#theme= on its next repaint.

Returns:



90
91
92
93
94
# File 'lib/tuile/component.rb', line 90

def effective_bg_color
  own = @bg_color
  own = own.resolve(screen.theme) if own.is_a?(Theme::Ref)
  own || parent&.effective_bg_color
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 on_detached at all. The reverse case (pulled out during an attach walk) gets on_detached from its own parent= and no on_attached, which is why the hooks are required to be idempotent: an unpaired detach releases nothing, whereas firing on_attached at a component that is no longer attached would start a ticker nothing stops.

@param attached — true to fire #on_attached, false for #on_detached.

Parameters:

  • attached (Boolean)


420
421
422
423
424
# File 'lib/tuile/component.rb', line 420

def fire_lifecycle(attached)
  kids = children.dup
  attached ? on_attached : on_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.



48
49
50
# File 'lib/tuile/component.rb', line 48

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)


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

def focusable? = false

#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.

@param _key — a key.

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

Parameters:

  • _key (String)

Returns:

  • (Boolean)


130
131
132
# File 'lib/tuile/component.rb', line 130

def handle_key(_key)
  false
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

Handles mouse event. Default implementation focuses this component when clicked (if #focusable?).

@param event

Parameters:



159
160
161
# File 'lib/tuile/component.rb', line 159

def handle_mouse(event)
  screen.focused = self unless event.button != :left || active? || !focusable?
end

#handle_paste(_text) ⇒ Boolean

Called when text is pasted while this component is on the focus chain; override to accept it (the default reports every paste unhandled, and unhandled text is dropped). 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]"
true
end

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.

@return — true if the paste was consumed.

Parameters:

  • _text (String)

Returns:

  • (Boolean)


151
152
153
# File 'lib/tuile/component.rb', line 151

def handle_paste(_text)
  false
end

#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.



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

def invalidate
  return unless attached?

  screen.invalidate(self)
end

#on_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 on_attached
@ticker = screen.event_queue.tick_fps(10) { advance }
end

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

on_attached starts what on_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 #on_detached — nothing else will. Not a destructor: process teardown does not fire #on_detached.

#invalidate needs no guard: #attached? is already true here (and already false in #on_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.



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

def on_attached; end

#on_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.

@param child — the just-detached child.

Parameters:



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/tuile/component.rb', line 278

def on_child_removed(child)
  return unless attached?

  f = screen.focused
  return if f.nil?

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

#on_detachedvoid

This method returns an undefined value.

Mirror of #on_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.



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

def on_detached; end

#on_focusvoid

This method returns an undefined value.

Called when the component receives focus.



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

def on_focus; end

#on_tree(&block) ⇒ void

This method returns an undefined value.

Calls block for this component and for every descendant component.



223
224
225
226
# File 'lib/tuile/component.rb', line 223

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

#on_width_changedvoid

This method returns an undefined value.

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



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

def on_width_changed; end

#remove_child(child) ⇒ void

This method returns an undefined value.

Drops child and notifies #on_child_removed.

@param child

Parameters:



325
326
327
328
# File 'lib/tuile/component.rb', line 325

def remove_child(child)
  detach_child(child)
  on_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.

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.



116
117
118
119
120
121
# File 'lib/tuile/component.rb', line 116

def repaint
  return if rect.empty?

  clear_background unless children.any? && children_tile_rect?
  children.each { |c| screen.invalidate(c) }
end

#rootComponent

@return — the root component of this component hierarchy.

Returns:



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

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

#screenScreen

@return — the screen which owns this component.

Returns:



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

def screen = Screen.instance

#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)


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

def tab_stop? = false