Class: Tuile::Component::BigDecimalField

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

Overview

A single-line field whose #value is a BigDecimal (or nil when empty) — the numeric field for money, where FloatField's binary double would round. Give it a single-row #rect:

price = Component::BigDecimalField.new
price.on_value_change = ->(d) { total.value = d }   # BigDecimal or nil
price.value = BigDecimal("19.99")                   # field shows "19.99"
price.value = 19.99                                 # ArgumentError: a Float can't be exact

Only 09, one leading - and one . can be typed; any other printable key is dropped without moving the caret. Up/Down step by one. Range checks (min/max) and a display scale (19.919.90) belong to a forms layer, not here — nothing rounds or pads what you typed.

Requires the bigdecimal gem, which Tuile does not depend on: it is a bundled gem from Ruby 3.4 on, so a Gemfile naming it is what puts it on the load path. Referencing this class without it raises LoadError.

Implementation details

#value is a derived parse: the buffer is the single source of truth, recomputed on read and left exactly as typed ("19.90" keeps its zero, which BigDecimal#to_s would not). It reads nil for a buffer that isn't a number ("", a lone "-") but 1 / 0.5 for a half-typed "1." / ".5", so reaching for the decimal point doesn't blink the value to nil and back through HasValue#on_value_change — which fires per keystroke, but only on a real value change ("1.0""1.00" is silent, since the two compare equal).

