tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# One component's background: what it states, and the {Color} that resolves
|
|
5
|
+
# to right now. A component reaches its own through the protected
|
|
6
|
+
# {Component#bg}; an app tints through {Component#bg_color=}.
|
|
7
|
+
#
|
|
8
|
+
# # a widget: its own well, brighter while focused
|
|
9
|
+
# def initialize
|
|
10
|
+
# super
|
|
11
|
+
# bg.default_color = ComponentBackground::INPUT_WELL
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# panel.bg_color = Theme.ref(:panel_bg) # an app: tint a whole subtree
|
|
15
|
+
#
|
|
16
|
+
# The chain, first answer wins: the owner's {Component#error_bg_color}, then
|
|
17
|
+
# {#color} (the app's), then {#default_color} (the widget's), then the parent's
|
|
18
|
+
# {#effective}, then `nil` — the terminal default. {INHERIT} at a level skips
|
|
19
|
+
# the owner's remaining levels and goes straight to the parent. Every level
|
|
20
|
+
# takes a {Color}, a {Theme::Ref} or a Hash keyed by {STATES}, and resolves
|
|
21
|
+
# against the live theme and the owner's {Component#active?} at paint time,
|
|
22
|
+
# so nothing here caches a color.
|
|
23
|
+
#
|
|
24
|
+
# == Implementation details
|
|
25
|
+
#
|
|
26
|
+
# The error level is *pulled* from the owner rather than stored here: it
|
|
27
|
+
# follows the validation state, and a pushed copy would need every edge of
|
|
28
|
+
# that state to remember to re-set it. A stale error well fails silently.
|
|
29
|
+
class ComponentBackground
|
|
30
|
+
# The states a background may be keyed by. Closed and framework-defined:
|
|
31
|
+
# a key is added when Tuile grows the state, never to let an app invent one.
|
|
32
|
+
# @return [Array<Symbol>]
|
|
33
|
+
STATES = %i[normal active].freeze
|
|
34
|
+
|
|
35
|
+
# Assign to {#color} to say "I contribute no background of my own" —
|
|
36
|
+
# resolution skips the owner's {#default_color} and takes whatever
|
|
37
|
+
# surrounds it. CSS's `background: inherit`, and the reason a widget with a
|
|
38
|
+
# well can be made to sit flush in a tinted panel:
|
|
39
|
+
#
|
|
40
|
+
# field.bg_color = ComponentBackground::INHERIT # no well; take the pane's tint
|
|
41
|
+
#
|
|
42
|
+
# Distinct from `nil`, which falls through to {#default_color} *first*.
|
|
43
|
+
# There is deliberately no counterpart forcing the terminal default despite
|
|
44
|
+
# a tinted ancestor (`D_bg_inherit`).
|
|
45
|
+
# @return [Symbol]
|
|
46
|
+
INHERIT = :inherit
|
|
47
|
+
|
|
48
|
+
# The well every input field paints: {Theme#input_bg_color} at rest,
|
|
49
|
+
# {Theme#active_bg_color} while on the focus chain. Live {Theme::Ref}s, so a
|
|
50
|
+
# {Screen#theme=} restyles it with no hook.
|
|
51
|
+
# @return [Hash{Symbol => Theme::Ref}]
|
|
52
|
+
INPUT_WELL = { normal: Theme.ref(:input_bg_color), active: Theme.ref(:active_bg_color) }.freeze
|
|
53
|
+
|
|
54
|
+
# @param owner [Component] whose background this is.
|
|
55
|
+
def initialize(owner)
|
|
56
|
+
@owner = owner
|
|
57
|
+
@color = nil
|
|
58
|
+
@default_color = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Color, Theme::Ref, Hash{Symbol => Color, Theme::Ref}, Symbol, nil]
|
|
62
|
+
# the app's background — the value as set, so a {Theme::Ref} comes back
|
|
63
|
+
# unresolved and a state map comes back a Hash; `nil` when unset.
|
|
64
|
+
attr_reader :color
|
|
65
|
+
|
|
66
|
+
# Tints the owner and every descendant that doesn't state its own, and
|
|
67
|
+
# invalidates that subtree. See {Component#bg_color=}, its public face.
|
|
68
|
+
# @param value [Color, Theme::Ref, Hash, Symbol, Integer, Array<Integer>, nil]
|
|
69
|
+
# @raise [ArgumentError] when a Hash carries a key outside {STATES}.
|
|
70
|
+
# @raise [KeyError] when a {Theme::Ref} names an absent custom token.
|
|
71
|
+
# @return [void]
|
|
72
|
+
def color=(value)
|
|
73
|
+
value = coerce(value)
|
|
74
|
+
return if @color == value
|
|
75
|
+
|
|
76
|
+
@color = value
|
|
77
|
+
invalidate
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Color, Theme::Ref, Hash{Symbol => Color, Theme::Ref}, nil] the
|
|
81
|
+
# widget's own surface — `nil` by default, meaning "whatever is behind me
|
|
82
|
+
# shows through".
|
|
83
|
+
attr_reader :default_color
|
|
84
|
+
|
|
85
|
+
# States the opaque surface a widget paints when the app has set no
|
|
86
|
+
# {#color} — and inheritance stops there, which is what keeps a form's
|
|
87
|
+
# fields looking like fields inside a tinted panel. Set it unconditionally,
|
|
88
|
+
# at construction: a widget owned by a bigger one is told so with
|
|
89
|
+
# {INHERIT}, and must not work it out from where it sits in the tree.
|
|
90
|
+
#
|
|
91
|
+
# bg.default_color = ComponentBackground::INPUT_WELL # the field well
|
|
92
|
+
# bg.default_color = { normal: Theme.ref(:panel_bg), active: … } # an app widget's
|
|
93
|
+
#
|
|
94
|
+
# **Hand it a {Theme::Ref}, never `screen.theme.input_bg_color`** — a
|
|
95
|
+
# resolved {Color} is a cached token and strands on the old scheme after a
|
|
96
|
+
# {Screen#theme=}, with nothing raising.
|
|
97
|
+
# @param value [Color, Theme::Ref, Hash, Symbol, Integer, Array<Integer>, nil]
|
|
98
|
+
# @raise [ArgumentError] when a Hash carries a key outside {STATES}.
|
|
99
|
+
# @raise [KeyError] when a {Theme::Ref} names an absent custom token.
|
|
100
|
+
# @return [void]
|
|
101
|
+
def default_color=(value)
|
|
102
|
+
value = coerce(value)
|
|
103
|
+
return if @default_color == value
|
|
104
|
+
|
|
105
|
+
@default_color = value
|
|
106
|
+
invalidate
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Color, nil] the background actually painted, for the state the
|
|
110
|
+
# owner is in right now — the whole chain, resolved. {Screen#canvas_for}
|
|
111
|
+
# loads it onto the canvas; an app never needs it.
|
|
112
|
+
def effective
|
|
113
|
+
own = resolve(@owner.__send__(:error_bg_color)) || resolve(@color) || resolve(@default_color)
|
|
114
|
+
return parent_effective if own.nil? || own == INHERIT
|
|
115
|
+
|
|
116
|
+
own
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# What surrounds the owner — the app's {#color}, else whatever the parent
|
|
120
|
+
# paints. Skips {#default_color} and the error well, the owner's *own*
|
|
121
|
+
# surface, which is what makes it the right answer for a dead tail outside
|
|
122
|
+
# {Component#extent} and a container's gaps.
|
|
123
|
+
# @return [Color, nil]
|
|
124
|
+
def ambient
|
|
125
|
+
own = resolve(@color)
|
|
126
|
+
return parent_effective if own.nil? || own == INHERIT
|
|
127
|
+
|
|
128
|
+
own
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# @return [Color, nil]
|
|
134
|
+
def parent_effective = @owner.parent&.__send__(:bg)&.effective
|
|
135
|
+
|
|
136
|
+
# @return [void]
|
|
137
|
+
def invalidate
|
|
138
|
+
@owner.walk_tree { |c| @owner.screen.invalidate(c) } if @owner.attached?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Collapses one level to the {Color} it means right now. An absent state
|
|
142
|
+
# key yields `nil`, so resolution falls through to the next level — which
|
|
143
|
+
# is what lets `bg_color = { active: … }` keep the widget's own normal well.
|
|
144
|
+
# @param value [Color, Theme::Ref, Hash, Symbol, nil]
|
|
145
|
+
# @return [Color, Symbol, nil]
|
|
146
|
+
def resolve(value)
|
|
147
|
+
case value
|
|
148
|
+
when nil then nil
|
|
149
|
+
when Hash then resolve(value[@owner.active? ? :active : :normal])
|
|
150
|
+
when Theme::Ref then value.resolve(@owner.screen.theme)
|
|
151
|
+
else value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Validates and normalizes a level's value, so a bad token or a misspelled
|
|
156
|
+
# state raises at the assignment rather than deep in a repaint. A chrome
|
|
157
|
+
# token needs no theme to check, which keeps construction screen-free.
|
|
158
|
+
# @param value [Object]
|
|
159
|
+
# @return [Color, Theme::Ref, Hash, Symbol, nil]
|
|
160
|
+
# @raise [ArgumentError] on a Hash key outside {STATES}.
|
|
161
|
+
# @raise [KeyError] on a {Theme::Ref} naming an absent custom token.
|
|
162
|
+
def coerce(value)
|
|
163
|
+
case value
|
|
164
|
+
when nil, Color, INHERIT then value
|
|
165
|
+
when Theme::Ref
|
|
166
|
+
value.tap { _1.resolve(@owner.screen.theme) unless Theme.chrome_token?(_1.name) }
|
|
167
|
+
when Hash
|
|
168
|
+
unknown = value.keys - STATES
|
|
169
|
+
raise ArgumentError, "unknown background state(s) #{unknown.join(", ")}; known: #{STATES.join(", ")}" \
|
|
170
|
+
unless unknown.empty?
|
|
171
|
+
|
|
172
|
+
value.to_h { |state, color| [state, coerce(color)] }.freeze
|
|
173
|
+
else Color.coerce(value)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# Pure queries over the component tree, as module functions:
|
|
5
|
+
#
|
|
6
|
+
# ComponentUtil.effectively_visible?(field) # false under a hidden panel
|
|
7
|
+
#
|
|
8
|
+
# Home for a question that belongs to no one component and to no one asker.
|
|
9
|
+
# Deliberately **not** {Component} methods, for the reason `D_empty_ancestor`
|
|
10
|
+
# declined a `Component#paintable?`: each reads as a component-level concept
|
|
11
|
+
# and is really the framework's question.
|
|
12
|
+
#
|
|
13
|
+
# **The gate for a new member** is a pure query over the tree — no state, no
|
|
14
|
+
# mutation — with **two or more call sites in `lib/`**; one caller stays
|
|
15
|
+
# private to the class that asks. Without it this becomes the drawer internal
|
|
16
|
+
# things go in.
|
|
17
|
+
#
|
|
18
|
+
# Tuile-internal: a member may change or vanish with no migration note, and
|
|
19
|
+
# one an app turns out to need graduates to a documented home instead.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
module ComponentUtil
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Whether `component` is genuinely on screen: attached, with neither it nor
|
|
26
|
+
# any ancestor hidden. {Component#visible?} is a component's own flag alone,
|
|
27
|
+
# so a field under a hidden panel is still `visible?` itself.
|
|
28
|
+
#
|
|
29
|
+
# A *walk* needs no such predicate — it prunes at the hidden subtree's root
|
|
30
|
+
# ({Component#walk_shown_tree}).
|
|
31
|
+
# @param component [Component]
|
|
32
|
+
# @raise [TypeError] on `nil` — a caller that may hold one says so itself,
|
|
33
|
+
# rather than having "no component" quietly answer "not visible".
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def effectively_visible?(component)
|
|
36
|
+
raise TypeError, "expected Component, got nil" if component.nil?
|
|
37
|
+
|
|
38
|
+
cursor = component
|
|
39
|
+
cursor = cursor.parent while cursor&.visible?
|
|
40
|
+
cursor.nil? && component.attached?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/tuile/event.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# The marker every event includes: *something happened, described by a frozen
|
|
5
|
+
# value*.
|
|
6
|
+
#
|
|
7
|
+
# class ValueChangeEvent < Data.define(:source, :value)
|
|
8
|
+
# include Tuile::Event
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# case event
|
|
12
|
+
# when Mouse::DownEvent then … # one concrete class
|
|
13
|
+
# when Tuile::Event then … # …or all of them at once
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# One concept in three namespaces — {Mouse}'s wire events, {EventQueue}'s loop
|
|
17
|
+
# events, and the ones a {Listeners} slot fires. There is no base class: the
|
|
18
|
+
# `case` above works off the marker alone (`D_mouse_dispatch`).
|
|
19
|
+
#
|
|
20
|
+
# **It mandates no members and supplies no defaults.** `source` belongs to the
|
|
21
|
+
# classes that have one — for the twelve events predating this marker it would
|
|
22
|
+
# be nil throughout, and a member nothing reads is the mailbox shape
|
|
23
|
+
# `D_bad_input` refuses. A default here would be worse than the absence rather
|
|
24
|
+
# than better: `from_user? = false` on the marker would make
|
|
25
|
+
# {Mouse::DownEvent}, the most from-user thing in the gem, answer `false`, and
|
|
26
|
+
# nothing would say so.
|
|
27
|
+
module Event
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -32,6 +32,9 @@ module Tuile
|
|
|
32
32
|
# Submits block to be run in the event queue. Returns immediately.
|
|
33
33
|
#
|
|
34
34
|
# The function may be called from any thread.
|
|
35
|
+
#
|
|
36
|
+
# The block runs only while a loop is draining: submitted before the first
|
|
37
|
+
# {#run_loop} it waits for one, submitted after the last it never runs.
|
|
35
38
|
# @yield called from the event-loop thread.
|
|
36
39
|
# @yieldreturn [void]
|
|
37
40
|
# @return [void]
|
|
@@ -165,6 +168,7 @@ module Tuile
|
|
|
165
168
|
# @!attribute [r] key
|
|
166
169
|
# @return [String] key code.
|
|
167
170
|
class KeyEvent < Data.define(:key)
|
|
171
|
+
include Tuile::Event
|
|
168
172
|
end
|
|
169
173
|
|
|
170
174
|
# Text arrived from the clipboard rather than the keyboard: the terminal
|
|
@@ -181,6 +185,8 @@ module Tuile
|
|
|
181
185
|
# @return [String] the pasted text, `\n`-normalized by
|
|
182
186
|
# {Keys.normalize_paste}.
|
|
183
187
|
class PasteEvent < Data.define(:text)
|
|
188
|
+
include Tuile::Event
|
|
189
|
+
|
|
184
190
|
# @param text [String]
|
|
185
191
|
def initialize(text:)
|
|
186
192
|
super(text: text.freeze)
|
|
@@ -193,6 +199,7 @@ module Tuile
|
|
|
193
199
|
# @!attribute [r] error
|
|
194
200
|
# @return [StandardError] the underlying error.
|
|
195
201
|
class ErrorEvent < Data.define(:error)
|
|
202
|
+
include Tuile::Event
|
|
196
203
|
end
|
|
197
204
|
|
|
198
205
|
# TTY has been resized. Contains the current width and height of the TTY
|
|
@@ -203,6 +210,8 @@ module Tuile
|
|
|
203
210
|
# @!attribute [r] height
|
|
204
211
|
# @return [Integer] terminal height in rows.
|
|
205
212
|
class TTYSizeEvent < Data.define(:width, :height)
|
|
213
|
+
include Tuile::Event
|
|
214
|
+
|
|
206
215
|
# @param width [Integer]
|
|
207
216
|
# @param height [Integer]
|
|
208
217
|
def initialize(width:, height:)
|
|
@@ -233,6 +242,8 @@ module Tuile
|
|
|
233
242
|
# @!attribute [r] scheme
|
|
234
243
|
# @return [Symbol] `:light` or `:dark`.
|
|
235
244
|
class ColorSchemeEvent < Data.define(:scheme)
|
|
245
|
+
include Tuile::Event
|
|
246
|
+
|
|
236
247
|
# The DSR-style color-scheme report: `\e[?997;1n` dark, `\e[?997;2n`
|
|
237
248
|
# light.
|
|
238
249
|
# @return [Regexp]
|
|
@@ -257,6 +268,8 @@ module Tuile
|
|
|
257
268
|
# @!attribute [r] color
|
|
258
269
|
# @return [Color] the reported background, 24-bit RGB.
|
|
259
270
|
class BackgroundColorEvent < Data.define(:color)
|
|
271
|
+
include Tuile::Event
|
|
272
|
+
|
|
260
273
|
# @param key [String] key read via {Keys.getkey}.
|
|
261
274
|
# @return [BackgroundColorEvent, nil] nil when `key` is not an OSC 11
|
|
262
275
|
# background reply.
|
|
@@ -271,6 +284,7 @@ module Tuile
|
|
|
271
284
|
# repainting windows.
|
|
272
285
|
class EmptyQueueEvent
|
|
273
286
|
include Singleton
|
|
287
|
+
include Tuile::Event
|
|
274
288
|
end
|
|
275
289
|
|
|
276
290
|
# Handle returned by {EventQueue#tick}. Cancel a running ticker via
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -11,6 +11,11 @@ module Tuile
|
|
|
11
11
|
# run an event loop, so it is *not* suitable for system-testing whole apps
|
|
12
12
|
# — for that, drive the real script through a PTY (see `spec/examples/`).
|
|
13
13
|
#
|
|
14
|
+
# It also turns the stale-rect diagnostic on ({Tuile::StrictLayout}): a rect
|
|
15
|
+
# read before the layout has settled raises here, where in an app it would
|
|
16
|
+
# quietly answer the previous pass's rectangle. `Tuile.strict_layout = false`
|
|
17
|
+
# opts a suite out; {Tuile.without_strict_layout} opts one read out.
|
|
18
|
+
#
|
|
14
19
|
# Call {Screen.fake} to initialize the fake screen easily. Typical usage:
|
|
15
20
|
#
|
|
16
21
|
# before { Screen.fake }
|
|
@@ -23,13 +28,21 @@ module Tuile
|
|
|
23
28
|
# assert_includes Screen.instance.prints.join, "hi"
|
|
24
29
|
# end
|
|
25
30
|
class FakeScreen < Screen
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
# The terminal starts at the size given, as if it had always been that big:
|
|
32
|
+
# nothing is dispatched and nothing repaints. To exercise a change of size,
|
|
33
|
+
# the SIGWINCH path, call {#resize_terminal} instead.
|
|
34
|
+
# @param width [Integer] the terminal's columns.
|
|
35
|
+
# @param height [Integer] the terminal's rows.
|
|
36
|
+
def initialize(width: 160, height: 50)
|
|
37
|
+
super()
|
|
38
|
+
# `Tuile.strict_layout` defaults to `:raise` under this screen; this puts
|
|
39
|
+
# the check it answers through into `Component#rect`.
|
|
40
|
+
StrictLayout.install
|
|
28
41
|
@event_queue = FakeEventQueue.new
|
|
29
|
-
@size = Size.new(
|
|
42
|
+
@size = Size.new(width, height)
|
|
30
43
|
# super sized both to the test runner's TTY.
|
|
31
44
|
@buffer.resize(@size)
|
|
32
|
-
|
|
45
|
+
size_pane
|
|
33
46
|
@prints = []
|
|
34
47
|
end
|
|
35
48
|
|
|
@@ -72,7 +85,7 @@ module Tuile
|
|
|
72
85
|
# against `\n`.
|
|
73
86
|
# @param text [String]
|
|
74
87
|
# @return [Boolean] true if some component consumed it.
|
|
75
|
-
def paste(text) =
|
|
88
|
+
def paste(text) = dispatch(EventQueue::PasteEvent.new(Keys.normalize_paste(text)))
|
|
76
89
|
|
|
77
90
|
# @param component [Component] the component to check.
|
|
78
91
|
# @return [Boolean]
|
|
@@ -101,7 +114,7 @@ module Tuile
|
|
|
101
114
|
# Plays a whole click at a screen cell — the press, then the release that
|
|
102
115
|
# ends its grab:
|
|
103
116
|
#
|
|
104
|
-
# screen.click(save_button.
|
|
117
|
+
# screen.click(save_button.absolute_rect.left, save_button.absolute_rect.top)
|
|
105
118
|
#
|
|
106
119
|
# Routed exactly as the terminal's own report would be ({Mouse::Router}), so
|
|
107
120
|
# it focuses, dismisses popups and bubbles.
|
|
@@ -114,26 +127,33 @@ module Tuile
|
|
|
114
127
|
release(x, y)
|
|
115
128
|
end
|
|
116
129
|
|
|
130
|
+
# The terminal reporting a new size, routed as its own report would be: the
|
|
131
|
+
# pane takes the whole screen, and every popup is placed again.
|
|
132
|
+
# @param width [Integer]
|
|
133
|
+
# @param height [Integer]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def resize_terminal(width, height) = dispatch(EventQueue::TTYSizeEvent.new(width, height))
|
|
136
|
+
|
|
117
137
|
# Half a {#click}, for a spec about the grab — what is claimed, what the
|
|
118
138
|
# drag does, what the release lands on.
|
|
119
139
|
# @param x [Integer] 0-based column.
|
|
120
140
|
# @param y [Integer] 0-based row.
|
|
121
141
|
# @param button [Symbol] `:left`, `:middle` or `:right`.
|
|
122
142
|
# @return [void]
|
|
123
|
-
def press(x, y, button: :left) =
|
|
143
|
+
def press(x, y, button: :left) = dispatch(Mouse::DownEvent.new(button, x, y))
|
|
124
144
|
|
|
125
145
|
# The other half of {#press}.
|
|
126
146
|
# @param x [Integer] 0-based column.
|
|
127
147
|
# @param y [Integer] 0-based row.
|
|
128
148
|
# @return [void]
|
|
129
|
-
def release(x, y) =
|
|
149
|
+
def release(x, y) = dispatch(Mouse::UpEvent.new(x, y))
|
|
130
150
|
|
|
131
151
|
# One wheel notch over a cell.
|
|
132
152
|
# @param direction [Symbol] `:up`, `:down`, `:left` or `:right`.
|
|
133
153
|
# @param x [Integer] 0-based column.
|
|
134
154
|
# @param y [Integer] 0-based row.
|
|
135
155
|
# @return [void]
|
|
136
|
-
def scroll(direction, x, y) =
|
|
156
|
+
def scroll(direction, x, y) = dispatch(Mouse::ScrollEvent.new(direction, x, y))
|
|
137
157
|
|
|
138
158
|
# Moves the pointer, firing the enter/exit hooks the new position implies —
|
|
139
159
|
# or, while a press is grabbed, one {Component#handle_mouse_drag}.
|
|
@@ -141,7 +161,7 @@ module Tuile
|
|
|
141
161
|
# @param y [Integer] 0-based row.
|
|
142
162
|
# @param button [Symbol, nil] the button held while moving, if any.
|
|
143
163
|
# @return [void]
|
|
144
|
-
def move(x, y, button: nil) =
|
|
164
|
+
def move(x, y, button: nil) = dispatch(Mouse::MoveEvent.new(button, x, y))
|
|
145
165
|
|
|
146
166
|
# Plays a whole drag: the press at the first point, one move per point
|
|
147
167
|
# after it, and the release at the last.
|
|
@@ -169,6 +189,17 @@ module Tuile
|
|
|
169
189
|
|
|
170
190
|
private
|
|
171
191
|
|
|
192
|
+
# Settles the layout on the way *in* as well as out. A spec mutates between
|
|
193
|
+
# gestures, where the loop would have settled at the end of the previous
|
|
194
|
+
# dispatch and there is none — and routing a press reads rects, so without
|
|
195
|
+
# this a `click` hit-tests what the last mutation left half-finished.
|
|
196
|
+
# @param event [Object] see {Screen#dispatch}.
|
|
197
|
+
# @return [Object] whatever the handler returned.
|
|
198
|
+
def dispatch(event)
|
|
199
|
+
flush_layout
|
|
200
|
+
super
|
|
201
|
+
end
|
|
202
|
+
|
|
172
203
|
# @param point [Point, Array(Integer, Integer)]
|
|
173
204
|
# @return [Point]
|
|
174
205
|
def coerce_point(point)
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -158,12 +158,13 @@ module Tuile
|
|
|
158
158
|
|
|
159
159
|
# Escape sequence. Try to read more data.
|
|
160
160
|
begin
|
|
161
|
-
# Read up to 5 bytes: that's the maximum tail length of any
|
|
162
|
-
# sequence Tuile recognizes after the initial \e (X10
|
|
163
|
-
# CTRL+arrow `[1;5D`, etc.)
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
161
|
+
# Read up to 5 bytes: that's the maximum tail length of any *fixed*-
|
|
162
|
+
# length escape sequence Tuile recognizes after the initial \e (X10
|
|
163
|
+
# mouse `[Mbxy`, CTRL+arrow `[1;5D`, etc.); the variable-length ones
|
|
164
|
+
# are drained below. Reading 6 here would over-read into the next
|
|
165
|
+
# sequence on tight mouse-event bursts — we'd silently steal the next
|
|
166
|
+
# event's leading \e and the rest of it would surface as individual
|
|
167
|
+
# printable keypresses in focused inputs.
|
|
167
168
|
char += $stdin.read_nonblock(5)
|
|
168
169
|
rescue IO::EAGAINWaitReadable
|
|
169
170
|
# The "ESC" key pressed => only the \e char is emitted.
|
|
@@ -176,6 +177,14 @@ module Tuile
|
|
|
176
177
|
# instead of leaking tail bytes as keypresses.
|
|
177
178
|
char += $stdin.read(6 - char.bytesize) if char.start_with?("\e[M") && char.bytesize < 6
|
|
178
179
|
|
|
180
|
+
# SGR mouse reports (`\e[<Cb;x;yM`, mode 1006) are variable-length and do
|
|
181
|
+
# not align to read boundaries, so no gulp width fits: drain to the final
|
|
182
|
+
# `M`/`m` a byte at a time. The gulp above cannot over-read one — the
|
|
183
|
+
# shortest report is 8 bytes after the `\e` — and only digits and `;`
|
|
184
|
+
# precede the terminator, so this stops at the first event's end.
|
|
185
|
+
# Keyboard sequences never start with `\e[<`, so this eats no real key.
|
|
186
|
+
char += $stdin.read(1) while char.start_with?("\e[<") && !char.end_with?("M", "m")
|
|
187
|
+
|
|
179
188
|
# Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
|
|
180
189
|
# can outgrow the 5-byte gulp above — the mode-2031 color-scheme
|
|
181
190
|
# notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# Who may assign a rect right now. {Component#rect=} is refused outside the
|
|
5
|
+
# parent's {Component#relayout}, and this is the bracket that says when one
|
|
6
|
+
# is running:
|
|
7
|
+
#
|
|
8
|
+
# LayoutPass.run(container) { relayout } # inside the container
|
|
9
|
+
#
|
|
10
|
+
# A pass also records which children it has placed *so far*. Before the
|
|
11
|
+
# first, {Component#invalidate_layout} drops a mark the container makes on
|
|
12
|
+
# itself — the pass that would answer it is the one running — and until a
|
|
13
|
+
# child's turn, {Component#rect_stale?} reports its rect as the previous
|
|
14
|
+
# pass's ({.unplaced?}).
|
|
15
|
+
#
|
|
16
|
+
# **Thread-local rather than an ivar**, because a tree with no {Screen}
|
|
17
|
+
# places children too, so there is no one object to hang the state on.
|
|
18
|
+
#
|
|
19
|
+
# Tuile-internal: a member may change or vanish with no migration note. The
|
|
20
|
+
# rule it enforces is {Component#rect=}'s and is documented there.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
module LayoutPass
|
|
24
|
+
# The thread-local naming what is placing children right now.
|
|
25
|
+
# @return [Symbol]
|
|
26
|
+
PLACING = :tuile_placing
|
|
27
|
+
private_constant :PLACING
|
|
28
|
+
|
|
29
|
+
# The thread-local holding the children the current placer has assigned a
|
|
30
|
+
# rect so far — an identity `Set`, or `nil` before the first.
|
|
31
|
+
# @return [Symbol]
|
|
32
|
+
PLACED = :tuile_placed
|
|
33
|
+
private_constant :PLACED
|
|
34
|
+
|
|
35
|
+
# Drain rounds a {Screen#flush_layout} or a detached {Component#flush_layout}
|
|
36
|
+
# runs before giving up. No container sizes itself from its children, so a
|
|
37
|
+
# tree settles in about its depth; one that is still marking past this is
|
|
38
|
+
# feeding a pass's output back into its input — a {Component::Scroller}
|
|
39
|
+
# whose `content_rows` the app derives from the content's width, say — and
|
|
40
|
+
# would otherwise hang the UI thread.
|
|
41
|
+
# @return [Integer]
|
|
42
|
+
MAX_ROUNDS = 50
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Runs the block with `placer` as the one thing whose children's rects may
|
|
47
|
+
# be assigned. Nests: the enclosing pass is restored on the way out.
|
|
48
|
+
# @param placer [Component, Screen]
|
|
49
|
+
# @return [Object] the block's value.
|
|
50
|
+
def run(placer)
|
|
51
|
+
outer = Thread.current[PLACING]
|
|
52
|
+
outer_placed = Thread.current[PLACED]
|
|
53
|
+
Thread.current[PLACING] = placer
|
|
54
|
+
Thread.current[PLACED] = nil
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
Thread.current[PLACING] = outer
|
|
58
|
+
Thread.current[PLACED] = outer_placed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether any pass is placing children right now — what a drain refuses to
|
|
62
|
+
# run inside ({.refuse_nested}), and {Screen#focused=} defers its geometry under.
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def running? = !Thread.current[PLACING].nil?
|
|
65
|
+
|
|
66
|
+
# The guard at the top of both drains, {Screen#flush_layout} and a detached
|
|
67
|
+
# {Component#flush_layout}.
|
|
68
|
+
# @raise [Tuile::Error] while a pass is running: it has not placed its
|
|
69
|
+
# children yet, so a nested drain would read the rects it is about to
|
|
70
|
+
# reassign.
|
|
71
|
+
# @return [void]
|
|
72
|
+
def refuse_nested
|
|
73
|
+
return unless running?
|
|
74
|
+
|
|
75
|
+
raise Tuile::Error, "flush_layout inside #{Thread.current[PLACING]}'s relayout: the running pass " \
|
|
76
|
+
"has not placed its children yet, so a nested drain would read stale rects"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# What may assign `component`'s rect: its parent, whose
|
|
80
|
+
# {Component#relayout} does. The exception is the {ScreenPane}, which has
|
|
81
|
+
# no parent whose pass could place it, so the {Screen} does (`D_tree_first`
|
|
82
|
+
# keeps the screen out of the tree). Those are the only two cases — this is
|
|
83
|
+
# a closed rule rather than a hook a component may answer for itself.
|
|
84
|
+
# @param component [Component]
|
|
85
|
+
# @return [Component, Screen, nil]
|
|
86
|
+
def placer(component)
|
|
87
|
+
component.is_a?(ScreenPane) ? component.screen : component.parent
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param component [Component] the one whose rect is being assigned.
|
|
91
|
+
# @raise [Tuile::Error] unless {.placer} is what is placing now.
|
|
92
|
+
# @return [void]
|
|
93
|
+
def check(component)
|
|
94
|
+
current = Thread.current[PLACING]
|
|
95
|
+
return if !current.nil? && current.equal?(placer(component))
|
|
96
|
+
|
|
97
|
+
if component.parent.nil?
|
|
98
|
+
raise Tuile::Error, "#{component} has no parent to place it; to size a detached tree, " \
|
|
99
|
+
"hold it in a Layout::Absolute (add(tree, rect), then flush_layout)"
|
|
100
|
+
end
|
|
101
|
+
raise Tuile::Error, "#{component}'s rect assigned outside #{component.parent}'s relayout; change " \
|
|
102
|
+
"what the parent places it by instead (Absolute#constrain, Box#constrain, " \
|
|
103
|
+
"Overlay#placement=)"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Runs every {Component#relayout} `root`'s tree owes, to a fixpoint — the one
|
|
107
|
+
# drain behind both {Screen#flush_layout} and a detached
|
|
108
|
+
# {Component#flush_layout}.
|
|
109
|
+
#
|
|
110
|
+
# **The dirty flags are the queue.** Each round walks the tree pre-order and
|
|
111
|
+
# runs whatever is marked, so a parent lays out before the children whose
|
|
112
|
+
# rects it just wrote, and those children run in the same walk. A mark on
|
|
113
|
+
# something *earlier* in the order — a child's pass marking its parent —
|
|
114
|
+
# waits for the next round, and the drain ends on the first walk that finds
|
|
115
|
+
# nothing marked. A flag is cleared by the pass that answers it, so a
|
|
116
|
+
# container is never run twice for one mark, and a raise strands nothing.
|
|
117
|
+
# @param root [Component] the tree's root: the {ScreenPane}, or a detached root.
|
|
118
|
+
# @param rounds [Integer] rounds already run in this flush, so a caller
|
|
119
|
+
# draining more than once — the screen, re-checking anchors — keeps one count.
|
|
120
|
+
# @raise [Tuile::Error] past {MAX_ROUNDS} rounds, naming the containers still marked.
|
|
121
|
+
# @return [Integer] the rounds run in total.
|
|
122
|
+
def drain(root, rounds = 0)
|
|
123
|
+
while drain_round(root)
|
|
124
|
+
next rounds += 1 if rounds < MAX_ROUNDS
|
|
125
|
+
|
|
126
|
+
culprits = []
|
|
127
|
+
root.walk_tree { culprits << _1 if _1.layout_dirty? }
|
|
128
|
+
raise Tuile::Error, "layout did not settle after #{MAX_ROUNDS} rounds, still marking: " \
|
|
129
|
+
"#{culprits.first(5).map(&:inspect).join(", ")} — a relayout keeps changing its " \
|
|
130
|
+
"own input (content_rows derived from the content's width?)"
|
|
131
|
+
end
|
|
132
|
+
rounds
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# One pre-order walk running every marked container.
|
|
136
|
+
# @param root [Component]
|
|
137
|
+
# @return [Boolean] whether it ran anything.
|
|
138
|
+
def drain_round(root)
|
|
139
|
+
ran = false
|
|
140
|
+
root.walk_tree do |component|
|
|
141
|
+
next unless component.layout_dirty?
|
|
142
|
+
|
|
143
|
+
# `__send__`: private, because it is this drain's alone.
|
|
144
|
+
component.__send__(:perform_relayout)
|
|
145
|
+
ran = true
|
|
146
|
+
end
|
|
147
|
+
ran
|
|
148
|
+
end
|
|
149
|
+
private_class_method :drain_round
|
|
150
|
+
|
|
151
|
+
# Records that the running pass has assigned `component` its rect. Past
|
|
152
|
+
# the first, a mark on the placer is kept rather than dropped.
|
|
153
|
+
# @param component [Component]
|
|
154
|
+
# @return [void]
|
|
155
|
+
def note_placement(component)
|
|
156
|
+
(Thread.current[PLACED] ||= Set.new.compare_by_identity) << component
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Whether `component` is the running placer and has placed no child yet —
|
|
160
|
+
# the window in which a mark it makes on itself is redundant.
|
|
161
|
+
# @param component [Component]
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def before_first_placement?(component)
|
|
164
|
+
Thread.current[PLACING].equal?(component) && Thread.current[PLACED].nil?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Whether `component`'s placer is running its pass right now and has not
|
|
168
|
+
# reached it yet — so its rect is the previous pass's, though no flag says
|
|
169
|
+
# so: {Component#perform_relayout} clears the placer's before the body runs.
|
|
170
|
+
# @param component [Component]
|
|
171
|
+
# @return [Boolean]
|
|
172
|
+
def unplaced?(component)
|
|
173
|
+
placing = Thread.current[PLACING]
|
|
174
|
+
return false if placing.nil? || !placing.equal?(placer(component))
|
|
175
|
+
|
|
176
|
+
placed = Thread.current[PLACED]
|
|
177
|
+
placed.nil? || !placed.include?(component)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|