tuile 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -10,12 +10,14 @@ module Tuile
10
10
  # field.value = 19.99 # field shows "19.99"
11
11
  # field.clear # empties it; value => nil
12
12
  #
13
- # Only `0`–`9`, one leading `-` and one `.` can be typed; any other
14
- # printable key is dropped without moving the caret. Up/Down step by `1.0`
15
- # (an empty field counting as `0.0`). A `Float` is a binary double, so this
16
- # is the wrong field for money — hold that as `Integer` cents in an
17
- # {IntegerField} — and range checks (`min`/`max`) belong to a forms layer,
18
- # not here.
13
+ # The buffer only ever holds `0`–`9`, one leading `-`, one `.` and an
14
+ # optional exponent: a key that would break that is dropped without moving
15
+ # the caret, and so is a *paste* that would (a European `"1,5"` lands
16
+ # nothing, rather than sieving through as the plausible, wrong `"15"`).
17
+ # Up/Down step by `1.0` (an empty field counting as `0.0`). A `Float` is a
18
+ # binary double, so this is the wrong field for money — hold that as
19
+ # `Integer` cents in an {IntegerField} — and range checks (`min`/`max`)
20
+ # belong to a forms layer, not here.
19
21
  #
20
22
  # == Implementation details
21
23
  # {#value} is a *derived parse*: the buffer is the single source of truth,
@@ -26,16 +28,21 @@ module Tuile
26
28
  # {#on_value_change} — which fires per keystroke, but only on a real *value*
27
29
  # change (`"7"`→`"07"` is silent). The parse also accepts the exponent
28
30
  # `Float#to_s` writes for extreme magnitudes, so `value = 1e-5` round-trips
29
- # through the `"1.0e-05"` it displays, though no key types an `e`.
31
+ # through the `"1.0e-05"` it displays — and `e` is typeable, so what the
32
+ # field displays is always something the user can go on editing.
30
33
  #
31
- # It *composes* a {TextField} (its single {HasContent} child) rather than
32
- # subclassing one, so its face carries only the typed {HasValue} seam, never
33
- # the widget's `String`-typed `text`.
34
+ # It *wraps* a {TextField} rather than subclassing one, so its face carries
35
+ # only the typed {HasValue} seam and never the widget's `String`-typed
36
+ # `text`; {AbstractWrappingField} supplies the wrapping.
34
37
  #
35
38
  # UI-thread-confined, like every component (see {Screen}).
36
- class FloatField < Component
37
- include HasContent
38
- include HasValue
39
+ class FloatField < AbstractWrappingField
40
+ include HasBadInput
41
+
42
+ # @return [String] what {#bad_input_message} reports for a buffer that is
43
+ # typeable but not a number.
44
+ BAD_INPUT_MESSAGE = "not a number"
45
+ private_constant :BAD_INPUT_MESSAGE
39
46
 
40
47
  # A buffer {#value} parses: an optional sign, digits with an optional
41
48
  # fractional part (either side may be empty, but not both), and the
@@ -44,19 +51,42 @@ module Tuile
44
51
  NUMERIC = /\A-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
45
52
  private_constant :NUMERIC
46
53
 
54
+ # The face: a {TextField} that admits only the buffers a float can be
55
+ # typed through, however the characters arrive.
56
+ class Field < TextField
57
+ # Buffers reachable by typing a float: an optional leading `-`, digits
58
+ # with at most one `.`, and an optional exponent. Looser than
59
+ # {NUMERIC} on purpose — `""`, `"-"`, `"1."` and `"1e"` are members, or
60
+ # the values past them could not be typed at all.
61
+ # @return [Regexp]
62
+ TYPEABLE = /\A-?\d*(?:\.\d*)?(?:[eE][-+]?\d*)?\z/
63
+ private_constant :TYPEABLE
64
+
65
+ protected
66
+
67
+ # Accepts the insertion only if the whole resulting buffer is still
68
+ # typeable, so a European `"1,5"` is dropped rather than sieved into the
69
+ # plausible, wrong `"15"`.
70
+ # @param str [String]
71
+ # @return [Boolean] true if the text changed.
72
+ def insert_text(str)
73
+ return false unless TYPEABLE.match?(@text.dup.insert(@caret, str))
74
+
75
+ super
76
+ end
77
+ end
78
+
47
79
  def initialize
