Class: Tuile::Component::AbstractWrappingField

Inherits:
Component
  • Object
show all
Includes:
HasPlaceholder, HasValue
Defined in:
lib/tuile/component/abstract_wrapping_field.rb,
sig/tuile.rbs

Overview

Abstract base for a field that wraps one editor completely: it carries a typed HasValue#value but paints nothing itself, handing the whole UI to a single AbstractStringField it owns and hides. Subclass it by passing the editor to super and defining the conversion both ways:

class IntegerField < Component::AbstractWrappingField
def initialize = super(TextField.new)

def value = Integer(editor.text, 10) rescue nil

def set_value(new_value, from_user:)
  editor.set_value(new_value.nil? ? "" : new_value.to_s, from_user:)
  editor.caret = editor.text.length
end

def empty_value = nil
end

Everything else arrives already wired: the editor is added as the single child and positioned across #rect, focus forwards into it, it sits on this field's one background well, and #placeholder / #on_enter / #cursor_position / #clear are re-exposed here so an app never addresses it. Give the field a single-row rect.

The editor is private machinery

There is no public accessor — #editor is protected, for subclasses — and that is the point: swapping it would break the conversion. An app never addresses the editor; what it needs is either already delegated here or earns a forwarder here. It is still in children, because the tree is reported honestly, but that is not an invitation.

A spec is the exception, and it has a sanctioned path — driving the editor is how a test reaches a state no public setter produces:

editor = Testing.get(Component::TextField, in: field)
editor.value = "-"         # bad input; field.value still reads nil

A knob that is editor-shaped rather than a concept of this field's own domain is not forwarded, and the subclass sets it on its editor instead:

def initialize
super(TextField.new)
editor.max_text_length = 20   # an internal cap, not part of my surface
end

Committing: leaving the widget, and ENTER

#commit fires on both commit gestures. Leaving the focus chain is one — the field's, not its editor's, which is left on every hop within a widget. ENTER is the other, because a form whose default button is reached by ENTER never moves focus at all. Override it to canonicalize a buffer the user typed loosely:

def commit = (set_value(value, from_user: true) unless value.nil?)   # rewrite canonically

ENTER is committed and then left to keep bubbling, so a scope's default button still sees it; only an #on_enter of this field's own consumes it, which is TextField#on_enter's existing contract.

When the value notice fires

Per edit by default. A field whose grammar is not prefix-closed sets #notify_on_edit? to false and lets the notice settle onto those same two gestures, so a form is never handed a half-typed date that happens to parse (DateField, TimeField).

Who made the change

The editor's own event says whether the user moved the buffer, and this field's notice passes that on: a subclass writes the editor through Tuile::Component::AbstractStringField#set_value with the from_user: it was handed, and the edit route relays it. The commit gestures announce as the user's — an approximation for the one commit code can cause, a programmatic screen.focused = moving focus off this field.

Implementation details

  • HasValue#value and #set_value raise until overridden. The inherited pair stores into @value and never touches the editor, so a subclass that defined only one would silently half-work.
  • HasValue#empty_value is called during construction, to seed the change guard, so it must not depend on subclass state that super has not set yet. In practice it is a constant per class.
  • The editor's on_value_change and on_enter slots carry this field's own listeners — for that guard, and to commit before an app's ENTER handler runs. They are lists, so nothing an app adds displaces them; a subclass reacting to buffer edits still overrides #handle_editor_change (every edit), #set_value or #commit, which run in a defined order.
  • Not for a field whose editor is a filter. This base assumes the buffer is a rendering of the value, so an edit may change the value. ComboBox breaks both halves — its text is a transient query and only a commit moves its value — which is the same line HasBadInput draws.

UI-thread-confined, like every component (see Screen).

Defined Under Namespace

Classes: EnterEvent

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from HasValue

listener

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

listener

Constructor Details

#initialize(editor) ⇒ AbstractWrappingField

@param editor — the editor to wrap; becomes this field's single child and is never swapped.

Parameters:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 103

def initialize(editor)
  super()
  raise TypeError, "expected AbstractStringField, got #{editor.inspect}" unless editor.is_a?(AbstractStringField)

  @editor = editor
  @last_value = empty_value
  # Held so the transition block can unsubscribe the same object it added:
  # a lambda is only equal to itself.
  @enter_bridge = lambda do
    commit_and_notify
    on_enter.fire(EnterEvent.new(source: self))
  end
  # One widget, one surface: the editor paints no well of its own, so this
  # field's well covers it and its bg_color reaches the cells the editor paints.
  editor.bg_color = ComponentBackground::INHERIT
  bg.default_color = ComponentBackground::INPUT_WELL
  editor.on_value_change do |e|
    handle_editor_change
    fire_if_changed(from_user: e.from_user?) if notify_on_edit?
  end
  add_child(editor, at: 0)