Both ends of that round-trip are written here rather than left to the library, because bigdecimal 3.1 (Ruby 3.3's default gem) and 4.x disagree about them: 3.1 rejects BigDecimal("1.") and BigDecimal(0.1) where 4.x accepts both. So the buffer is normalized before parsing, a Float is refused on both, and display goes through to_s("F") — plain notation, never BigDecimal#to_s's "0.1999e2".

It composes a TextField (its single HasContent child) rather than subclassing one, so its face carries only the typed HasValue seam, never the widget's String-typed text.

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

Constant Summary collapse

NUMERIC =

A buffer #value parses: an optional sign and digits with an optional fractional part (either side may be empty, but not both). No exponent — to_s("F") never writes one and no key types an e.

Returns:

  • (Regexp)
/\A-?(?:\d+(?:\.\d*)?|\.\d+)\z/

Instance Attribute Summary

Attributes included from HasValue

#on_value_change

Attributes included from HasContent

#content

Instance Method Summary collapse

Constructor Details

#initializeBigDecimalField

Returns a new instance of BigDecimalField.



67
68
69
70
71
72
73
74
# File 'lib/tuile/component/big_decimal_field.rb', line 67

def initialize
  super()
  @last_value = nil
  field = TextField.new
  field.on_change = ->(_text) { fire_if_changed }
  field.on_key = method(:field_key)
  self.content = field
end

Instance Method Details

#accepts?(char) ⇒ Boolean

Whether char may be inserted. Deliberately shallow: it keeps the buffer typeable rather than always-valid — a transient "-" or "1." has to be reachable — and #value decides what parses.

@param char — a single printable character.

Parameters:

  • char (String)

Returns:

  • (Boolean)


177
178
179
180
181
182
183
184
# File 'lib/tuile/component/big_decimal_field.rb', line 177

def accepts?(char)
  case char
  when /\A[0-9]\z/ then true
  when "-" then content.caret.zero? && !content.text.start_with?("-")
  when "." then !content.text.include?(".")
  else false
  end
end

#clearvoid

This method returns an undefined value.

Resets #value to #empty_value.



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

def clear: () -> void

#coerce(new_value) ⇒ ::BigDecimal

@param new_value

Parameters:

  • new_value (::BigDecimal, Integer, String)

Returns:

  • (::BigDecimal)


141
142
143
144
145
146
147
148
149
150
# File 'lib/tuile/component/big_decimal_field.rb', line 141

def coerce(new_value)
  if new_value.is_a?(Float)
    raise ArgumentError, "a Float is not exact — pass BigDecimal(#{new_value.to_s.inspect}) or the String"
  end

  big = BigDecimal(new_value)
  raise ArgumentError, "value must be finite, got #{big}" unless big.finite?

  big
end

#cursor_positionPoint?

@return — the field's caret (the hardware cursor is delegated to the inner field).

Returns:



104
# File 'lib/tuile/component/big_decimal_field.rb', line 104

def cursor_position = content.cursor_position

#empty?Boolean

@return — true iff #value equals #empty_value.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_valuevoid

This method returns an undefined value.

nil, not "": a numeric field with no parseable number is empty.



100
# File 'lib/tuile/component/big_decimal_field.rb', line 100

def empty_value = nil

#field_key(key) ⇒ Boolean

The field's key interceptor, consulted before the field acts on the key — which is what lets a rejected character be swallowed without the caret ever moving.

@param key

@return — true to consume the key.

Parameters:

  • key (String)

Returns:

  • (Boolean)


157
158
159
160
161
162
163
164
# File 'lib/tuile/component/big_decimal_field.rb', line 157

def field_key(key)
  case key
  when Keys::UP_ARROW then step(1)
  when Keys::DOWN_ARROW then step(-1)
  else return Keys.printable?(key) && !accepts?(key)
  end
  true
end

#fire_if_changedvoid

This method returns an undefined value.

Re-emits HasValue#on_value_change with the freshly-parsed #value, but only when it differs from the last one fired — so a buffer edit that leaves the value unchanged ("1.0""1.00") stays silent.



190
191
192
193
194
195
196
# File 'lib/tuile/component/big_decimal_field.rb', line 190

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 (DECISIONS.md D-integer-field).

Returns:

  • (Boolean)


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

def focusable?: () -> bool

#handle_mousevoid

This method returns an undefined value.

@param event

Parameters:



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

def handle_mouse: (MouseEvent event) -> void

#layout(field) ⇒ void

This method returns an undefined value.

Places the wrapped field across the whole rect (HasContent hook).

@param field

Parameters:



121
# File 'lib/tuile/component/big_decimal_field.rb', line 121

def layout(field) = (field.rect = rect)

#normalize(text) ⇒ String

Rewrites the half-typed shapes NUMERIC admits into ones every bigdecimal version parses: ".5""0.5", "1.""1".

@param text — a buffer matching NUMERIC.

Parameters:

  • text (String)

Returns:

  • (String)


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

def normalize(text)
  text = text.sub(".", "0.") if text.start_with?(".", "-.")
  text.chomp(".")
end

#on_enterProc, ...

Fired when ENTER is pressed in the field; see TextField#on_enter.

@return — no-arg callable, or nil.

Returns:

  • (Proc, Method, nil)


108
# File 'lib/tuile/component/big_decimal_field.rb', line 108

def on_enter = content.on_enter

#on_enter=(callback) ⇒ void

This method returns an undefined value.

@param callback

Parameters:

  • callback (Proc, Method, nil)


112
113
114
# File 'lib/tuile/component/big_decimal_field.rb', line 112

def on_enter=(callback)
  content.on_enter = callback
end

#on_focusvoid

This method returns an undefined value.



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

def on_focus: () -> void

#rect=void

This method returns an undefined value.

@param rect

Parameters:



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

def rect=: (Rect rect) -> void

#step(delta) ⇒ void

This method returns an undefined value.

Nudges #value by delta, treating an empty/un-parseable field as zero.

@param delta

Parameters:

  • delta (Integer)


170
# File 'lib/tuile/component/big_decimal_field.rb', line 170

def step(delta) = (self.value = (value || BigDecimal(0)) + delta)

#value::BigDecimal?

@return — the parsed buffer; nil when empty or not a number (e.g. a lone "-").

Returns:

  • (::BigDecimal, nil)


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

def value
  text = content.text
  text.match?(NUMERIC) ? BigDecimal(normalize(text)) : nil
end

#value=(new_value) ⇒ void

This method returns an undefined value.

Writes new_value into the buffer in plain notation and parks the caret at its end; fires HasValue#on_value_change only if the value actually changed.

@param new_valuenil empties the field. A Float is refused, not converted — see the raise.

Parameters:

  • new_value (::BigDecimal, Integer, String, nil)


93
94
95
96
# File 'lib/tuile/component/big_decimal_field.rb', line 93

def value=(new_value)
  content.text = new_value.nil? ? "" : coerce(new_value).to_s("F")
  content.caret = content.text.length
end