48
- super()
49
- @last_value = nil
50
- field = TextField.new
51
- field.on_change = ->(_text) { fire_if_changed }
52
- field.on_key = method(:field_key)
53
- self.content = field
80
+ super(Field.new)
81
+ # Not the general on_key interceptor: that slot stays free for the app.
82
+ editor.on_key_up = -> { step(1.0) }
83
+ editor.on_key_down = -> { step(-1.0) }
54
84
  end
55
85
 
56
86
  # @return [Float, nil] the parsed buffer; `nil` when empty or not a
57
87
  # number (e.g. a lone `"-"`).
58
88
  def value
59
- text = content.text
89
+ text = editor.text
60
90
  text.match?(NUMERIC) ? text.to_f : nil
61
91
  end
62
92
 
@@ -68,34 +98,19 @@ module Tuile
68
98
  # @raise [TypeError] on a value `Float()` won't take at all (an `Array`).
69
99
  # @return [void]
70
100
  def value=(new_value)
71
- content.text = new_value.nil? ? "" : coerce(new_value).to_s
72
- content.caret = content.text.length
101
+ editor.text = new_value.nil? ? "" : coerce(new_value).to_s
102
+ editor.caret = editor.text.length
73
103
  end
74
104
 
75
105
  # `nil`, not `""`: a numeric field with no parseable number is empty.
76
106
  # @return [nil]
77
107
  def empty_value = nil
78
108
 
79
- # @return [Point, nil] the field's caret (the hardware cursor is delegated
80
- # to the inner field).
81
- def cursor_position = content.cursor_position
82
-
83
- # Fired when ENTER is pressed in the field; see {TextField#on_enter}.
84
- # @return [Proc, Method, nil] no-arg callable, or nil.
85
- def on_enter = content.on_enter
86
-
87
- # @param callback [Proc, Method, nil]
88
- # @return [void]
89
- def on_enter=(callback)
90
- content.on_enter = callback
91
- end
92
-
93
- protected
94
-
95
- # Places the wrapped field across the whole rect ({HasContent} hook).
96
- # @param field [Component]
97
- # @return [void]
98
- def layout(field) = (field.rect = rect)
109
+ # `"-"`, `"."`, `"-."` and every exponent in progress (`"1e"`, `"1.0e-"`,
110
+ # …) are typeable and parse to nothing; an *empty* buffer is empty, not
111
+ # bad ({HasBadInput}).
112
+ # @return [String, nil]
113
+ def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
99
114
 
100
115
  private
101
116
 
@@ -111,51 +126,11 @@ module Tuile
111
126
  float
112
127
  end
113
128
 
114
- # The field's key interceptor, consulted *before* the field acts on the
115
- # key — which is what lets a rejected character be swallowed without the
116
- # caret ever moving.
117
- # @param key [String]
118
- # @return [Boolean] true to consume the key.
119
- def field_key(key)
120
- case key
121
- when Keys::UP_ARROW then step(1.0)
122
- when Keys::DOWN_ARROW then step(-1.0)
123
- else return Keys.printable?(key) && !accepts?(key)
124
- end
125
- true
126
- end
127
-
128
129
  # Nudges {#value} by `delta`, treating an empty/un-parseable field as
129
130
  # `0.0`.
130
131
  # @param delta [Float]
131
132
  # @return [void]
132
133
  def step(delta) = (self.value = (value || 0.0) + delta)
133
-
134
- # Whether `char` may be inserted. Deliberately shallow: it keeps the
135
- # buffer *typeable* rather than always-valid — a transient `"-"` or
136
- # `"1."` has to be reachable — and {#value} decides what parses.
137
- # @param char [String] a single printable character.
138
- # @return [Boolean]
139
- def accepts?(char)
140
- case char
141
- when /\A[0-9]\z/ then true
142
- when "-" then content.caret.zero? && !content.text.start_with?("-")
143
- when "." then !content.text.include?(".")
144
- else false
145
- end
146
- end
147
-
148
- # Re-emits {#on_value_change} with the freshly-parsed {#value}, but only
149
- # when it differs from the last one fired — so a buffer edit that leaves
150
- # the value unchanged (`"7"`→`"07"`) stays silent.
151
- # @return [void]
152
- def fire_if_changed
153
- v = value
154
- return if v == @last_value
155
-
156
- @last_value = v
157
- on_value_change&.call(v)
158
- end
159
134
  end
