tuile 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/examples/file_commander.rb +196 -0
- data/examples/hello_world.rb +29 -0
- data/lib/tuile/component/has_content.rb +69 -0
- data/lib/tuile/component/info_window.rb +30 -0
- data/lib/tuile/component/label.rb +63 -0
- data/lib/tuile/component/layout.rb +98 -0
- data/lib/tuile/component/list.rb +583 -0
- data/lib/tuile/component/log_window.rb +59 -0
- data/lib/tuile/component/picker_window.rb +97 -0
- data/lib/tuile/component/popup.rb +127 -0
- data/lib/tuile/component/text_field.rb +209 -0
- data/lib/tuile/component/window.rb +215 -0
- data/lib/tuile/component.rb +236 -0
- data/lib/tuile/event_queue.rb +192 -0
- data/lib/tuile/fake_event_queue.rb +31 -0
- data/lib/tuile/fake_screen.rb +58 -0
- data/lib/tuile/keys.rb +63 -0
- data/lib/tuile/mouse_event.rb +49 -0
- data/lib/tuile/point.rb +14 -0
- data/lib/tuile/rect.rb +58 -0
- data/lib/tuile/screen.rb +377 -0
- data/lib/tuile/screen_pane.rb +174 -0
- data/lib/tuile/size.rb +42 -0
- data/lib/tuile/version.rb +6 -0
- data/lib/tuile/vertical_scroll_bar.rb +46 -0
- data/lib/tuile.rb +37 -0
- data/sig/tuile.rbs +1502 -0
- metadata +197 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A UI component which is positioned on the screen and draws characters into
|
|
5
|
+
# its bounding rectangle (in {#repaint}).
|
|
6
|
+
#
|
|
7
|
+
# Component is considered invisible if {#rect} is empty or one of left/top is
|
|
8
|
+
# negative. The component won't draw when invisible.
|
|
9
|
+
class Component
|
|
10
|
+
def initialize
|
|
11
|
+
@rect = Rect.new(0, 0, 0, 0)
|
|
12
|
+
@active = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Rect] the rectangle the component occupies on screen.
|
|
16
|
+
attr_reader :rect
|
|
17
|
+
|
|
18
|
+
# Sets new position of the component. This is the absolute component
|
|
19
|
+
# positioning on screen, not a relative positioning relative to component's
|
|
20
|
+
# {#parent}.
|
|
21
|
+
#
|
|
22
|
+
# The component must not stick outside of {#parent}'s rect.
|
|
23
|
+
#
|
|
24
|
+
# The component is invalidated and will paint over the new rectangle. It is
|
|
25
|
+
# parent's job to paint over the old component position.
|
|
26
|
+
# @param new_rect [Rect] new position. Does nothing if the new rectangle is
|
|
27
|
+
# the same as the old one.
|
|
28
|
+
def rect=(new_rect)
|
|
29
|
+
raise TypeError, "expected Rect, got #{new_rect.inspect}" unless new_rect.is_a? Rect
|
|
30
|
+
return if @rect == new_rect
|
|
31
|
+
|
|
32
|
+
prev_width = @rect.width
|
|
33
|
+
@rect = new_rect
|
|
34
|
+
on_width_changed if prev_width != new_rect.width
|
|
35
|
+
invalidate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Screen] the screen which owns this component.
|
|
39
|
+
def screen = Screen.instance
|
|
40
|
+
|
|
41
|
+
# Focuses this component. Equivalent to `screen.focused = self`.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def focus
|
|
44
|
+
screen.focused = self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Repaints the component. Default implementation does nothing.
|
|
48
|
+
#
|
|
49
|
+
# The component must fully draw over {#rect}, and must not draw outside of
|
|
50
|
+
# {#rect}.
|
|
51
|
+
#
|
|
52
|
+
# Tip: use {#clear_background} to clear component background before painting.
|
|
53
|
+
# @return [void]
|
|
54
|
+
def repaint; end
|
|
55
|
+
|
|
56
|
+
# Called when a character is pressed on the keyboard.
|
|
57
|
+
#
|
|
58
|
+
# Also called for inactive components. Inactive component should just return
|
|
59
|
+
# false.
|
|
60
|
+
#
|
|
61
|
+
# Default implementation searches for a component with {#key_shortcut} and
|
|
62
|
+
# focuses it. The shortcut search is suppressed while the focused component
|
|
63
|
+
# owns the hardware cursor (e.g. a {Component::TextField} the user is
|
|
64
|
+
# typing into) so that hotkeys don't steal printable keys from the editor.
|
|
65
|
+
# @param key [String] a key.
|
|
66
|
+
# @return [Boolean] true if the key was handled, false if not.
|
|
67
|
+
def handle_key(key)
|
|
68
|
+
return false unless screen.cursor_position.nil?
|
|
69
|
+
|
|
70
|
+
c = find_shortcut_component(key)
|
|
71
|
+
if !c.nil?
|
|
72
|
+
screen.focused = c
|
|
73
|
+
true
|
|
74
|
+
else
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A global keyboard shortcut. When pressed, will focus this component.
|
|
80
|
+
# @return [String, nil] shortcut, `nil` by default.
|
|
81
|
+
attr_accessor :key_shortcut
|
|
82
|
+
|
|
83
|
+
# @param key [String] keyboard key to look up.
|
|
84
|
+
# @return [Component, nil] the component whose {#key_shortcut} matches `key`,
|
|
85
|
+
# or nil.
|
|
86
|
+
def find_shortcut_component(key)
|
|
87
|
+
return self if key_shortcut == key
|
|
88
|
+
|
|
89
|
+
children.each do |child|
|
|
90
|
+
sc = child.find_shortcut_component(key)
|
|
91
|
+
return sc unless sc.nil?
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Handles mouse event. Default implementation focuses this component when
|
|
97
|
+
# clicked (if {#focusable?}).
|
|
98
|
+
# @param event [MouseEvent]
|
|
99
|
+
# @return [void]
|
|
100
|
+
def handle_mouse(event)
|
|
101
|
+
screen.focused = self unless event.button != :left || active? || !focusable?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Boolean] true if the component is on the active chain — i.e. it
|
|
105
|
+
# is the focused component or an ancestor of it. Set by {Screen#focused=}.
|
|
106
|
+
def active? = @active
|
|
107
|
+
|
|
108
|
+
# @param active [Boolean] true if active. Set by {Screen#focused=} as it
|
|
109
|
+
# marks the focus chain (root → focused); not meant to be called directly.
|
|
110
|
+
# @return [void]
|
|
111
|
+
def active=(active)
|
|
112
|
+
active = active ? true : false
|
|
113
|
+
return unless @active != active
|
|
114
|
+
|
|
115
|
+
@active = active
|
|
116
|
+
invalidate
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether this component is a valid focus target. `false` by default —
|
|
120
|
+
# passive components like {Label} are decoration and don't accept focus.
|
|
121
|
+
# The flag gates click-to-focus ({#handle_mouse}) and the focus-cascade
|
|
122
|
+
# in container components ({HasContent#on_focus}, {Layout#on_focus}).
|
|
123
|
+
# Independent from {#active?}: every component carries the active flag, but
|
|
124
|
+
# only focusable ones can become a focus target that puts themselves and
|
|
125
|
+
# their ancestors on the active chain.
|
|
126
|
+
# @return [Boolean] true if this component can be focused.
|
|
127
|
+
def focusable? = false
|
|
128
|
+
|
|
129
|
+
# @return [Component, nil] the parent component or nil if the component has
|
|
130
|
+
# no parent.
|
|
131
|
+
attr_reader :parent
|
|
132
|
+
|
|
133
|
+
# @return [Integer] the distance from the root component; 0 if {#parent}
|
|
134
|
+
# is nil.
|
|
135
|
+
def depth = parent.nil? ? 0 : parent.depth + 1
|
|
136
|
+
|
|
137
|
+
# @return [Component] the root component of this component hierarchy.
|
|
138
|
+
def root = parent.nil? ? self : parent.root
|
|
139
|
+
|
|
140
|
+
# List of child components, defaults to an empty array.
|
|
141
|
+
# @return [Array<Component>] child components. Must not be mutated! May be
|
|
142
|
+
# empty.
|
|
143
|
+
def children = []
|
|
144
|
+
|
|
145
|
+
# Calls block for this component and for every descendant component.
|
|
146
|
+
# @yield [component]
|
|
147
|
+
# @yieldparam component [Component]
|
|
148
|
+
# @yieldreturn [void]
|
|
149
|
+
# @return [void]
|
|
150
|
+
def on_tree(&block)
|
|
151
|
+
block.call(self)
|
|
152
|
+
children.each { it.on_tree(&block) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Called when the component receives focus.
|
|
156
|
+
# @return [void]
|
|
157
|
+
def on_focus; end
|
|
158
|
+
|
|
159
|
+
# @return [Boolean] true if this component's tree is currently mounted on
|
|
160
|
+
# the {Screen}, i.e. its root is the {ScreenPane}.
|
|
161
|
+
def attached? = root == screen.pane
|
|
162
|
+
|
|
163
|
+
# Called by container components after `child` has been detached from
|
|
164
|
+
# `self.children` (its `parent` is already nil and it is no longer in the
|
|
165
|
+
# children list). Default behavior repairs dangling focus: if the focused
|
|
166
|
+
# component lived inside the removed subtree, focus shifts to `self` so the
|
|
167
|
+
# cursor doesn't dangle on a detached component. No-op if `self` is not
|
|
168
|
+
# attached to the screen — focus state in a detached subtree is moot.
|
|
169
|
+
# @param child [Component] the just-detached child.
|
|
170
|
+
# @return [void]
|
|
171
|
+
def on_child_removed(child)
|
|
172
|
+
return unless attached?
|
|
173
|
+
|
|
174
|
+
f = screen.focused
|
|
175
|
+
return if f.nil?
|
|
176
|
+
|
|
177
|
+
cursor = f
|
|
178
|
+
until cursor.nil?
|
|
179
|
+
if cursor == child
|
|
180
|
+
screen.focused = self
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
cursor = cursor.parent
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The {Size} big enough to show the entire component contents without
|
|
188
|
+
# scrolling. Plain components have no intrinsic content and report
|
|
189
|
+
# {Size::ZERO}; container/decorative components (e.g. {Label}, {List},
|
|
190
|
+
# {Layout}, {Window}) override this to fold in their content's natural
|
|
191
|
+
# extent. Used by callers like {Component::Popup} to auto-size to
|
|
192
|
+
# whatever content was assigned, regardless of its concrete type.
|
|
193
|
+
# @return [Size]
|
|
194
|
+
def content_size = Size::ZERO
|
|
195
|
+
|
|
196
|
+
# Where the hardware terminal cursor should sit when this component is the
|
|
197
|
+
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
198
|
+
# {Screen} positions the hardware cursor after each repaint cycle by
|
|
199
|
+
# consulting the {Screen#focused} component only.
|
|
200
|
+
# @return [Point, nil] absolute screen coordinates, or nil to hide.
|
|
201
|
+
def cursor_position = nil
|
|
202
|
+
|
|
203
|
+
# @return [String] formatted keyboard hint surfaced in the status bar by
|
|
204
|
+
# {Screen} when this component is the active tiled window or the
|
|
205
|
+
# topmost popup. Empty by default; override to advertise shortcuts.
|
|
206
|
+
def keyboard_hint = ""
|
|
207
|
+
|
|
208
|
+
protected
|
|
209
|
+
|
|
210
|
+
# @param parent [Component, nil]
|
|
211
|
+
attr_writer :parent
|
|
212
|
+
|
|
213
|
+
# Called whenever the component width changes. Does nothing by default.
|
|
214
|
+
# @return [void]
|
|
215
|
+
def on_width_changed; end
|
|
216
|
+
|
|
217
|
+
# Invalidates the component: {Screen} records this component as
|
|
218
|
+
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
219
|
+
# @return [void]
|
|
220
|
+
def invalidate
|
|
221
|
+
screen.invalidate(self)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Clears the background: prints spaces into all characters occupied by the
|
|
225
|
+
# component's rect.
|
|
226
|
+
# @return [void]
|
|
227
|
+
def clear_background
|
|
228
|
+
return if rect.empty?
|
|
229
|
+
|
|
230
|
+
spaces = " " * rect.width
|
|
231
|
+
(rect.top..(rect.top + rect.height - 1)).each do |row|
|
|
232
|
+
screen.print TTY::Cursor.move_to(rect.left, row), spaces
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# An event queue. The idea is that all UI-related updates run from the thread
|
|
5
|
+
# which runs the event queue only; this removes any need for locking and/or
|
|
6
|
+
# need for thread-safety mechanisms.
|
|
7
|
+
#
|
|
8
|
+
# Any events (keypress, timer, term resize – WINCH) are captured in background
|
|
9
|
+
# threads; instead of processing the events directly the events are pushed
|
|
10
|
+
# into the event queue: this causes the events to be processed centrally,
|
|
11
|
+
# by a single thread only.
|
|
12
|
+
class EventQueue
|
|
13
|
+
# @param listen_for_keys [Boolean] if true, fires {KeyEvent}.
|
|
14
|
+
def initialize(listen_for_keys: true)
|
|
15
|
+
@queue = Thread::Queue.new
|
|
16
|
+
@listen_for_keys = listen_for_keys
|
|
17
|
+
@run_lock = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Posts event into the event queue. The event may be of any type. Since the
|
|
21
|
+
# event is passed between threads, the event object should be frozen.
|
|
22
|
+
#
|
|
23
|
+
# The function may be called from any thread.
|
|
24
|
+
# @param event [Object] the event to post to the queue, should be frozen.
|
|
25
|
+
# @return [void]
|
|
26
|
+
def post(event)
|
|
27
|
+
raise ArgumentError, "event passed across threads must be frozen, got #{event.inspect}" unless event.frozen?
|
|
28
|
+
|
|
29
|
+
@queue << event
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Submits block to be run in the event queue. Returns immediately.
|
|
33
|
+
#
|
|
34
|
+
# The function may be called from any thread.
|
|
35
|
+
# @yield called from the event-loop thread.
|
|
36
|
+
# @yieldreturn [void]
|
|
37
|
+
# @return [void]
|
|
38
|
+
def submit(&block)
|
|
39
|
+
@queue << block
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Awaits until the event queue is empty (all events have been processed).
|
|
43
|
+
# @return [void]
|
|
44
|
+
def await_empty
|
|
45
|
+
latch = Concurrent::CountDownLatch.new(1)
|
|
46
|
+
submit { latch.count_down }
|
|
47
|
+
latch.wait
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Runs the event loop and blocks. Must be run from at most one thread at the
|
|
51
|
+
# same time. Blocks until some thread calls {#stop}. Calls block for all
|
|
52
|
+
# events submitted via {#post}; the block is always called from the thread
|
|
53
|
+
# running this function.
|
|
54
|
+
#
|
|
55
|
+
# Any exception raised by block is re-thrown, causing this function to
|
|
56
|
+
# terminate.
|
|
57
|
+
# @yield [event] called for each non-internal event.
|
|
58
|
+
# @yieldparam event [Object] a posted event — typically a {KeyEvent},
|
|
59
|
+
# {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, or any object pushed
|
|
60
|
+
# via {#post}.
|
|
61
|
+
# @yieldreturn [void]
|
|
62
|
+
# @return [void]
|
|
63
|
+
def run_loop(&)
|
|
64
|
+
raise ArgumentError, "run_loop requires a block" unless block_given?
|
|
65
|
+
|
|
66
|
+
@run_lock.synchronize do
|
|
67
|
+
start_key_thread if @listen_for_keys
|
|
68
|
+
begin
|
|
69
|
+
trap_winch
|
|
70
|
+
event_loop(&)
|
|
71
|
+
ensure
|
|
72
|
+
Signal.trap("WINCH", "SYSTEM_DEFAULT")
|
|
73
|
+
@key_thread&.kill
|
|
74
|
+
@queue.clear
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean] true if this thread is running inside an event queue.
|
|
80
|
+
def locked? = @run_lock.owned?
|
|
81
|
+
|
|
82
|
+
# Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
|
|
83
|
+
# process a bunch of events before terminating.
|
|
84
|
+
#
|
|
85
|
+
# Can be called from any thread, including the thread which runs the event
|
|
86
|
+
# loop.
|
|
87
|
+
# @return [void]
|
|
88
|
+
def stop
|
|
89
|
+
@queue.clear
|
|
90
|
+
post(nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A keypress event. See {Keys} for a list of key codes.
|
|
94
|
+
#
|
|
95
|
+
# @!attribute [r] key
|
|
96
|
+
# @return [String] key code.
|
|
97
|
+
class KeyEvent < Data.define(:key)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# An error event, causes {EventQueue#run_loop} to throw `StandardError` with
|
|
101
|
+
# {#error} as its origin.
|
|
102
|
+
#
|
|
103
|
+
# @!attribute [r] error
|
|
104
|
+
# @return [StandardError] the underlying error.
|
|
105
|
+
class ErrorEvent < Data.define(:error)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# TTY has been resized. Contains the current width and height of the TTY
|
|
109
|
+
# terminal.
|
|
110
|
+
#
|
|
111
|
+
# @!attribute [r] width
|
|
112
|
+
# @return [Integer] terminal width in columns.
|
|
113
|
+
# @!attribute [r] height
|
|
114
|
+
# @return [Integer] terminal height in rows.
|
|
115
|
+
class TTYSizeEvent < Data.define(:width, :height)
|
|
116
|
+
# @param width [Integer]
|
|
117
|
+
# @param height [Integer]
|
|
118
|
+
def initialize(width:, height:)
|
|
119
|
+
super
|
|
120
|
+
return unless !width.is_a?(Integer) || !height.is_a?(Integer) || width.negative? || height.negative?
|
|
121
|
+
|
|
122
|
+
raise ArgumentError, "TTY size must be non-negative integers, got #{width.inspect} x #{height.inspect}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [TTYSizeEvent] event with current TTY size.
|
|
126
|
+
def self.create
|
|
127
|
+
height, width = TTY::Screen.size
|
|
128
|
+
TTYSizeEvent.new(width, height)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Size]
|
|
132
|
+
def size = Size.new(width, height)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Emitted once when the queue is cleared, all messages are processed and the
|
|
136
|
+
# event loop will block waiting for more messages. Perfect time for
|
|
137
|
+
# repainting windows.
|
|
138
|
+
class EmptyQueueEvent
|
|
139
|
+
include Singleton
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# @return [void]
|
|
145
|
+
def event_loop
|
|
146
|
+
loop do
|
|
147
|
+
yield EmptyQueueEvent.instance if @queue.empty?
|
|
148
|
+
event = @queue.pop
|
|
149
|
+
break if event.nil?
|
|
150
|
+
|
|
151
|
+
if event.is_a? ErrorEvent
|
|
152
|
+
begin
|
|
153
|
+
raise event.error
|
|
154
|
+
rescue StandardError
|
|
155
|
+
# Re-raise wrapped so the original error is preserved as `cause`
|
|
156
|
+
# while the loop's own backtrace shows up in the wrapper.
|
|
157
|
+
raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
|
|
158
|
+
end
|
|
159
|
+
elsif event.is_a? Proc
|
|
160
|
+
event.call
|
|
161
|
+
else
|
|
162
|
+
yield event
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Starts listening for stdin, firing {KeyEvent} on keypress.
|
|
168
|
+
# @return [void]
|
|
169
|
+
def start_key_thread
|
|
170
|
+
@key_thread = Thread.new do
|
|
171
|
+
loop do
|
|
172
|
+
key = Keys.getkey
|
|
173
|
+
event = MouseEvent.parse(key)
|
|
174
|
+
event = KeyEvent.new(key) if event.nil?
|
|
175
|
+
post event
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
post ErrorEvent.new(e)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Trap the WINCH signal (TTY resize signal) and fire {TTYSizeEvent}.
|
|
183
|
+
# @return [void]
|
|
184
|
+
def trap_winch
|
|
185
|
+
Signal.trap("WINCH") do
|
|
186
|
+
post TTYSizeEvent.create
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
post ErrorEvent.new(e)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A "synchronous" event queue – no loop is run, submitted blocks are run right
|
|
5
|
+
# away and submitted events are thrown away. Intended for testing only.
|
|
6
|
+
class FakeEventQueue
|
|
7
|
+
# @return [Boolean]
|
|
8
|
+
def locked? = true
|
|
9
|
+
# @return [void]
|
|
10
|
+
def stop; end
|
|
11
|
+
|
|
12
|
+
# @return [void]
|
|
13
|
+
def run_loop
|
|
14
|
+
raise Tuile::Error, "FakeEventQueue does not run an event loop"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [void]
|
|
18
|
+
def await_empty; end
|
|
19
|
+
|
|
20
|
+
# @yield runs the block synchronously.
|
|
21
|
+
# @yieldreturn [void]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def submit
|
|
24
|
+
yield
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param event [Object]
|
|
28
|
+
# @return [void]
|
|
29
|
+
def post(event); end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# Testing only — a screen which doesn't paint anything and pretends that the
|
|
5
|
+
# lock is held. This way, the TTY running the tests is not painted over.
|
|
6
|
+
#
|
|
7
|
+
# Intended for unit-testing individual components: instantiate a component,
|
|
8
|
+
# mutate it, and assert against {#prints} or {#invalidated?}. It does not
|
|
9
|
+
# run an event loop, so it is *not* suitable for system-testing whole apps
|
|
10
|
+
# — for that, drive the real script through a PTY (see `spec/examples/`).
|
|
11
|
+
#
|
|
12
|
+
# Call {Screen.fake} to initialize the fake screen easily. Typical usage:
|
|
13
|
+
#
|
|
14
|
+
# before { Screen.fake }
|
|
15
|
+
# after { Screen.close }
|
|
16
|
+
#
|
|
17
|
+
# it "paints its content" do
|
|
18
|
+
# label = Component::Label.new.tap { |l| l.text = "hi" }
|
|
19
|
+
# Screen.instance.content = Component::Window.new("Greeting").tap { |w| w.content = label }
|
|
20
|
+
# Screen.instance.repaint
|
|
21
|
+
# assert_includes Screen.instance.prints.join, "hi"
|
|
22
|
+
# end
|
|
23
|
+
class FakeScreen < Screen
|
|
24
|
+
def initialize
|
|
25
|
+
super
|
|
26
|
+
@event_queue = FakeEventQueue.new
|
|
27
|
+
@size = Size.new(160, 50)
|
|
28
|
+
@prints = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<String>] whatever {#print} printed so far.
|
|
32
|
+
attr_reader :prints
|
|
33
|
+
|
|
34
|
+
# @return [void]
|
|
35
|
+
def check_locked; end
|
|
36
|
+
|
|
37
|
+
# @return [void]
|
|
38
|
+
def clear
|
|
39
|
+
@prints.clear
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Doesn't print anything: collects all strings in {#prints}.
|
|
43
|
+
# @param args [String]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def print(*args)
|
|
46
|
+
@prints += args
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param component [Component] the component to check.
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def invalidated?(component) = @invalidated.include?(component)
|
|
52
|
+
|
|
53
|
+
# @return [void]
|
|
54
|
+
def invalidated_clear
|
|
55
|
+
@invalidated.clear
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/tuile/keys.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# Constants for keys returned by {.getkey} and helpers for reading them from
|
|
5
|
+
# stdin. The constants are the raw escape sequences emitted by the terminal;
|
|
6
|
+
# see https://en.wikipedia.org/wiki/ANSI_escape_code for the encoding.
|
|
7
|
+
module Keys
|
|
8
|
+
# @return [String]
|
|
9
|
+
DOWN_ARROW = "\e[B"
|
|
10
|
+
# @return [String]
|
|
11
|
+
UP_ARROW = "\e[A"
|
|
12
|
+
# @return [Array<String>]
|
|
13
|
+
DOWN_ARROWS = [DOWN_ARROW, "j"].freeze
|
|
14
|
+
# @return [Array<String>]
|
|
15
|
+
UP_ARROWS = [UP_ARROW, "k"].freeze
|
|
16
|
+
# @return [String]
|
|
17
|
+
LEFT_ARROW = "\e[D"
|
|
18
|
+
# @return [String]
|
|
19
|
+
RIGHT_ARROW = "\e[C"
|
|
20
|
+
# @return [String]
|
|
21
|
+
ESC = "\e"
|
|
22
|
+
# @return [String]
|
|
23
|
+
HOME = "\e[H"
|
|
24
|
+
# @return [String]
|
|
25
|
+
END_ = "\e[F"
|
|
26
|
+
# @return [String]
|
|
27
|
+
PAGE_UP = "\e[5~"
|
|
28
|
+
# @return [String]
|
|
29
|
+
PAGE_DOWN = "\e[6~"
|
|
30
|
+
# @return [String]
|
|
31
|
+
BACKSPACE = "\u007f"
|
|
32
|
+
# @return [String]
|
|
33
|
+
DELETE = "\e[3~"
|
|
34
|
+
# @return [String]
|
|
35
|
+
CTRL_H = "\b"
|
|
36
|
+
# @return [Array<String>]
|
|
37
|
+
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
38
|
+
# @return [String]
|
|
39
|
+
CTRL_U = "\u0015"
|
|
40
|
+
# @return [String]
|
|
41
|
+
CTRL_D = "\u0004"
|
|
42
|
+
# @return [String]
|
|
43
|
+
ENTER = "\u000d"
|
|
44
|
+
|
|
45
|
+
# Grabs a key from stdin and returns it. Blocks until the key is obtained.
|
|
46
|
+
# Reads a full ESC key sequence; see constants above for some values returned
|
|
47
|
+
# by this function.
|
|
48
|
+
# @return [String] key, such as {DOWN_ARROW}.
|
|
49
|
+
def self.getkey
|
|
50
|
+
char = $stdin.getch
|
|
51
|
+
return char unless char == Keys::ESC
|
|
52
|
+
|
|
53
|
+
# Escape sequence. Try to read more data.
|
|
54
|
+
begin
|
|
55
|
+
# Read 6 chars: mouse events are e.g. `\e[Mxyz`
|
|
56
|
+
char += $stdin.read_nonblock(6)
|
|
57
|
+
rescue IO::EAGAINWaitReadable
|
|
58
|
+
# The "ESC" key pressed => only the \e char is emitted.
|
|
59
|
+
end
|
|
60
|
+
char
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A mouse event.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] button
|
|
7
|
+
# @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
|
|
8
|
+
# `:scroll_down`; `nil` if not known.
|
|
9
|
+
# @!attribute [r] x
|
|
10
|
+
# @return [Integer] x coordinate, 0-based.
|
|
11
|
+
# @!attribute [r] y
|
|
12
|
+
# @return [Integer] y coordinate, 0-based.
|
|
13
|
+
class MouseEvent < Data.define(:button, :x, :y)
|
|
14
|
+
# @return [Point] the event's position.
|
|
15
|
+
def point = Point.new(x, y)
|
|
16
|
+
|
|
17
|
+
# Checks whether given key is a mouse event key
|
|
18
|
+
# @param key [String] key read via {Keys.getkey}
|
|
19
|
+
# @return [Boolean] true if it is a mouse event
|
|
20
|
+
def self.mouse_event?(key)
|
|
21
|
+
key.start_with?("\e[M") && key.size >= 6
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param key [String] key read via {Keys.getkey}
|
|
25
|
+
# @return [MouseEvent, nil]
|
|
26
|
+
def self.parse(key)
|
|
27
|
+
return nil unless mouse_event?(key)
|
|
28
|
+
|
|
29
|
+
button = key[3].ord - 32
|
|
30
|
+
# XTerm reports coordinates 1-based (column N is encoded as N + 32);
|
|
31
|
+
# subtract 33 so that `x` and `y` are 0-based.
|
|
32
|
+
x = key[4].ord - 33
|
|
33
|
+
y = key[5].ord - 33
|
|
34
|
+
button = case button
|
|
35
|
+
when 0 then :left
|
|
36
|
+
when 2 then :right
|
|
37
|
+
when 1 then :middle
|
|
38
|
+
when 64 then :scroll_up
|
|
39
|
+
when 65 then :scroll_down
|
|
40
|
+
end
|
|
41
|
+
MouseEvent.new(button, x, y)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [String]
|
|
45
|
+
def self.start_tracking = "\e[?1000h"
|
|
46
|
+
# @return [String]
|
|
47
|
+
def self.stop_tracking = "\e[?1000l"
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/tuile/point.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A point with `x` and `y` integer coordinates, both 0-based.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] x
|
|
7
|
+
# @return [Integer] x coordinate, 0-based.
|
|
8
|
+
# @!attribute [r] y
|
|
9
|
+
# @return [Integer] y coordinate, 0-based.
|
|
10
|
+
class Point < Data.define(:x, :y)
|
|
11
|
+
# @return [String]
|
|
12
|
+
def to_s = "#{x},#{y}"
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/tuile/rect.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A rectangle, with integer `left`, `top`, `width` and `height`, all 0-based.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] left
|
|
7
|
+
# @return [Integer] left edge, 0-based.
|
|
8
|
+
# @!attribute [r] top
|
|
9
|
+
# @return [Integer] top edge, 0-based.
|
|
10
|
+
# @!attribute [r] width
|
|
11
|
+
# @return [Integer] width.
|
|
12
|
+
# @!attribute [r] height
|
|
13
|
+
# @return [Integer] height.
|
|
14
|
+
class Rect < Data.define(:left, :top, :width, :height)
|
|
15
|
+
# @return [String]
|
|
16
|
+
def to_s = "#{top_left} #{size}"
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] true if either {#width} or {#height} is zero or negative.
|
|
19
|
+
def empty?
|
|
20
|
+
width <= 0 || height <= 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param point [Point] new top-left corner.
|
|
24
|
+
# @return [Rect] positioned at the new `left`/`top`.
|
|
25
|
+
def at(point)
|
|
26
|
+
Rect.new(point.x, point.y, width, height)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Centers the rectangle — keeps {#width} and {#height} but modifies
|
|
30
|
+
# {#top} and {#left} so that the rectangle is centered on a screen.
|
|
31
|
+
# @param screen_size [Size] screen size
|
|
32
|
+
# @return [Rect] moved rectangle.
|
|
33
|
+
def centered(screen_size)
|
|
34
|
+
at(Point.new((screen_size.width - width) / 2, (screen_size.height - height) / 2))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Clamp both width and height and return a rectangle.
|
|
38
|
+
# @param max_size [Size] the max size
|
|
39
|
+
# @return [Rect]
|
|
40
|
+
def clamp(max_size)
|
|
41
|
+
new_width = width.clamp(nil, max_size.width)
|
|
42
|
+
new_height = height.clamp(nil, max_size.height)
|
|
43
|
+
new_width == width && new_height == height ? self : Rect.new(left, top, new_width, new_height)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param point [Point]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def contains?(point)
|
|
49
|
+
point.x >= left && point.x < left + width && point.y >= top && point.y < top + height
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Size]
|
|
53
|
+
def size = Size.new(width, height)
|
|
54
|
+
|
|
55
|
+
# @return [Point]
|
|
56
|
+
def top_left = Point.new(left, top)
|
|
57
|
+
end
|
|
58
|
+
end
|