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
|
@@ -38,7 +38,7 @@ module Tuile
|
|
|
38
38
|
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
39
39
|
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
40
40
|
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
41
|
-
# `
|
|
41
|
+
# `design/decisions.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
|
|
42
42
|
# beats a third convention, and if that bet is ever reversed both swap
|
|
43
43
|
# together.
|
|
44
44
|
class ProgressBar < Component
|
|
@@ -148,7 +148,7 @@ module Tuile
|
|
|
148
148
|
|
|
149
149
|
# Sets the color of both glyphs, live-resolved at paint time when given a
|
|
150
150
|
# {Theme::Ref} (so it follows a {Screen#theme=} with no
|
|
151
|
-
# {Component#
|
|
151
|
+
# {Component#handle_theme_changed} hook).
|
|
152
152
|
#
|
|
153
153
|
# bar.bar_color = Color::GREEN
|
|
154
154
|
# bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
|
|
@@ -192,10 +192,16 @@ module Tuile
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
# @return [void]
|
|
195
|
-
def
|
|
195
|
+
def handle_attached
|
|
196
|
+
super
|
|
197
|
+
sync_ticker
|
|
198
|
+
end
|
|
196
199
|
|
|
197
200
|
# @return [void]
|
|
198
|
-
def
|
|
201
|
+
def handle_detached
|
|
202
|
+
super
|
|
203
|
+
sync_ticker
|
|
204
|
+
end
|
|
199
205
|
|
|
200
206
|
# Paints the bar on the first row of {#rect} and blanks the rest.
|
|
201
207
|
#
|
|
@@ -22,12 +22,13 @@ module Tuile
|
|
|
22
22
|
# already-selected row is a no-op rather than a deselect.
|
|
23
23
|
#
|
|
24
24
|
# Composes rather than subclasses, like {ComboBox}: a {List} of the items
|
|
25
|
-
# is its single
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# `show_cursor_when_inactive`, …)
|
|
30
|
-
# inner list is the tab stop,
|
|
25
|
+
# is its single child, which is where the cursor, scrolling, the scrollbar
|
|
26
|
+
# and per-row mouse hit-testing come from — the group only supplies the
|
|
27
|
+
# {List#renderer} that puts the marker in front of the label. {#list} is
|
|
28
|
+
# that list, exposed read-only so an app can tune it
|
|
29
|
+
# (`scrollbar_visibility`, `show_cursor_when_inactive`, …) but never swap it
|
|
30
|
+
# out. Rows beyond {#rect}'s height scroll; the inner list is the tab stop,
|
|
31
|
+
# not the group.
|
|
31
32
|
#
|
|
32
33
|
# == The cursor is chrome
|
|
33
34
|
# The cursor and the selection are two independent things, as in
|
|
@@ -36,7 +37,7 @@ module Tuile
|
|
|
36
37
|
# {#value=} therefore does *not* move the cursor. An app that wants it
|
|
37
38
|
# parked on the selection parks it:
|
|
38
39
|
#
|
|
39
|
-
# rg.
|
|
40
|
+
# rg.list.cursor = List::Cursor.new(position: rg.items.index(rg.value))
|
|
40
41
|
#
|
|
41
42
|
# {#items=} is the one thing that moves it, clamping it back into range.
|
|
42
43
|
#
|
|
@@ -60,7 +61,6 @@ module Tuile
|
|
|
60
61
|
#
|
|
61
62
|
# UI-thread-confined, like every component (see {Screen}).
|
|
62
63
|
class RadioGroup < Component
|
|
63
|
-
include HasContent
|
|
64
64
|
include HasValue
|
|
65
65
|
|
|
66
66
|
# @param items [Array] the items to present, one row each; also settable
|
|
@@ -80,11 +80,36 @@ module Tuile
|
|
|
80
80
|
list.renderer = method(:render_row)
|
|
81
81
|
list.on_item_chosen = ->(_index, item) { self.value = item }
|
|
82
82
|
list.items = items.to_a
|
|
83
|
-
|
|
83
|
+
@list = list
|
|
84
|
+
add_child(list, at: 0)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The composed {List}: an app may *tune* it — its scrollbar, its cursor,
|
|
88
|
+
# `show_cursor_when_inactive` — but never replace it, since this group's
|
|
89
|
+
# renderer and selection are wired into this one (`design/decisions.md`
|
|
90
|
+
# `D_has_content`). Those knobs are {List} concepts rather than group
|
|
91
|
+
# concepts, which is why they are reached here instead of forwarded
|
|
92
|
+
# (`D_wrapping_field`).
|
|
93
|
+
# @return [List]
|
|
94
|
+
attr_reader :list
|
|
95
|
+
|
|
96
|
+
# @param new_rect [Rect]
|
|
97
|
+
# @return [void]
|
|
98
|
+
def rect=(new_rect)
|
|
99
|
+
super
|
|
100
|
+
list.rect = rect
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [void]
|
|
104
|
+
def handle_focus
|
|
105
|
+
super
|
|
106
|
+
# The list is what the arrows drive, so it takes the focus this group
|
|
107
|
+
# was given; the group itself claims only Space.
|
|
108
|
+
screen.focused = list if list.focusable?
|
|
84
109
|
end
|
|
85
110
|
|
|
86
111
|
# @return [Array] the presented items.
|
|
87
|
-
def items =
|
|
112
|
+
def items = list.items
|
|
88
113
|
|
|
89
114
|
# @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
|
|
90
115
|
# anything with `#to_s`); `:to_s` by default.
|
|
@@ -101,14 +126,14 @@ module Tuile
|
|
|
101
126
|
# Before the items land, so the single {List#on_cursor_changed} that
|
|
102
127
|
# {List#items=} fires reports the final row rather than a stale one.
|
|
103
128
|
clamp_cursor(new_items.size)
|
|
104
|
-
|
|
129
|
+
list.items = new_items
|
|
105
130
|
end
|
|
106
131
|
|
|
107
132
|
# @param proc [Proc, Method] item -> row label.
|
|
108
133
|
# @return [void]
|
|
109
134
|
def item_label=(proc)
|
|
110
135
|
@item_label = proc
|
|
111
|
-
|
|
136
|
+
list.refresh_rows
|
|
112
137
|
end
|
|
113
138
|
|
|
114
139
|
# Selects `new_value`, firing {HasValue#on_value_change} when it really
|
|
@@ -122,7 +147,7 @@ module Tuile
|
|
|
122
147
|
return if value == new_value
|
|
123
148
|
|
|
124
149
|
super
|
|
125
|
-
|
|
150
|
+
list.refresh_rows
|
|
126
151
|
end
|
|
127
152
|
|
|
128
153
|
# Selects the cursor row on Space. Nothing else is claimed: the composed
|
|
@@ -131,20 +156,13 @@ module Tuile
|
|
|
131
156
|
# neither of us wants bubbles on to an ancestor.
|
|
132
157
|
# @param key [String]
|
|
133
158
|
# @return [Boolean]
|
|
134
|
-
def handle_key(key)
|
|
159
|
+
def handle_key?(key)
|
|
135
160
|
return false unless key == " "
|
|
136
161
|
|
|
137
|
-
select_at(
|
|
162
|
+
select_at(list.cursor.position)
|
|
138
163
|
true
|
|
139
164
|
end
|
|
140
165
|
|
|
141
|
-
protected
|
|
142
|
-
|
|
143
|
-
# Places the composed list across the whole rect ({HasContent} hook).
|
|
144
|
-
# @param list [Component]
|
|
145
|
-
# @return [void]
|
|
146
|
-
def layout(list) = (list.rect = rect)
|
|
147
|
-
|
|
148
166
|
private
|
|
149
167
|
|
|
150
168
|
# Selects the item on row `index`; an index outside {#items} is ignored —
|
|
@@ -172,7 +190,7 @@ module Tuile
|
|
|
172
190
|
# @param item_count [Integer] size of the incoming item list.
|
|
173
191
|
# @return [void]
|
|
174
192
|
def clamp_cursor(item_count)
|
|
175
|
-
cursor =
|
|
193
|
+
cursor = list.cursor
|
|
176
194
|
# go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
|
|
177
195
|
# items list floors at 0 instead of going negative.
|
|
178
196
|
cursor.go_to_last(item_count) if cursor.position >= item_count
|
|
@@ -42,7 +42,7 @@ module Tuile
|
|
|
42
42
|
# Home/End are declined too, so they stay available app-wide.
|
|
43
43
|
#
|
|
44
44
|
# There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
|
|
45
|
-
# the feedback removed (`
|
|
45
|
+
# the feedback removed (`design/decisions.md` `D_select`). Which is also why labels
|
|
46
46
|
# need no prefix-disambiguation.
|
|
47
47
|
#
|
|
48
48
|
# == Implementation details
|
|
@@ -138,7 +138,7 @@ module Tuile
|
|
|
138
138
|
# is left unhandled so it bubbles to an ancestor.
|
|
139
139
|
# @param key [String]
|
|
140
140
|
# @return [Boolean]
|
|
141
|
-
def handle_key(key)
|
|
141
|
+
def handle_key?(key)
|
|
142
142
|
if @overlay.open?
|
|
143
143
|
return true if @overlay.move(key)
|
|
144
144
|
|
|
@@ -159,22 +159,21 @@ module Tuile
|
|
|
159
159
|
# The one row this Select paints — the full width, at the top of {#rect}.
|
|
160
160
|
# A single-slot container ({Component::Window}, {Component::Popup}) hands
|
|
161
161
|
# its content the whole inner rect, so a Select is routinely assigned more
|
|
162
|
-
# height than it uses; {#repaint} clears that tail,
|
|
163
|
-
#
|
|
162
|
+
# height than it uses; {#repaint} clears that tail, a press in it never reaches
|
|
163
|
+
# {#handle_mouse_down?}, and the dropdown hangs under this rather than under the
|
|
164
164
|
# unused space.
|
|
165
165
|
# @return [Size]
|
|
166
166
|
def extent = Size.new(rect.width, 1)
|
|
167
167
|
|
|
168
|
-
# Toggles the dropdown on a left
|
|
169
|
-
# affordance is its whole row, as the well advertises
|
|
170
|
-
#
|
|
171
|
-
# @
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
super
|
|
175
|
-
return unless event.button == :left && extent_rect.contains?(event.point)
|
|
168
|
+
# Toggles the dropdown on a left press anywhere in {#extent} — a field's
|
|
169
|
+
# affordance is its whole row, as the well advertises.
|
|
170
|
+
# @param event [Mouse::DownEvent]
|
|
171
|
+
# @return [Boolean]
|
|
172
|
+
def handle_mouse_down?(event)
|
|
173
|
+
return false unless event.button == :left
|
|
176
174
|
|
|
177
175
|
@overlay.open? ? close_menu : open_menu
|
|
176
|
+
true
|
|
178
177
|
end
|
|
179
178
|
|
|
180
179
|
# @return [void]
|
|
@@ -185,17 +184,25 @@ module Tuile
|
|
|
185
184
|
draw_text(rect.left, rect.top, face_row)
|
|
186
185
|
end
|
|
187
186
|
|
|
187
|
+
# The field well this Select's face sits on — {Theme#active_bg_color}
|
|
188
|
+
# while on the focus chain, {Theme#input_bg_color} otherwise. A Select has
|
|
189
|
+
# no caret, so the focus shade is its only indicator: an app that flattens
|
|
190
|
+
# it with a plain {Component#bg_color} is choosing that, and can keep the
|
|
191
|
+
# pair with `bg_color = { normal: …, active: … }`.
|
|
192
|
+
# @return [Color]
|
|
193
|
+
def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
|
|
194
|
+
|
|
188
195
|
private
|
|
189
196
|
|
|
190
197
|
# The painted row: the value's label padded across all but the last column,
|
|
191
|
-
# then the
|
|
192
|
-
#
|
|
198
|
+
# then the `▾`. The well underneath is {#default_bg_color}, applied by
|
|
199
|
+
# {Component#draw_text} — so a label span carrying its own background keeps
|
|
200
|
+
# it, where the old override-all fill flattened it.
|
|
193
201
|
# @return [StyledString]
|
|
194
202
|
def face_row
|
|
195
203
|
width = [rect.width - 1, 0].max
|
|
196
204
|
label = label_for(value).ellipsize(width)
|
|
197
|
-
|
|
198
|
-
row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
|
|
205
|
+
label + StyledString.plain("#{" " * (width - label.display_width)}▾")
|
|
199
206
|
end
|
|
200
207
|
|
|
201
208
|
# Rebuilds the dropdown's rows, highlight and geometry, opening it if
|
data/lib/tuile/component/slot.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Tuile
|
|
|
23
23
|
# (what {Window} does with an absent footer); never detach it.
|
|
24
24
|
#
|
|
25
25
|
# Transparent to input: not {Component#focusable?},
|
|
26
|
-
#
|
|
26
|
+
# the mouse routes straight through it, and a departing occupant's
|
|
27
27
|
# focus repair is handed to the container.
|
|
28
28
|
class Slot < Component
|
|
29
29
|
include Component::HasContent
|
|
@@ -40,8 +40,8 @@ module Tuile
|
|
|
40
40
|
# nothing to bubble from.
|
|
41
41
|
# @param child [Component] the just-detached occupant.
|
|
42
42
|
# @return [void]
|
|
43
|
-
def
|
|
44
|
-
parent&.
|
|
43
|
+
def handle_child_removed(child)
|
|
44
|
+
parent&.handle_child_removed(child)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
protected
|
|
@@ -26,11 +26,11 @@ module Tuile
|
|
|
26
26
|
# designing around:
|
|
27
27
|
#
|
|
28
28
|
# - A hidden pane is invisible to *everything*: the Tab cycle, focus
|
|
29
|
-
# cascades, repaint, the cursor, `
|
|
29
|
+
# cascades, repaint, the cursor, `walk_tree` walks. No gates anywhere.
|
|
30
30
|
# - Its state survives, because state is ivars — scroll position, caret,
|
|
31
31
|
# list cursor, text are all exactly as the user left them, and mutating a
|
|
32
32
|
# hidden pane is safe (`invalidate` while detached is a silent no-op).
|
|
33
|
-
# - {Component#
|
|
33
|
+
# - {Component#handle_detached} / {Component#handle_attached} fire on every switch,
|
|
34
34
|
# so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
|
|
35
35
|
# ticker — releases it while hidden and re-acquires it on return. A pane
|
|
36
36
|
# that must keep something alive while hidden can't; that something
|
|
@@ -40,7 +40,7 @@ module Tuile
|
|
|
40
40
|
# `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
|
|
41
41
|
# traversal gives the strip-then-pane Tab order for free. The swap follows
|
|
42
42
|
# the slot-swap recipe {Component#detach_child} documents — detach, rewire,
|
|
43
|
-
# `
|
|
43
|
+
# `handle_child_removed` last, so the focus repair sees the new occupant.
|
|
44
44
|
#
|
|
45
45
|
# Panes live in an identity-keyed `Tab => Component` map here rather than in
|
|
46
46
|
# a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
|
|
@@ -169,7 +169,7 @@ module Tuile
|
|
|
169
169
|
# Sends focus to the strip: a sheet is a container, and the strip is where
|
|
170
170
|
# a tab switch is driven from. The pane is a Tab press away.
|
|
171
171
|
# @return [void]
|
|
172
|
-
def
|
|
172
|
+
def handle_focus
|
|
173
173
|
super
|
|
174
174
|
screen.focused = @strip
|
|
175
175
|
end
|
|
@@ -179,7 +179,7 @@ module Tuile
|
|
|
179
179
|
# action was a tab switch.
|
|
180
180
|
# @param child [Component]
|
|
181
181
|
# @return [void]
|
|
182
|
-
def
|
|
182
|
+
def handle_child_removed(child)
|
|
183
183
|
super
|
|
184
184
|
screen.focused = @strip if attached? && screen.focused.equal?(self)
|
|
185
185
|
end
|
|
@@ -205,7 +205,7 @@ module Tuile
|
|
|
205
205
|
layout_pane
|
|
206
206
|
end
|
|
207
207
|
invalidate
|
|
208
|
-
|
|
208
|
+
handle_child_removed(old) unless old.nil?
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
# Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
|
data/lib/tuile/component/tabs.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Tuile
|
|
|
29
29
|
#
|
|
30
30
|
# {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
|
|
31
31
|
# `items=`: a tab is identity plus its own state, so the set grows and
|
|
32
|
-
# shrinks one tab at a time. See book ch7 and `
|
|
32
|
+
# shrinks one tab at a time. See book ch7 and `design/decisions.md` `D_tabs`.
|
|
33
33
|
#
|
|
34
34
|
# == Sizing
|
|
35
35
|
# Assign a {#rect} (typically from the surrounding {Layout}). One wider than
|
|
@@ -275,7 +275,7 @@ module Tuile
|
|
|
275
275
|
#
|
|
276
276
|
# Both the focus highlight and the click hit test use it, so a click on the
|
|
277
277
|
# blank tail — or on a lower row, when the rect is taller than one —
|
|
278
|
-
# selects nothing. It still *focuses*: {
|
|
278
|
+
# selects nothing. It still *focuses*: {Mouse::Router}'s
|
|
279
279
|
# click-to-focus is ungated by geometry.
|
|
280
280
|
# @return [Size]
|
|
281
281
|
def extent
|
|
@@ -291,7 +291,7 @@ module Tuile
|
|
|
291
291
|
# bubbles to an ancestor; an empty strip handles nothing at all.
|
|
292
292
|
# @param key [String]
|
|
293
293
|
# @return [Boolean]
|
|
294
|
-
def handle_key(key)
|
|
294
|
+
def handle_key?(key)
|
|
295
295
|
case key
|
|
296
296
|
when Keys::LEFT_ARROW then select_previous
|
|
297
297
|
when Keys::RIGHT_ARROW then select_next
|
|
@@ -299,16 +299,16 @@ module Tuile
|
|
|
299
299
|
end
|
|
300
300
|
end
|
|
301
301
|
|
|
302
|
-
# Selects the tab under a left
|
|
303
|
-
#
|
|
304
|
-
# @param event [
|
|
305
|
-
# @return [
|
|
306
|
-
def
|
|
307
|
-
|
|
308
|
-
return unless event.button == :left
|
|
302
|
+
# Selects the tab under a left press; a press between tabs selects
|
|
303
|
+
# nothing and still claims the strip.
|
|
304
|
+
# @param event [Mouse::DownEvent]
|
|
305
|
+
# @return [Boolean]
|
|
306
|
+
def handle_mouse_down?(event)
|
|
307
|
+
return false unless event.button == :left
|
|
309
308
|
|
|
310
309
|
tab = tab_at(event.point)
|
|
311
310
|
self.selected = tab if tab
|
|
311
|
+
true
|
|
312
312
|
end
|
|
313
313
|
|
|
314
314
|
# @return [void]
|
|
@@ -339,7 +339,7 @@ module Tuile
|
|
|
339
339
|
# The rect's *width* is the only part of it the offset depends on, so this
|
|
340
340
|
# hook is the whole geometry story; {Component#rect=} invalidates for us.
|
|
341
341
|
# @return [void]
|
|
342
|
-
def
|
|
342
|
+
def handle_width_changed
|
|
343
343
|
super
|
|
344
344
|
adjust_left_column
|
|
345
345
|
end
|
|
@@ -31,7 +31,7 @@ module Tuile
|
|
|
31
31
|
# class PromptArea < Component::TextArea
|
|
32
32
|
# protected
|
|
33
33
|
#
|
|
34
|
-
# def handle_text_input_key(key)
|
|
34
|
+
# def handle_text_input_key?(key)
|
|
35
35
|
# return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
|
|
36
36
|
# return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
|
|
37
37
|
#
|
|
@@ -39,8 +39,7 @@ module Tuile
|
|
|
39
39
|
# end
|
|
40
40
|
# end
|
|
41
41
|
#
|
|
42
|
-
# Both recalls return `true` to consume the key
|
|
43
|
-
# not subclass claims the same keys through {AbstractStringField#on_key}.
|
|
42
|
+
# Both recalls return `true` to consume the key.
|
|
44
43
|
#
|
|
45
44
|
# == Implementation details
|
|
46
45
|
#
|
|
@@ -88,11 +87,10 @@ module Tuile
|
|
|
88
87
|
Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
|
|
89
88
|
end
|
|
90
89
|
|
|
91
|
-
# @param event [
|
|
92
|
-
# @return [
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
return unless event.button == :left && rect.contains?(event.point)
|
|
90
|
+
# @param event [Mouse::DownEvent]
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def handle_mouse_down?(event)
|
|
93
|
+
return false unless event.button == :left
|
|
96
94
|
|
|
97
95
|
target_row = (event.y - rect.top) + @scroll_top_row
|
|
98
96
|
self.caret = if target_row >= wrap.row_count
|
|
@@ -100,6 +98,7 @@ module Tuile
|
|
|
100
98
|
else
|
|
101
99
|
wrap.index_at(target_row, event.x - rect.left)
|
|
102
100
|
end
|
|
101
|
+
true
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
# @return [void]
|
|
@@ -108,31 +107,36 @@ module Tuile
|
|
|
108
107
|
|
|
109
108
|
(0...rect.height).each do |row_in_viewport|
|
|
110
109
|
line = wrap.row_text(row_in_viewport + @scroll_top_row)
|
|
111
|
-
|
|
110
|
+
draw_text(rect.left, rect.top + row_in_viewport, StyledString.plain(line))
|
|
112
111
|
end
|
|
113
112
|
end
|
|
114
113
|
|
|
115
114
|
protected
|
|
116
115
|
|
|
117
116
|
# @return [void]
|
|
118
|
-
def
|
|
117
|
+
def handle_text_mutated
|
|
118
|
+
super
|
|
119
119
|
@wrap = nil
|
|
120
120
|
adjust_scroll_top_row
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
# @return [void]
|
|
124
|
-
def
|
|
124
|
+
def handle_caret_mutated
|
|
125
|
+
super
|
|
125
126
|
adjust_scroll_top_row
|
|
126
127
|
end
|
|
127
128
|
|
|
129
|
+
# HOME/END and CTRL+U act on the caret's **row** — the wrapped one, not
|
|
130
|
+
# the `\n` line — so CTRL+U kills back to exactly where HOME would go.
|
|
128
131
|
# @param key [String]
|
|
129
132
|
# @return [Boolean]
|
|
130
|
-
def handle_text_input_key(key)
|
|
133
|
+
def handle_text_input_key?(key)
|
|
131
134
|
case key
|
|
132
135
|
when Keys::UP_ARROW then move_caret_vertical(-1)
|
|
133
136
|
when Keys::DOWN_ARROW then move_caret_vertical(1)
|
|
134
137
|
when *Keys::HOMES then move_caret_to_row_start
|
|
135
138
|
when *Keys::ENDS_ then move_caret_to_row_end
|
|
139
|
+
when Keys::CTRL_U then delete_back_to(caret_row_start)
|
|
136
140
|
when *Keys::BACKSPACES then delete_before_caret
|
|
137
141
|
when Keys::DELETE then delete_at_caret
|
|
138
142
|
when Keys::ENTER, Keys::CTRL_J then insert_char("\n")
|
|
@@ -145,7 +149,7 @@ module Tuile
|
|
|
145
149
|
end
|
|
146
150
|
|
|
147
151
|
# @return [void]
|
|
148
|
-
def
|
|
152
|
+
def handle_width_changed
|
|
149
153
|
super
|
|
150
154
|
@wrap = nil
|
|
151
155
|
adjust_scroll_top_row
|
|
@@ -173,9 +177,12 @@ module Tuile
|
|
|
173
177
|
self.caret = wrap.index_at(new_row, cur_col)
|
|
174
178
|
end
|
|
175
179
|
|
|
180
|
+
# @return [Integer] index of the first character of the caret's row.
|
|
181
|
+
def caret_row_start = wrap.row_start(wrap.row_at(@caret))
|
|
182
|
+
|
|
176
183
|
# @return [void]
|
|
177
184
|
def move_caret_to_row_start
|
|
178
|
-
self.caret =
|
|
185
|
+
self.caret = caret_row_start
|
|
179
186
|
end
|
|
180
187
|
|
|
181
188
|
# @return [void]
|
|
@@ -183,12 +190,13 @@ module Tuile
|
|
|
183
190
|
self.caret = wrap.row_end(wrap.row_at(@caret))
|
|
184
191
|
end
|
|
185
192
|
|
|
193
|
+
# Routes a typed character (or the ENTER newline) through
|
|
194
|
+
# {AbstractStringField#insert_text}, the same mutation a paste lands on.
|
|
186
195
|
# @param char [String]
|
|
187
|
-
# @return [Boolean] always true
|
|
196
|
+
# @return [Boolean] always true — a rejected character is swallowed rather
|
|
197
|
+
# than declined, so typing never falls through to a scope-wide binding.
|
|
188
198
|
def insert_char(char)
|
|
189
|
-
|
|
190
|
-
@caret += char.length
|
|
191
|
-
self.text = new_text
|
|
199
|
+
insert_text(char)
|
|
192
200
|
true
|
|
193
201
|
end
|
|
194
202
|
|
|
@@ -12,7 +12,8 @@ module Tuile
|
|
|
12
12
|
# f.caret = 0 # paints "hello " — left_column 0
|
|
13
13
|
#
|
|
14
14
|
# The field's width never bounds its contents — {#max_text_length} does, and
|
|
15
|
-
# only for typing.
|
|
15
|
+
# only for typing. An empty field paints its {HasPlaceholder#placeholder}
|
|
16
|
+
# instead, when it has one.
|
|
16
17
|
#
|
|
17
18
|
# == Implementation details
|
|
18
19
|
#
|
|
@@ -21,7 +22,7 @@ module Tuile
|
|
|
21
22
|
# - an **index** counts characters into {#text} — {#caret},
|
|
22
23
|
# {#max_text_length}, `text[i]`, every edit;
|
|
23
24
|
# - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
|
|
24
|
-
# {
|
|
25
|
+
# {Mouse::DownEvent}, and the private horizontal scroll offset `left_column`.
|
|
25
26
|
#
|
|
26
27
|
# They coincide only while every glyph is one column wide. A fullwidth CJK
|
|
27
28
|
# char is two columns and a combining mark zero, so index 3 of `"日本語"` is
|
|
@@ -40,8 +41,11 @@ module Tuile
|
|
|
40
41
|
# overriding the paint alone leaves the measurements on the buffer while the
|
|
41
42
|
# cells show the substitute, and the two drift apart by a growing offset.
|
|
42
43
|
class TextField < AbstractStringField
|
|
44
|
+
include HasPlaceholder
|
|
45
|
+
|
|
43
46
|
def initialize
|
|
44
47
|
super
|
|
48
|
+
@placeholder = nil
|
|
45
49
|
@left_column = 0
|
|
46
50
|
@max_text_length = nil
|
|
47
51
|
@on_key_up = nil
|
|
@@ -99,32 +103,37 @@ module Tuile
|
|
|
99
103
|
Point.new(rect.left + offset, rect.top)
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
# Places the caret at the
|
|
106
|
+
# Places the caret at the pressed column. A press on the right half of a
|
|
103
107
|
# wide glyph lands *after* it, as in any editor.
|
|
104
|
-
# @param event [
|
|
105
|
-
# @return [
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
return unless event.button == :left && rect.contains?(event.point)
|
|
108
|
+
# @param event [Mouse::DownEvent]
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def handle_mouse_down?(event)
|
|
111
|
+
return false unless event.button == :left
|
|
109
112
|
|
|
110
113
|
self.caret = index_at(event.x - rect.left + @left_column)
|
|
114
|
+
true
|
|
111
115
|
end
|
|
112
116
|
|
|
113
117
|
# @return [void]
|
|
114
118
|
def repaint
|
|
115
119
|
return if rect.empty?
|
|
116
120
|
|
|
117
|
-
|
|
121
|
+
return draw_text(rect.left, rect.top, placeholder_row) if show_placeholder?
|
|
122
|
+
|
|
123
|
+
draw_text(rect.left, rect.top, StyledString.plain(visible_text))
|
|
118
124
|
end
|
|
119
125
|
|
|
120
126
|
protected
|
|
121
127
|
|
|
128
|
+
# CTRL+U kills back to the start of the field — with the caret at the end,
|
|
129
|
+
# "clear what I typed".
|
|
122
130
|
# @param key [String]
|
|
123
131
|
# @return [Boolean]
|
|
124
|
-
def handle_text_input_key(key)
|
|
132
|
+
def handle_text_input_key?(key)
|
|
125
133
|
case key
|
|
126
134
|
when *Keys::HOMES then self.caret = 0
|
|
127
135
|
when *Keys::ENDS_ then self.caret = @text.length
|
|
136
|
+
when Keys::CTRL_U then delete_back_to(0)
|
|
128
137
|
when *Keys::BACKSPACES then delete_before_caret
|
|
129
138
|
when Keys::DELETE then delete_at_caret
|
|
130
139
|
when Keys::UP_ARROW
|
|
@@ -147,31 +156,38 @@ module Tuile
|
|
|
147
156
|
true
|
|
148
157
|
end
|
|
149
158
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
159
|
+
# Keeps the paste's **first line** and drops the rest, then trims what's
|
|
160
|
+
# left to what {#max_text_length} still allows:
|
|
161
|
+
#
|
|
162
|
+
# f.handle_paste("name\nstreet\ncity")
|
|
163
|
+
# f.text # => "name"
|
|
164
|
+
#
|
|
165
|
+
# Overshooting the cap trims rather than rejects, which is what typing the
|
|
166
|
+
# same characters would have done. Why the first line, and not a
|
|
167
|
+
# newline-to-space flattening: `D_paste_newlines`.
|
|
154
168
|
# @param text [String]
|
|
155
169
|
# @return [String]
|
|
156
170
|
def preprocess_paste(text)
|
|
157
|
-
|
|
158
|
-
return
|
|
171
|
+
first_line = super[/\A[^\n]*/]
|
|
172
|
+
return first_line if @max_text_length.nil?
|
|
159
173
|
|
|
160
|
-
|
|
174
|
+
first_line[0, [@max_text_length - @text.length, 0].max] || ""
|
|
161
175
|
end
|
|
162
176
|
|
|
163
177
|
# @return [void]
|
|
164
|
-
def
|
|
178
|
+
def handle_text_mutated
|
|
179
|
+
super
|
|
165
180
|
adjust_left_column
|
|
166
181
|
end
|
|
167
182
|
|
|
168
183
|
# @return [void]
|
|
169
|
-
def
|
|
184
|
+
def handle_caret_mutated
|
|
185
|
+
super
|
|
170
186
|
adjust_left_column
|
|
171
187
|
end
|
|
172
188
|
|
|
173
189
|
# @return [void]
|
|
174
|
-
def
|
|
190
|
+
def handle_width_changed
|
|
175
191
|
super
|
|
176
192
|
adjust_left_column
|
|
177
193
|
end
|
|
@@ -186,16 +202,16 @@ module Tuile
|
|
|
186
202
|
|
|
187
203
|
private
|
|
188
204
|
|
|
205
|
+
# Routes a typed character through {AbstractStringField#insert_text}, the
|
|
206
|
+
# same mutation a paste lands on.
|
|
189
207
|
# @param char [String]
|
|
190
|
-
# @return [Boolean] always true — a field at {#max_text_length}
|
|
191
|
-
#
|
|
192
|
-
# scope-wide binding.
|
|
208
|
+
# @return [Boolean] always true — a field that is at {#max_text_length},
|
|
209
|
+
# or that rejected the character, swallows the key rather than declining
|
|
210
|
+
# it, so typing can never fall through to a scope-wide binding.
|
|
193
211
|
def insert(char)
|
|
194
212
|
return true if @max_text_length && @text.length >= @max_text_length
|
|
195
213
|
|
|
196
|
-
|
|
197
|
-
@caret += 1
|
|
198
|
-
self.text = new_text
|
|
214
|
+
insert_text(char)
|
|
199
215
|
true
|
|
200
216
|
end
|
|
201
217
|
|
|
@@ -221,6 +237,19 @@ module Tuile
|
|
|
221
237
|
i
|
|
222
238
|
end
|
|
223
239
|
|
|
240
|
+
# @return [Boolean] true when the empty field should paint its
|
|
241
|
+
# {HasPlaceholder#placeholder} instead of its (blank) contents.
|
|
242
|
+
def show_placeholder? = @text.empty? && !placeholder.to_s.empty?
|
|
243
|
+
|
|
244
|
+
# {#repaint} does not call `super`, so this padded row is the only thing
|
|
245
|
+
# that clears the rect: it *is* the well.
|
|
246
|
+
# @return [StyledString] the hint, ellipsized to `rect.width` and padded
|
|
247
|
+
# back out to it.
|
|
248
|
+
def placeholder_row
|
|
249
|
+
hint = StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width)
|
|
250
|
+
hint + StyledString.plain(" " * [rect.width - hint.display_width, 0].max)
|
|
251
|
+
end
|
|
252
|
+
|
|
224
253
|
# @return [Integer] total display width of {#text}.
|
|
225
254
|
def text_columns = column_at(@text.length)
|
|
226
255
|
|