160
135
  end
161
136
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # The one fact a parsing field knows that {HasValue#on_value_change} cannot
6
+ # carry: the input is something the field's value cannot represent.
7
+ #
8
+ # field = Component::IntegerField.new
9
+ # # …the user types a lone minus, which no Integer can represent:
10
+ # field.value # => nil, exactly as for an untouched field
11
+ # field.empty? # => true, likewise — empty of *value*
12
+ # field.bad_input? # => true
13
+ # field.bad_input_message # => "not a whole number"
14
+ #
15
+ # So a form asks this *before* `empty?`, on every field that can answer:
16
+ #
17
+ # bad = fields.select { _1.respond_to?(:bad_input?) && _1.bad_input? }
18
+ # Component::ConfirmWindow.alert("Cannot save", bad.map(&:bad_input_message).join("\n")) if bad.any?
19
+ #
20
+ # Include it in a field whose parse is *partial* — whose input can be
21
+ # something its value cannot represent, as a date field's can. Not in a
22
+ # {ComboBox}, the near miss: its input is a *filter* rather than a
23
+ # formatting of the value, so a no-match is not a failed conversion and it
24
+ # reverts the query instead.
25
+ #
26
+ # == Implementation details
27
+ # An includer overrides {#bad_input_message} and nothing else. Two rules
28
+ # bind that override, and `design/decisions.md` `D_bad_input` has the why:
29
+ #
30
+ # - **Empty input is not bad input.** Return `nil` for an empty buffer even
31
+ # though it parses to nothing, or every blank *optional* field blocks a
32
+ # save.
33
+ # - **One frozen constant per field kind, no interpolation** — `"not a
34
+ # valid date"`, never `"'xyz' is not a valid date"`. It is read per call,
35
+ # and a future error ink would read it per paint.
36
+ #
37
+ # Ask at the moment you need the answer and you always get the current one:
38
+ # it is derived on read, never stored. Which fields *can* answer is a class
39
+ # fact worth caching; what they answer is not. There is deliberately no
40
+ # change notice, because the fact is *continuous* — every prefix of a valid
41
+ # date is bad input — so anything reacting per keystroke flashes through the
42
+ # act of typing correctly, while a save gate consulted at a click sees one
43
+ # settled state. The **ink** is the one consumer that cannot be asked at a
44
+ # click, so it has {#bad_input_settled?}: a field whose whole grammar is
45
+ # prefix-bad latches the well on its commit gesture instead of painting it
46
+ # per keystroke.
47
+ module HasBadInput
48
+ # Pinned rather than relied on: {#error_ink?} calls `super`, so
49
+ # {HasValidation} must be below this module in the ancestor chain whatever
50
+ # order an includer writes its `include` lines in.
51
+ include HasValidation
52
+
53
+ # Why the current input cannot be turned into a {HasValue#value} — the
54
+ # single override point.
55
+ # @return [String, nil] the reason, or `nil` when the input converts (a
56
+ # field holding *no* input converts: it is empty, not bad).
57
+ # @raise [NotImplementedError] unless the includer overrides it.
58
+ def bad_input_message = raise(NotImplementedError, "#{self.class} must implement bad_input_message")
59
+
60
+ # @return [Boolean] true iff the field is holding input its value cannot
61
+ # represent.
62
+ def bad_input? = !bad_input_message.nil?
63
+
64
+ protected
65
+
66
+ # Widens {HasValidation#error_ink?}: bad input paints the invalid well
67
+ # too, with no verdict written — once {#bad_input_settled?} says the
68
+ # report may be shown.
69
+ # @return [Boolean]
70
+ def error_ink? = (bad_input? && bad_input_settled?) || super
71
+
72
+ # Whether bad input may paint the well *yet*. `true` here, so the well is
73
+ # as continuous as the report: a {FloatField} reddens at the half-typed
74
+ # `"1."`, which is a fair warning while the residue is one or two
75
+ # transient buffers. Override it to *latch* where the grammar makes
76
+ # **every** prefix bad input, or the well is red for the whole time the
77
+ # user types a correct value:
78
+ #
79
+ # def bad_input_settled? = @settled # set on commit, cleared on an edit
80
+ #
81
+ # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
82
+ # asking at a click must get the answer settled or not (`design/decisions.md`
83
+ # `D_bad_input`).
84
+ # @return [Boolean]
85
+ def bad_input_settled? = true
86
+ end
87
+ end
88
+ end
@@ -39,6 +39,14 @@ module Tuile
39
39
  @caption = new_caption
