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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +32 -13
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- 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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
|
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 *
|
|
32
|
-
#
|
|
33
|
-
#
|
|
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 <
|
|
37
|
-
include
|
|
38
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (`
|
|
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
|