Class: Tuile::Component::BigDecimalField

Inherits:
AbstractWrappingField show all
Includes:
HasBadInput
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

The buffer only ever holds 09, one leading - and one .: a key that would break that is dropped without moving the caret, and so is a paste that would ("$19.99" lands nothing, rather than sieving through as a price the user never copied). 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 wraps a TextField rather than subclassing one, so its face carries only the typed HasValue seam and never the widget's String-typed text; AbstractWrappingField supplies the wrapping.

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

Defined Under Namespace

Classes: Field

Constant Summary collapse

BAD_INPUT_MESSAGE =

Returns what #bad_input_message reports for a buffer that is typeable but not a decimal.

Returns:

  • (String)

    what #bad_input_message reports for a buffer that is typeable but not a decimal.

"not a decimal number"
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 HasValidation

#on_error_message_change

Attributes inherited from AbstractWrappingField

#editor, #on_enter

Attributes included from HasValue

#on_value_change

Instance Method Summary collapse

Methods inherited from AbstractWrappingField

#active=, #clear, #commit, #commit_and_notify, #cursor_position, #default_bg_color, #empty?, #fire_if_changed, #focusable?, #handle_editor_change, #handle_focus, #handle_key?, #layout, #notify_on_edit?, #placeholder, #placeholder=, #rect=

Methods included from HasPlaceholder

#placeholder, #placeholder=

Methods included from HasValue

#clear, #empty?, #focusable?

Constructor Details

#initializeBigDecimalField

Returns a new instance of BigDecimalField.



98
99
100
101
102
103
# File 'lib/tuile/component/big_decimal_field.rb', line 98

def initialize
  super(Field.new)
  # Not the general on_key interceptor: that slot stays free for the app.
  editor.on_key_up = -> { step(1) }
  editor.on_key_down = -> { step(-1) }
end

Instance Method Details

#bad_input?Boolean

@return — true iff the field is holding input its value cannot represent.

Returns:

  • (Boolean)


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

def bad_input?: () -> bool

#bad_input_messageString?

"-", "." and "-." are typeable and parse to nothing; an empty buffer is empty, not bad (HasBadInput).

Returns:

  • (String, nil)


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

def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil

#bad_input_settled?Boolean

Whether bad input may paint the well yet. true here, so the well is as continuous as the report: a FloatField reddens at the half-typed "1.", which is a fair warning while the residue is one or two transient buffers. Override it to latch where the grammar makes every prefix bad input, or the well is red for the whole time the user types a correct value:

def bad_input_settled? = @settled   # set on commit, cleared on an edit

It gates the ink only: #bad_input? is a pull, and a save gate asking at a click must get the answer settled or not (design/decisions.md D_bad_input).

Returns:

  • (Boolean)


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

def bad_input_settled?: () -> bool

#coerce(new_value) ⇒ ::BigDecimal

@param new_value

Parameters:

  • new_value (::BigDecimal, Integer, String)

Returns:

  • (::BigDecimal)


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

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

#empty_valuevoid

This method returns an undefined value.

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



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

def empty_value = nil

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



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

def error_bg_color: () -> Color?

#error_ink?Boolean

Widens HasValidation#error_ink?: bad input paints the invalid well too, with no verdict written — once #bad_input_settled? says the report may be shown.

Returns:

  • (Boolean)


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

def error_ink?: () -> bool

#error_messageStyledString?

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

Returns:



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

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:



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

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

#inspect_details::Array[String]

Adds error_message=… to Tuile::Component#inspect, omitted while valid — so a Testing.get failure dump says which field is already flagged.

Returns:

  • (::Array[String])


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

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

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


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

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

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


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

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)


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

def value
  text = editor.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)


122
123
124
125
# File 'lib/tuile/component/big_decimal_field.rb', line 122

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