40
40
  invalidate
41
41
  end
42
+
43
+ protected
44
+
45
+ # Adds `caption="…"` to {Component#inspect}, omitted while empty.
46
+ # @return [Array<String>]
47
+ def inspect_details
48
+ caption.empty? ? super : super + ["caption=#{caption.to_s.inspect}"]
49
+ end
42
50
  end
43
51
  end
44
52
  end
@@ -2,9 +2,20 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A component that owns exactly one child *directly*, under the name
6
- # `content`. The includer initializes `@content` to nil and provides a
7
- # protected `layout(content)` positioning the child; the mixin owns the swap:
5
+ # A component with a *primary* child named `content`: **this is my content,
6
+ # which you populate; my other children are mine to manage, not yours to
7
+ # address**. Include it wherever addressing that child is the point — a
8
+ # {Slot} is a bare region for one, a {Window} frames one, an {Overlay}
9
+ # floats one.
10
+ #
11
+ # It says nothing about *arity*: {Window} has two app-settable children, and
12
+ # this mixin names which of them is *the* content. Nor about permanence — an
13
+ # {Overlay}'s body is permanent and public both. A container with *several*
14
+ # populatable regions gives each one a {Slot} rather than including this
15
+ # twice.
16
+ #
17
+ # The includer initializes `@content` to nil and provides a protected
18
+ # `layout(content)` positioning the child; the mixin owns the swap:
8
19
  #
9
20
  # class Slot < Component
10
21
  # include Component::HasContent
@@ -16,14 +27,20 @@ module Tuile
16
27
  # def layout(content) = content.rect = rect
17
28
  # end
18
29
  #
19
- # Include it when the child is **permanent and integral** — a typed field's
20
- # inner {TextField}, an {Overlay}'s body. It does *not* mean "a component
21
- # with one child": an includer may hold others alongside, as {Window} does
22
- # with its footer. For a region an app swaps, hold a {Slot} instead.
30
+ # **A child that is private machinery stays out**, because {#content=} ships
31
+ # public and re-checks nothing its owner depends on — swapping in a
32
+ # component of the wrong type succeeds, and breaks the owner at the next
33
+ # call. Such a component owns its child outright instead, with `add_child`
34
+ # in the constructor and placement from `rect=`. Both in-tree shapes are
35
+ # worth knowing: {AbstractWrappingField} hides its editor completely, while
36
+ # {CheckboxGroup} and {RadioGroup} expose theirs **read-only** as `list` —
37
+ # an app tunes that {List}, but never supplies it. *Populate* is the word
38
+ # doing the work here: addressable is not the same as yours.
23
39
  #
24
40
  # A tree walk finds content generically through `is_a?(HasContent)` plus a
25
41
  # `content` compare, which is why this is a mixin rather than a per-class
26
- # accessor — the same reason {HasCaption} is one.
42
+ # accessor — the same reason {HasCaption} is one. Under the rule above such
43
+ # a walk reaches only *public* contents, never a widget's private editor.
27
44
  module HasContent
28
45
  # @return [Component, nil] the current content component.
29
46
  attr_reader :content
@@ -45,7 +62,7 @@ module Tuile
45
62
 
46
63
  old = self.content
47
64
  # Detached without notifying, and notified at the very end: the focus