end

Instance Attribute Details

#editor ⇒ AbstractStringField (readonly)

@return — the wrapped editor.

Returns:



222
223
224
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 222

def editor
  @editor
end

Instance Method Details

#active=(flag) ⇒ void

This method returns an undefined value.

Runs #commit on the falling edge, i.e. when this field leaves the focus chain. Moving focus within a widget keeps it active, so a future multi-editor field inherits the same semantics unchanged.

@param flag

Parameters:

  • flag (Boolean)


205
206
207
208
209
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 205

def active=(flag)
  was = active?
  super
  commit_and_notify if was && !active?
end

#clear ⇒ void

This method returns an undefined value.

Empties the input, not just the value — a field holding bad input already reads HasValue#empty_value, so clearing through #value= could leave the glyphs on screen (HasBadInput).



144
145
146
147
148
149
150
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 144

def clear
  editor.clear
  # Announced here rather than through the editor's change, so a field
  # holding its notice ({#notify_on_edit?}) still reports an emptying as
  # it happens: emptying is not a half-typed prefix.
  fire_if_changed(from_user: false)
end

#commit ⇒ void

This method returns an undefined value.

Called on a commit gesture — the field leaving the focus chain, or ENTER; no-op by default. This is the commit point a canonicalizing field rewrites its buffer from.



228
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 228

def commit = nil

#commit_and_notify ⇒ void

This method returns an undefined value.

Every commit gesture runs through here, so a field holding its notice (#notify_on_edit?) announces from one place rather than three; the diff guard makes the call free for a field that fired on the way in.



265
266
267
268
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 265

def commit_and_notify
  commit
  fire_if_changed(from_user: true)
end

#cursor_position ⇒ Point?

@return — the editor's caret — the hardware cursor is delegated to it.

Returns:



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

def cursor_position = editor.cursor_position

#empty? ⇒ Boolean

Empty of value: a field whose parse is partial reports true while the user is looking at glyphs it could not use, so ask HasBadInput#bad_input? first.

@return — true iff #value equals #empty_value.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_value ⇒ Object

@return — the value #empty?/#clear treat as empty; nil unless an includer overrides it.

Returns:

  • (Object)


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

def empty_value: () -> Object

#error_bg_color ⇒ Color?

The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides Tuile::Component#error_bg_color).

Returns:



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

def error_bg_color: () -> Color?

#error_ink? ⇒ Boolean

Whether to paint the invalid well right now. Its own hook because HasBadInput widens it: a field holding input its value cannot represent is invalid on the face too, even with no verdict written.

Returns:

  • (Boolean)


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

def error_ink?: () -> bool

#error_message ⇒ StyledString?

@return — why the field is invalid, or nil when it is not; nil until something sets it.

Returns:



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

def error_message: () -> StyledString?

#error_message= ⇒ void

This method returns an undefined value.

Sets the verdict and repaints the field in Theme#error_color; nil clears it. No-op (no repaint, no listener) when unchanged. A String is parsed via StyledString.parse, as HasCaption#caption= does.

Safe on a detached field — an app validates a form it assembled but has not mounted, and Tuile::Component#invalidate is already a no-op there.

@param new_message

Parameters:



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

def error_message=: ((String | StyledString)? new_message) -> void

#fire_if_changed(from_user:) ⇒ void

This method returns an undefined value.

Re-emits HasValue#on_value_change, but only when #value differs from the last one fired — so a buffer edit that leaves the value alone ("7"→"07") stays silent.

@param from_user — what the write that moved the buffer declared.

Parameters:

  • from_user: (Boolean)


275
276
277
278
279
280
281
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 275

def fire_if_changed(from_user:)
  v = value
  return if v == @last_value

  @last_value = v
  on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: v, from_user:))
end

#focusable? ⇒ Boolean

