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 value=(new_value)
  editor.text = new_value.nil? ? "" : new_value.to_s
  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.text = "-"          # 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 = (self.value = value unless value.nil?)   # rewrite in the canonical form

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

Implementation details

  • HasValue#value and #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_change and on_enter slots are claimed — for that guard, and to commit before an app's ENTER handler runs. A slot cannot be shared, so a subclass reacting to buffer edits overrides #handle_editor_change (every edit), #value= or #commit rather than reassigning either.
  • 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).

Instance Attribute Summary collapse

Attributes included from HasValue

#on_value_change

Attributes included from HasValidation

#on_error_message_change

Instance Method Summary collapse

Constructor Details

#initialize(editor) ⇒ AbstractWrappingField

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

Parameters:



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 95

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

  @editor = editor
  @last_value = empty_value
  @on_enter = nil
  # One widget, one surface: the editor paints no well of its own, so this
  # field's bg_color reaches the cells the editor paints.
  editor.bg_color = BG_INHERIT
  editor.on_change = lambda do |_text|
    handle_editor_change
    fire_if_changed if notify_on_edit?
  end
  add_child(editor, at: 0)
end

Instance Attribute Details

#editorAbstractStringField (readonly)

@return — the wrapped editor.

Returns:



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

def editor
  @editor
end

#on_enterProc, ...

@return — fired when ENTER is pressed, after #commit; see TextField#on_enter.

Returns:

  • (Proc, Method, nil)


149
150
151
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 149

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


187
188
189
190
191
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 187

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

#clearvoid

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



128
129
130
131
132
133
134
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 128

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
end

#commitvoid

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.



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

def commit = nil

#commit_and_notifyvoid

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.



261
262
263
264
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 261

def commit_and_notify
  commit
  fire_if_changed
end

#cursor_positionPoint?

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

Returns:



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

def cursor_position = editor.cursor_position

#default_bg_colorColor

The field well the face sits on — the editor is marked BG_INHERIT, so this one covers it (exactly one well per widget) and Tuile::Component#bg_color set here reaches the cells it paints.

Returns:



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

def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color

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


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

def empty?: () -> bool

#empty_valueObject

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

Returns:

  • (Object)


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

def empty_value: () -> Object

#error_bg_colorColor?

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:



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

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)


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

def error_ink?: () -> bool

#error_messageStyledString?

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

Returns:



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

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:



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

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

#fire_if_changedvoid

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.



270
271
272
273
274
275
276
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 270

def fire_if_changed
  v = value
  return if v == @last_value

  @last_value = v
  on_value_change&.call(v)
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)


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

def focusable?: () -> bool

#handle_editor_changevoid

This method returns an undefined value.

Called whenever the editor's buffer changes, however the characters arrived — a typed key, a paste, or a #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).



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

def handle_editor_change; end

#handle_focusvoid

This method returns an undefined value.



194
195
196
197
198
199
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 194

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 nil, 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)


173
174
175
176
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 173

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


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

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

#layout(editor) ⇒ void

This method returns an undefined value.

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

@param editor

Parameters:



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

def layout(editor) = (editor.rect = rect)

#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 value= too, or a programmatic write and an Up/Down step go unannounced until the next commit.

Returns:

  • (Boolean)


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

def notify_on_edit? = true

#placeholderString?

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

Returns:

  • (String, nil)


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

def placeholder = editor.placeholder

#placeholder=(text) ⇒ void

This method returns an undefined value.

@param text

Parameters:

  • text (String, nil)


143
144
145
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 143

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

#rect=(new_rect) ⇒ void

This method returns an undefined value.

@param new_rect

Parameters:



203
204
205
206
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 203

def rect=(new_rect)
  super
  layout(editor)
end

#valueObject

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

Returns:

  • (Object)


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

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

#value=(new_value) ⇒ void

This method returns an undefined value.

Writes new_value into the editor's buffer.

@param new_value

Parameters:

  • new_value (Object)


120
121
122
# File 'lib/tuile/component/abstract_wrapping_field.rb', line 120

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