48
- # repair in on_child_removed cascades into whatever occupies the slot
65
+ # repair in handle_child_removed cascades into whatever occupies the slot
49
66
  # *now*, so it has to see the new content (window_spec pins it).
50
67
  detach_child(old) unless old.nil?
51
68
  @content = content
@@ -54,7 +71,7 @@ module Tuile
54
71
  content.invalidate
55
72
  layout(content)
56
73
  end
57
- on_child_removed(old) unless old.nil?
74
+ handle_child_removed(old) unless old.nil?
58
75
  end
59
76
 
60
77
  # @param rect [Rect]
@@ -65,11 +82,13 @@ module Tuile
65
82
  end
66
83
 
67
84
  # @return [void]
68
- def on_focus
85
+ def handle_focus
69
86
  super
70
87
  # Let the content component receive focus, so that it can immediately
71
- # start responding to key presses.
72
- screen.focused = content if !content.nil? && content.focusable?
88
+ # start responding to key presses. Hidden content is left alone, so
89
+ # focus parks here — where a container with nothing to forward to
90
+ # leaves it anyway.
91
+ screen.focused = content if !content.nil? && content.visible? && content.focusable?
73
92
  end
74
93
  end
75
94
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # The hint a field paints in its own cells while it holds nothing — a format
6
+ # the user could not otherwise guess:
7
+ #
8
+ # field = Component::TextField.new
9
+ # field.placeholder = "dd.mm.yyyy"
10
+ # field.text.empty? # => true — the hint is not content
11
+ #
12
+ # Paint-only in every direction: it never enters {HasValue#value}, never
13
+ # fires `on_value_change`, is never reached by a paste, and never counts
14
+ # against {TextField#max_text_length}. That asymmetry *is* the feature — a
15
+ # placeholder living in the buffer would be a default value, and a form
16
+ # saving it would write `"dd.mm.yyyy"` to the database.
17
+ #
18
+ # Shown whenever the field is empty, **focus included**: the format hint is
19
+ # wanted most precisely while the user is typing into the field.
20
+ #
21
+ # Include it in a field whose *input shape* is unguessable from an empty
22
+ # well. Not in {Select}, the near miss: a blank face plus `▾` already reads
23
+ # as "nothing picked", so an absent enum *value* needs no hint the way an
24
+ # unguessable input *format* does (`design/decisions.md` `D_select`).
25
+ #
26
+ # == Implementation details
27
+ # The ink is {Theme#placeholder_color}, calibrated to be *barely* visible —
28
+ # which is why the hint is a plain `String`: a color baked in by the app
29
+ # would go stale on the next theme flip and defeat that calibration.
30
+ #
31
+ # Being a mixin is what lets a tree walk find every hintable field via
32
+ # `is_a?(HasPlaceholder)`, whatever their classes. A field that *composes*
33
+ # another overrides both accessors to delegate; this module's storage is the
34
+ # leaf's alone.
35
+ module HasPlaceholder
36
+ # @return [String, nil] the hint, or `nil` when there is none (default).
37
+ def placeholder = @placeholder
38
+
39
+ # Sets the hint and invalidates the component. No-op when unchanged.
40
+ # @param text [String, nil] `nil` removes it.
41
+ # @return [void]
42
+ # @raise [TypeError] unless `text` is a String or nil — notably a
43
+ # {StyledString} is refused rather than flattened, since the ink is the
44
+ # theme's to choose.
45
+ def placeholder=(text)
46
+ raise TypeError, "expected String or nil, got #{text.inspect}" unless text.nil? || text.instance_of?(String)
47
+ return if @placeholder == text
48
+
49
+ @placeholder = text
50
+ invalidate
51
+ end
52
+
53
+ protected
54
+
55
+ # Adds `placeholder="…"` to {Component#inspect}, omitted while unset.
56
+ # @return [Array<String>]
57
+ def inspect_details
58
+ placeholder.nil? ? super : super + ["placeholder=#{placeholder.inspect}"]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A field's verdict slot: one message, written from *outside* the field, and
6
+ # shown by the field as a red *well* — {Theme#error_bg_color}, or
7
+ # {Theme#error_active_bg_color} while focused.
8
+ #
9
+ # login = Component::Button.new(caption: "Log in")
10
+ # login.on_click = lambda do
11
+ # username.error_message = username.empty? ? "Required" : nil
12
+ # password.error_message = password.empty? ? "Required" : nil
13
+ # next if [username, password].any?(&:error_message)
14
+ #
15
+ # authenticate(username.value, password.value)
16
+ # end
17
+ #
18
+ # The field turns red on its own; the *message* needs cells the field
19
+ # doesn't own, so whoever has them — a `FormLayout`, or an app's own
20
+ # {Label} — subscribes to {#on_error_message_change} and paints the text in
21
+ # {Theme#error_color}.
22
+ #
23
+ # Included by {HasValue}, so every field has it. Include it directly in a
24
+ # component that can be invalid without being a field (a form section
25
+ # wrapping several).
26
+ #
27
+ # == Implementation details
28
+ # **A well, not ink on the glyphs.** A field's background is what shows its
29
+ # boundary in the first place, so an invalid field gets a red one and loses
30
+ # nothing — where red *text* is invisible on the empty field that is the
31
+ # required-field case, and invisible again on content carrying colors of its
32
+ # own. It takes two tokens rather than one because a focused invalid field
33
+ # still has to look focused (`design/decisions.md` `D_has_validation`).
34
+ #
35
+ # The well reaches the whole widget with nothing forwarding it: a composed
36
+ # field's inner face is marked {Component::BG_INHERIT} and a group's {List}
37
+ # declares no background, so both walk up the ordinary background chain and
38
+ # land on the composer's answer.
39
+ #
40
+ # **The field never writes this.** It computes no verdicts — it cannot see
41
+ # the sibling a rule compares against — so it has nothing to write, and that
42
+ # is what leaves exactly one writer: whoever validates. The discipline that
43
+ # writer owes is one sentence: **set *or clear* it on every validate pass**,
44
+ # as the example above does with its `: nil` branches. Vaadin's custom-field
45
+ # guide warns that sharing one `invalid` cell between internal and external
46
+ # validation ends with each overriding the other; Tuile's answer is that the
47
+ # field's *own* report is a different member ({HasBadInput#bad_input?} —
48
+ # derived on read, never stored), so the two never share a cell.
49
+ #
50
+ # There is deliberately **no `invalid?`**: a second predicate beside
51
+ # `bad_input?` gives a caller no way to know which to ask, and invalid *is*
52
+ # a non-nil message. Assign `""` for a verdict with nothing to say.
53
+ #
54
+ # Unlike `bad_input?`, this fact is *discrete* — asserted at a click or a
55
+ # binder pass, not recomputed per keystroke — which is why it carries a
56
+ # change notice where `bad_input?` deliberately doesn't (`design/decisions.md`
57
+ # `D_bad_input`, `D_has_validation`).
58
+ module HasValidation
59
+ # @return [Proc, Method, nil] one-arg callable fired with the new message
60
+ # (or `nil`) whenever {#error_message} actually changes — never on a
61
+ # no-op set. Claimed by the container that paints the message; an app
62
+ # painting its own takes it instead.
63
+ attr_accessor :on_error_message_change
64
+
65
+ # @return [StyledString, nil] why the field is invalid, or `nil` when it
66
+ # is not; `nil` until something sets it.
67
+ def error_message = @error_message
68
+
69
+ # Sets the verdict and repaints the field in {Theme#error_color}; `nil`
70
+ # clears it. No-op (no repaint, no listener) when unchanged. A `String` is
71
+ # parsed via {StyledString.parse}, as {HasCaption#caption=} does.
72
+ #
73
+ # Safe on a detached field — an app validates a form it assembled but has
74
+ # not mounted, and {Component#invalidate} is already a no-op there.
75
+ # @param new_message [String, StyledString, nil]
76
+ # @return [void]
77
+ def error_message=(new_message)
78
+ new_message = StyledString.parse(new_message) unless new_message.nil?
79
+ return if error_message == new_message
80
+
81
+ @error_message = new_message
82
+ invalidate
83
+ on_error_message_change&.call(new_message)
84
+ end
85
+
86
+ protected
87
+
88
+ # The invalid well, picked up by everything this component paints —
89
+ # including the inner face of a composed field and the {List} of a group,
90
+ # neither of which forwards anything: both declare no background of their
91
+ # own, so the ordinary chain walks up to this (overrides
92
+ # {Component#error_bg_color}).
93
+ # @return [Color, nil]
94
+ def error_bg_color
95
+ return nil unless error_ink?
96
+
97
+ active? ? screen.theme.error_active_bg_color : screen.theme.error_bg_color
98
+ end
99
+
100
+ # Whether to paint the invalid well right now. Its own hook because
101
+ # {HasBadInput} widens it: a field holding input its value cannot
102
+ # represent is invalid on the face too, even with no verdict written.
103
+ # @return [Boolean]
104
+ def error_ink? = !error_message.nil?
105
+
106
+ # Adds `error_message=…` to {Component#inspect}, omitted while valid — so
107
+ # a {Testing.get} failure dump says which field is already flagged.
108
+ # @return [Array<String>]
109
+ def inspect_details
110
+ m = error_message
111
+ m.nil? ? super : super + ["error_message=#{m.to_s.inspect}"]
112
+ end
113
+ end
114
+ end
115
+ end
@@ -18,11 +18,16 @@ module Tuile
18
18
  # ({AbstractStringField} backs them with its text buffer). Override {#empty_value}