Input fields are focusable by default (overrides Tuile::Component#focusable?); a read-only display field could override back to false. Only focusable? lives here — tab_stop? diverges between leaf fields and composing wrappers, so it stays per-class (design/decisions.md D_integer_field).

Returns:

  • (Boolean)


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

def focusable?: () -> bool

#handle_editor_change ⇒ void

This method returns an undefined value.

Called whenever the editor's buffer changes, however the characters arrived — a typed key, a paste, or a #set_value of this field's own. It is named for the editor, not for the user, because those last two are not input. No-op by default; override it to drop state that describes the previous buffer, as a field latching whether its input has settled must (HasBadInput).



252
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 252

def handle_editor_change; end

#handle_focus ⇒ void

This method returns an undefined value.



212
213
214
215
216
217
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 212

def handle_focus
  super
  # The editor is what actually edits, so it takes the focus this field was
  # given — the field itself has no keys of its own.
  screen.focused = editor if editor.focusable?
end

#handle_key?(key) ⇒ Boolean

Commits on ENTER, and leaves the key unconsumed so it keeps bubbling.

The editor declines ENTER whenever #on_enter is empty, so the key reaches this field instead — and it must be committed on the way past, or the form default button it is bubbling towards acts on an uncommitted buffer.

@param key

@return — whatever super returns — committing never consumes the key.

Parameters:

  • key (String)

Returns:

  • (Boolean)


191
192
193
194
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 191

def handle_key?(key)
  commit_and_notify if key == Keys::ENTER
  super
end

#inspect_details ⇒ ::Array[String]

Adds placeholder="…" to Tuile::Component#inspect, omitted while unset.

Returns:

  • (::Array[String])


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

def inspect_details: () -> ::Array[String]

#notify_on_edit? ⇒ Boolean

Whether an edit of the buffer fires HasValue#on_value_change as it happens. true here, which is right wherever every buffer state is a value the user might mean: an IntegerField passing through 4 on the way to 42 really does hold 4 for that keystroke. A field whose grammar is not prefix-closed answers false and lets the notice settle onto the commit gestures instead (DateField, D_date_field).

Only the push settles: HasValue#value stays a live parse of the buffer either way. And overriding this is half the job — #commit is covered here, but the field must fire from its own set_value too, or a programmatic write and an Up/Down step go unannounced until the next commit.

Returns:

  • (Boolean)


243
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 243

def notify_on_edit? = true

#on_enter ⇒ Listeners

Fired with an EnterEvent when ENTER is pressed, after #commit; see TextField#on_enter.

Empty means the field declines ENTER, which keeps it bubbling to the scope's default button — see #handle_key?. The editor's own slot is claimed only while this one is non-empty, by a bridge that commits first, so a listener here always reads a committed buffer.

Returns:



178
179
180
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 178

listener :on_enter do |claimed|
  claimed ? editor.on_enter << @enter_bridge : editor.on_enter.remove(@enter_bridge)
end

#on_error_message_change ⇒ Listeners

Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set. The container that paints the message registers here, and so may an app painting its own: the list takes both, which a single slot could not.

Returns:



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

def on_error_message_change: () -> Listeners

#on_value_change ⇒ Listeners

Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.

Returns:



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

def on_value_change: () -> Listeners

#placeholder ⇒ String?

@return — the hint the editor paints while empty (HasPlaceholder).

Returns:

  • (String, nil)


154
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 154

def placeholder = editor.placeholder

#placeholder=(text) ⇒ void

This method returns an undefined value.

@param text

Parameters:

  • text (String, nil)


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

def placeholder=(text)
  editor.placeholder = text
end

#relayout ⇒ void

This method returns an undefined value.

Places the editor across the whole rect; override to reserve cells for a face of your own.



257
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 257

def relayout = (editor.rect = local_rect)

#set_value(new_value, from_user:) ⇒ void

This method returns an undefined value.

Writes new_value into the editor's buffer, passing from_user on to Tuile::Component::AbstractStringField#set_value.

@param new_value

@param from_user — see HasValue#set_value.

Parameters:

  • new_value (Object)
  • from_user: (Boolean)


136
137
138
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 136

def set_value(new_value, from_user:)
  raise(NotImplementedError, "#{self.class} must implement set_value")
end

#shown_message ⇒ StyledString, ...

The message a consumer with cells of its own paints beside the field: the verdict here, and HasBadInput widens it to prefer the field's own report, which outranks a verdict computed a pass ago.

field.on_error_message_change { message_label.caption = field.shown_message.to_s }
field.on_bad_input_change     { message_label.caption = field.shown_message.to_s }

Register on both: two channels with two writers, either of which moves it.

@return — nil when there is nothing to show; a plain String when it is the field's own bad-input report.

Returns:



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

def shown_message: () -> (StyledString | String)?

#value ⇒ Object

@return — the typed value, parsed from the editor's buffer.

Returns:

  • (Object)


128
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 128

def value = raise(NotImplementedError, "#{self.class} must implement value")

#value= ⇒ void

This method returns an undefined value.

A programmatic write: #set_value with from_user: false. Defined here once and never overridden — override #set_value instead, since a setter has no call syntax for the keyword.

@param new_value

Parameters:

  • new_value (Object)


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

def value=: (Object new_value) -> void