19
19
  # when the empty sentinel isn't `nil` (a text field's is `""`).
20
20
  #
21
+ # {HasValidation} comes with it, so every field carries the
22
+ # `error_message` a validator writes and paints its own error ink.
23
+ #
21
24
  # == Implementation details
22
25
  # Deliberately smaller than Vaadin's `HasValue`: read-only,
23
26
  # required-indicator, the from-client/old-value event payload, and
24
27
  # converters all belong to the not-yet-built form layer, not here.
25
28
  module HasValue
29
+ include HasValidation
30
+
26
31
  # @return [Proc, Method, nil] one-arg callable fired with the new value
27
32
  # whenever {#value} actually changes — never on a no-op set.
28
33
  attr_accessor :on_value_change
@@ -41,10 +46,18 @@ module Tuile
41
46
  on_value_change&.call(new_value)
42
47
  end
43
48
 
49
+ # Empty of *value*: a field whose parse is partial reports `true` while the
50
+ # user is looking at glyphs it could not use, so ask
51
+ # {HasBadInput#bad_input?} first.
44
52
  # @return [Boolean] true iff {#value} equals {#empty_value}.
45
53
  def empty? = value == empty_value
46
54
 
47
55
  # Resets {#value} to {#empty_value}.
56
+ #
57
+ # An includer whose input can outrun its value ({HasBadInput}) must clear
58
+ # the *input*: a field holding bad input already reads `empty_value`, so
59
+ # inheriting this default — over a {#value=} that returns early on a no-op
60
+ # set — is a `clear` that leaves the garbage on screen.
48
61
  # @return [void]
49
62
  def clear = (self.value = empty_value)
50
63
 
@@ -55,10 +68,24 @@ module Tuile
55
68
  # Input fields are focusable by default (overrides {Component#focusable?});
56
69
  # a read-only display field could override back to `false`. Only
57
70
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
58
- # composing wrappers, so it stays per-class (`DECISIONS.md`
71
+ # composing wrappers, so it stays per-class (`design/decisions.md`
59
72
  # `D_integer_field`).
60
73
  # @return [Boolean]
61
74
  def focusable? = true
75
+
76
+ protected
77
+
78
+ # Adds `value=…` to {Component#inspect}, omitted while the value is nil.
79
+ # @return [Array<String>]
80
+ def inspect_details
81
+ v = value
82
+ return super if v.nil?
83
+
84
+ # Truncate before #inspect, not after: a TextArea's value is its whole
85
+ # buffer.
86
+ v = "#{v[0, 40]}…" if v.is_a?(String) && v.length > 40
87
+ super + ["value=#{v.inspect}"]
88
+ end
62
89
  end
63
90
  end
64
91
  end