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
data/lib/tuile/screen.rb
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# The TTY screen. There is exactly one screen per app.
|
|
5
|
+
#
|
|
6
|
+
# A screen runs the event loop; call {#run_event_loop} to do that.
|
|
7
|
+
#
|
|
8
|
+
# A screen holds the screen lock; any UI modifications must be called from
|
|
9
|
+
# the event queue.
|
|
10
|
+
#
|
|
11
|
+
# All UI lives under a single {ScreenPane} owned by the screen. Set tiled
|
|
12
|
+
# content via {#content=}; the pane fills the entire terminal and is
|
|
13
|
+
# responsible for laying out its children.
|
|
14
|
+
#
|
|
15
|
+
# Modal popups are supported too, via {Component::Popup#open}. They
|
|
16
|
+
# auto-size to their wrapped content and are drawn centered over the
|
|
17
|
+
# tiled content.
|
|
18
|
+
#
|
|
19
|
+
# The drawing procedure is very simple: when a window needs repaint, it
|
|
20
|
+
# invalidates itself, but won't draw immediately. After the keyboard press
|
|
21
|
+
# event processing is done in the event loop, {#repaint} is called which
|
|
22
|
+
# then repaints all invalidated windows. This prevents repeated paintings.
|
|
23
|
+
class Screen
|
|
24
|
+
# Class variable (not class instance var) so the singleton survives
|
|
25
|
+
# subclassing — `FakeScreen < Screen` and `Screen.instance` see the same slot.
|
|
26
|
+
@@instance = nil # rubocop:disable Style/ClassVars
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@@instance = self # rubocop:disable Style/ClassVars
|
|
30
|
+
@event_queue = EventQueue.new
|
|
31
|
+
@size = EventQueue::TTYSizeEvent.create.size
|
|
32
|
+
@invalidated = Set.new
|
|
33
|
+
# Components being repainted right now. A component may invalidate its
|
|
34
|
+
# children during its repaint phase; this prevents double-draw.
|
|
35
|
+
@repainting = Set.new
|
|
36
|
+
# Until the event loop is run, we pretend we're in the UI thread.
|
|
37
|
+
@pretend_ui_lock = true
|
|
38
|
+
# Structural root of the component tree: holds tiled content, popup
|
|
39
|
+
# stack and status bar.
|
|
40
|
+
@pane = ScreenPane.new
|
|
41
|
+
@on_error = ->(e) { raise e }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [ScreenPane] the structural root of the component tree.
|
|
45
|
+
attr_reader :pane
|
|
46
|
+
|
|
47
|
+
# Handler invoked when a {StandardError} escapes an event handler inside
|
|
48
|
+
# the event loop (e.g. a {Component::TextField}'s `on_change` raises).
|
|
49
|
+
#
|
|
50
|
+
# The default re-raises, so the exception propagates out of
|
|
51
|
+
# {#run_event_loop} and crashes the script with a stacktrace — unhandled
|
|
52
|
+
# exceptions are bugs and should be surfaced loudly.
|
|
53
|
+
#
|
|
54
|
+
# Replace it when the host has somewhere visible to put errors, e.g. a
|
|
55
|
+
# {Component::LogWindow} wired to {Tuile.logger}:
|
|
56
|
+
#
|
|
57
|
+
# screen.on_error = lambda do |e|
|
|
58
|
+
# Tuile.logger.error("#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# The handler runs on the event-loop thread with the UI lock held.
|
|
62
|
+
# Returning normally keeps the loop alive; raising from within the handler
|
|
63
|
+
# tears the loop down and propagates out of {#run_event_loop}.
|
|
64
|
+
# @return [Proc] one-arg callable receiving the {StandardError} instance.
|
|
65
|
+
attr_accessor :on_error
|
|
66
|
+
|
|
67
|
+
# @return [Screen] the singleton instance.
|
|
68
|
+
def self.instance
|
|
69
|
+
raise Tuile::Error, "Screen not initialized; call Screen.new first" if @@instance.nil?
|
|
70
|
+
|
|
71
|
+
@@instance
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Component, nil] tiled content (forwarded to {ScreenPane}).
|
|
75
|
+
def content = @pane.content
|
|
76
|
+
|
|
77
|
+
# @param content [Component]
|
|
78
|
+
# @return [void]
|
|
79
|
+
def content=(content)
|
|
80
|
+
@pane.content = content
|
|
81
|
+
layout
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Size] current screen size.
|
|
85
|
+
attr_reader :size
|
|
86
|
+
|
|
87
|
+
# @return [Array<Component>] currently active popup components (forwarded
|
|
88
|
+
# to {ScreenPane}). The array must not be modified!
|
|
89
|
+
def popups = @pane.popups
|
|
90
|
+
|
|
91
|
+
# @return [EventQueue] the event queue.
|
|
92
|
+
attr_reader :event_queue
|
|
93
|
+
|
|
94
|
+
# Checks that the UI lock is held and the current code runs in the "UI
|
|
95
|
+
# thread".
|
|
96
|
+
# @return [void]
|
|
97
|
+
def check_locked
|
|
98
|
+
return if @pretend_ui_lock || @event_queue.locked?
|
|
99
|
+
|
|
100
|
+
raise Tuile::Error,
|
|
101
|
+
"UI lock not held: UI mutations must run on the event-loop thread; " \
|
|
102
|
+
"marshal via screen.event_queue.submit { ... }"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Clears the TTY screen.
|
|
106
|
+
# @return [void]
|
|
107
|
+
def clear
|
|
108
|
+
print TTY::Cursor.move_to(0, 0), TTY::Cursor.clear_screen
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Invalidates a component: causes the component to be repainted on next
|
|
112
|
+
# call to {#repaint}.
|
|
113
|
+
# @param component [Component]
|
|
114
|
+
# @return [void]
|
|
115
|
+
def invalidate(component)
|
|
116
|
+
check_locked
|
|
117
|
+
raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component
|
|
118
|
+
|
|
119
|
+
@invalidated << component unless @repainting.include? component
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Component, nil] currently focused component.
|
|
123
|
+
attr_reader :focused
|
|
124
|
+
|
|
125
|
+
# Sets the focused {Component}. Focused component receives keyboard events.
|
|
126
|
+
# All focusable components live under {#pane}, so this is a single uniform
|
|
127
|
+
# path (no separate popup-vs-content branches).
|
|
128
|
+
# @param focused [Component, nil] the new component to be focused.
|
|
129
|
+
def focused=(focused)
|
|
130
|
+
unless focused.nil? || focused.is_a?(Component)
|
|
131
|
+
raise TypeError, "expected Component or nil, got #{focused.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
check_locked
|
|
135
|
+
if focused.nil?
|
|
136
|
+
@focused = nil
|
|
137
|
+
@pane.on_tree { it.active = false }
|
|
138
|
+
else
|
|
139
|
+
raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
|
|
140
|
+
|
|
141
|
+
@focused = focused
|
|
142
|
+
active = Set[focused]
|
|
143
|
+
cursor = focused.parent
|
|
144
|
+
until cursor.nil?
|
|
145
|
+
active << cursor
|
|
146
|
+
cursor = cursor.parent
|
|
147
|
+
end
|
|
148
|
+
@pane.on_tree { it.active = active.include?(it) }
|
|
149
|
+
@focused.on_focus
|
|
150
|
+
end
|
|
151
|
+
# Popups own their own "q Close" prefix in #keyboard_hint; for the tiled
|
|
152
|
+
# case Screen tacks on the global "q quit" instead.
|
|
153
|
+
top_popup = @pane.popups.last
|
|
154
|
+
@pane.status_bar.text = if top_popup.nil?
|
|
155
|
+
"q #{Rainbow("quit").cadetblue} #{active_window&.keyboard_hint}".strip
|
|
156
|
+
else
|
|
157
|
+
top_popup.keyboard_hint
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Internal — use {Component::Popup#open} instead. Adds the popup to
|
|
162
|
+
# {#pane}, centers and focuses it.
|
|
163
|
+
# @api private
|
|
164
|
+
# @param window [Component::Popup]
|
|
165
|
+
# @return [void]
|
|
166
|
+
def add_popup(window)
|
|
167
|
+
check_locked
|
|
168
|
+
@pane.add_popup(window)
|
|
169
|
+
# No need to fully repaint the scene: a popup simply paints over the
|
|
170
|
+
# current screen contents.
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Runs event loop – waits for keys and sends them to active window. The
|
|
174
|
+
# function exits when the 'ESC' or 'q' key is pressed.
|
|
175
|
+
# @return [void]
|
|
176
|
+
def run_event_loop
|
|
177
|
+
@pretend_ui_lock = false
|
|
178
|
+
$stdin.echo = false
|
|
179
|
+
print MouseEvent.start_tracking
|
|
180
|
+
$stdin.raw do
|
|
181
|
+
event_loop
|
|
182
|
+
end
|
|
183
|
+
ensure
|
|
184
|
+
print MouseEvent.stop_tracking
|
|
185
|
+
print TTY::Cursor.show
|
|
186
|
+
$stdin.echo = true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [Component, nil] current active tiled component.
|
|
190
|
+
def active_window
|
|
191
|
+
check_locked
|
|
192
|
+
result = nil
|
|
193
|
+
@pane.content&.on_tree { result = it if it.is_a?(Component::Window) && it.active? }
|
|
194
|
+
result
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Internal — use {Component::Popup#close} instead. Removes the popup
|
|
198
|
+
# from {#pane}, repairs focus, and repaints the scene.
|
|
199
|
+
#
|
|
200
|
+
# Does nothing if the window is not open on this screen.
|
|
201
|
+
# @api private
|
|
202
|
+
# @param window [Component::Popup]
|
|
203
|
+
# @return [void]
|
|
204
|
+
def remove_popup(window)
|
|
205
|
+
check_locked
|
|
206
|
+
@pane.remove_popup(window)
|
|
207
|
+
needs_full_repaint
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Internal — use {Component::Popup#open?} instead.
|
|
211
|
+
# @api private
|
|
212
|
+
# @param window [Component::Popup]
|
|
213
|
+
# @return [Boolean] true if this popup is currently mounted.
|
|
214
|
+
def has_popup?(window) # rubocop:disable Naming/PredicatePrefix
|
|
215
|
+
check_locked
|
|
216
|
+
@pane.has_popup?(window)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Testing only — creates new screen, locks the UI, and prevents any
|
|
220
|
+
# redraws, so that test TTY is not painted over. {FakeScreen#initialize}
|
|
221
|
+
# self-installs as the singleton, so subsequent {Screen.instance} calls
|
|
222
|
+
# return the same object.
|
|
223
|
+
# @return [FakeScreen]
|
|
224
|
+
def self.fake = FakeScreen.new
|
|
225
|
+
|
|
226
|
+
# @return [void]
|
|
227
|
+
def close
|
|
228
|
+
clear
|
|
229
|
+
@pane = nil
|
|
230
|
+
@@instance = nil # rubocop:disable Style/ClassVars
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @return [void]
|
|
234
|
+
def self.close
|
|
235
|
+
@@instance&.close
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Prints given strings.
|
|
239
|
+
# @param args [String] stuff to print.
|
|
240
|
+
# @return [void]
|
|
241
|
+
def print(*args)
|
|
242
|
+
Kernel.print(*args)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Repaints the screen; tries to be as effective as possible, by only
|
|
246
|
+
# considering invalidated windows.
|
|
247
|
+
# @return [void]
|
|
248
|
+
def repaint
|
|
249
|
+
check_locked
|
|
250
|
+
# This simple TUI framework doesn't support window clipping since tiled
|
|
251
|
+
# windows are not expected to overlap. If there rarely is a popup, we
|
|
252
|
+
# just repaint all windows in correct order — sure they will paint over
|
|
253
|
+
# other windows, but if this is done in the right order, the final
|
|
254
|
+
# drawing will look okay. Not the most effective algorithm, but very
|
|
255
|
+
# simple and very fast in common cases.
|
|
256
|
+
|
|
257
|
+
did_paint = false
|
|
258
|
+
until @invalidated.empty?
|
|
259
|
+
did_paint = true
|
|
260
|
+
popups = @pane.popups
|
|
261
|
+
|
|
262
|
+
# Partition invalidated components into tiled vs popup-tree. Sorting
|
|
263
|
+
# by depth across the whole tree would interleave them: a tiled
|
|
264
|
+
# grandchild (depth 3) sorts after a popup's content (depth 2) and
|
|
265
|
+
# overdraws it.
|
|
266
|
+
popup_tree = Set.new
|
|
267
|
+
popups.each { |p| p.on_tree { popup_tree << it } }
|
|
268
|
+
tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
|
|
269
|
+
|
|
270
|
+
# Within the tiled tree, paint parents before children.
|
|
271
|
+
tiled.sort_by!(&:depth)
|
|
272
|
+
|
|
273
|
+
repaint = if tiled.empty?
|
|
274
|
+
# Only popups need repaint — paint just their invalidated
|
|
275
|
+
# components in depth order.
|
|
276
|
+
popup_invalidated.sort_by(&:depth)
|
|
277
|
+
else
|
|
278
|
+
# Tiled components may overdraw popups; repaint each open
|
|
279
|
+
# popup's full subtree on top, in stacking order
|
|
280
|
+
# (parent-before-child within each popup).
|
|
281
|
+
tiled + popups.flat_map { |p| collect_subtree(p) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
@repainting = repaint.to_set
|
|
285
|
+
@invalidated.clear
|
|
286
|
+
|
|
287
|
+
# Don't call {#clear} before repaint — causes flickering, and only
|
|
288
|
+
# needed when @content doesn't cover the entire screen.
|
|
289
|
+
repaint.each(&:repaint)
|
|
290
|
+
|
|
291
|
+
# Repaint done, mark all components as up-to-date.
|
|
292
|
+
@repainting.clear
|
|
293
|
+
end
|
|
294
|
+
position_cursor if did_paint
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Returns the absolute screen coordinates where the hardware cursor should
|
|
298
|
+
# sit, or nil if it should be hidden. Only the {#focused} component owns
|
|
299
|
+
# the cursor: there can be multiple active components (the focus path),
|
|
300
|
+
# but only one focused.
|
|
301
|
+
# @return [Point, nil]
|
|
302
|
+
def cursor_position = @focused&.cursor_position
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
# Collects a component and all its descendants in tree order
|
|
307
|
+
# (parent before children).
|
|
308
|
+
# @param component [Component]
|
|
309
|
+
# @return [Array<Component>]
|
|
310
|
+
def collect_subtree(component)
|
|
311
|
+
result = []
|
|
312
|
+
component.on_tree { result << it }
|
|
313
|
+
result
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Hides or moves the hardware cursor based on the current focus state.
|
|
317
|
+
# @return [void]
|
|
318
|
+
def position_cursor
|
|
319
|
+
pos = cursor_position
|
|
320
|
+
if pos.nil?
|
|
321
|
+
print TTY::Cursor.hide
|
|
322
|
+
else
|
|
323
|
+
print TTY::Cursor.move_to(pos.x, pos.y), TTY::Cursor.show
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Recalculates positions of all windows, and repaints the scene.
|
|
328
|
+
# Automatically called whenever terminal size changes. Call when the app
|
|
329
|
+
# starts. {#size} provides correct size of the terminal.
|
|
330
|
+
# @return [void]
|
|
331
|
+
def layout
|
|
332
|
+
check_locked
|
|
333
|
+
needs_full_repaint
|
|
334
|
+
@pane.rect = Rect.new(0, 0, size.width, size.height)
|
|
335
|
+
repaint
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Called after a popup is closed. Since a popup can cover any window,
|
|
339
|
+
# top-level component or other popups, we need to redraw everything.
|
|
340
|
+
# @return [void]
|
|
341
|
+
def needs_full_repaint
|
|
342
|
+
@pane&.on_tree { invalidate it }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
346
|
+
# window.
|
|
347
|
+
# @param key [String]
|
|
348
|
+
# @return [Boolean] true if the key was handled by some window.
|
|
349
|
+
def handle_key(key) = @pane.handle_key(key)
|
|
350
|
+
|
|
351
|
+
# Finds target window and calls {Component::Window#handle_mouse}.
|
|
352
|
+
# @param event [MouseEvent]
|
|
353
|
+
# @return [void]
|
|
354
|
+
def handle_mouse(event) = @pane.handle_mouse(event)
|
|
355
|
+
|
|
356
|
+
# @return [void]
|
|
357
|
+
def event_loop
|
|
358
|
+
@event_queue.run_loop do |event|
|
|
359
|
+
case event
|
|
360
|
+
when EventQueue::KeyEvent
|
|
361
|
+
key = event.key
|
|
362
|
+
handled = handle_key(key)
|
|
363
|
+
@event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
|
|
364
|
+
when MouseEvent
|
|
365
|
+
handle_mouse(event)
|
|
366
|
+
when EventQueue::TTYSizeEvent
|
|
367
|
+
@size = event.size
|
|
368
|
+
layout
|
|
369
|
+
when EventQueue::EmptyQueueEvent
|
|
370
|
+
repaint
|
|
371
|
+
end
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
@on_error.call(e)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# The structural root of the {Screen}'s component tree.
|
|
5
|
+
#
|
|
6
|
+
# {Screen} is a singleton runtime owner (event loop, lock, terminal IO,
|
|
7
|
+
# invalidation set). All actual UI lives under a {ScreenPane}: the tiled
|
|
8
|
+
# {#content}, the modal {#popups} stack, and the bottom {#status_bar}.
|
|
9
|
+
# Putting them under a single Component parent gives focus traversal a real
|
|
10
|
+
# root, makes {Component#attached?} a one-liner, and lets popup-focus repair
|
|
11
|
+
# fall out of the standard {Component#on_child_removed} hook.
|
|
12
|
+
#
|
|
13
|
+
# The pane is not a {Component::Layout}: popups deliberately overlap content
|
|
14
|
+
# (Z-ordered, full overdraw, no clipping) and key/mouse dispatch follows
|
|
15
|
+
# modal-popup rules rather than active-child dispatch.
|
|
16
|
+
class ScreenPane < Component
|
|
17
|
+
def initialize
|
|
18
|
+
super
|
|
19
|
+
@popups = []
|
|
20
|
+
# Per-popup snapshot of {Screen#focused} taken just before the popup was
|
|
21
|
+
# added. Restored when the popup closes so focus returns to where the
|
|
22
|
+
# user was, instead of falling through to {#content} and getting
|
|
23
|
+
# cascaded to the first focusable child.
|
|
24
|
+
@popup_prior_focus = {}
|
|
25
|
+
@status_bar = Component::Label.new
|
|
26
|
+
@status_bar.parent = self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Component, nil] the tiled content component.
|
|
30
|
+
attr_reader :content
|
|
31
|
+
# @return [Array<Component>] modal popups in stacking order; last is
|
|
32
|
+
# topmost. The array must not be mutated by callers.
|
|
33
|
+
attr_reader :popups
|
|
34
|
+
# @return [Component::Label] the bottom status bar.
|
|
35
|
+
attr_reader :status_bar
|
|
36
|
+
|
|
37
|
+
def focusable? = false
|
|
38
|
+
|
|
39
|
+
# Children for tree traversal: content first, popups in stacking order,
|
|
40
|
+
# status bar last.
|
|
41
|
+
def children = [*[@content].compact, *@popups, @status_bar]
|
|
42
|
+
|
|
43
|
+
# Replaces the tiled content. Wipes focus first (the new tree starts
|
|
44
|
+
# fresh), detaches the old content, then attaches the new one and
|
|
45
|
+
# re-lays out.
|
|
46
|
+
# @param content [Component]
|
|
47
|
+
def content=(content)
|
|
48
|
+
raise TypeError, "expected Component, got #{content.inspect}" unless content.is_a? Component
|
|
49
|
+
raise ArgumentError, "#{content} already has a parent #{content.parent}" unless content.parent.nil?
|
|
50
|
+
return if @content == content
|
|
51
|
+
|
|
52
|
+
screen.focused = nil
|
|
53
|
+
old = @content
|
|
54
|
+
old&.parent = nil
|
|
55
|
+
@content = content
|
|
56
|
+
content.parent = self
|
|
57
|
+
layout
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Adds a popup, centers it, focuses it, and invalidates it for repaint.
|
|
61
|
+
# @param window [Component::Popup]
|
|
62
|
+
# @return [void]
|
|
63
|
+
def add_popup(window)
|
|
64
|
+
raise TypeError, "expected Popup, got #{window.inspect}" unless window.is_a? Component::Popup
|
|
65
|
+
raise ArgumentError, "#{window} already has a parent #{window.parent}" unless window.parent.nil?
|
|
66
|
+
|
|
67
|
+
@popup_prior_focus[window] = screen.focused
|
|
68
|
+
@popups << window
|
|
69
|
+
window.parent = self
|
|
70
|
+
window.center
|
|
71
|
+
screen.focused = window
|
|
72
|
+
screen.invalidate(window)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Removes a popup. If the popup held focus, focus shifts to the now-topmost
|
|
76
|
+
# remaining popup, falling back to the focus snapshotted when the popup
|
|
77
|
+
# was opened (if still attached), then to {#content}, then to nil.
|
|
78
|
+
# @param window [Component]
|
|
79
|
+
# @return [void]
|
|
80
|
+
def remove_popup(window)
|
|
81
|
+
raise Tuile::Error, "#{window} is not an open popup on this pane" unless @popups.delete(window)
|
|
82
|
+
|
|
83
|
+
prior = @popup_prior_focus.delete(window)
|
|
84
|
+
# Detach first so the popup becomes its own root; then any prior
|
|
85
|
+
# pointing *inside* that popup is detectable via `p.root == window`.
|
|
86
|
+
window.parent = nil
|
|
87
|
+
# If any other popup recorded its prior focus inside the popup we're
|
|
88
|
+
# removing, forward it to *our* prior so chained closures still climb
|
|
89
|
+
# back to the original owner instead of stopping at a detached
|
|
90
|
+
# component.
|
|
91
|
+
@popup_prior_focus.transform_values! { |p| p && p.root == window ? prior : p }
|
|
92
|
+
|
|
93
|
+
@removing_popup_prior = prior
|
|
94
|
+
on_child_removed(window)
|
|
95
|
+
ensure
|
|
96
|
+
@removing_popup_prior = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param window [Component]
|
|
100
|
+
# @return [Boolean] true if this pane currently hosts the popup.
|
|
101
|
+
def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
|
|
102
|
+
|
|
103
|
+
# Re-lays out children whenever the pane's own rect changes.
|
|
104
|
+
# @param new_rect [Rect]
|
|
105
|
+
# @return [void]
|
|
106
|
+
def rect=(new_rect)
|
|
107
|
+
super
|
|
108
|
+
layout
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Lays out content (full pane minus the bottom row) and the status bar
|
|
112
|
+
# (bottom row). Popups self-position via {Component::Popup#center}.
|
|
113
|
+
# @return [void]
|
|
114
|
+
def layout
|
|
115
|
+
return if rect.empty?
|
|
116
|
+
|
|
117
|
+
@content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
|
|
118
|
+
@popups.each(&:center)
|
|
119
|
+
@status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Pane paints nothing itself; its children paint over the entire rect.
|
|
123
|
+
# @return [void]
|
|
124
|
+
def repaint; end
|
|
125
|
+
|
|
126
|
+
# Topmost popup is modal: it eats keys. Falls through to content only
|
|
127
|
+
# when no popup is open.
|
|
128
|
+
def handle_key(key)
|
|
129
|
+
topmost = @popups.last
|
|
130
|
+
return topmost.handle_key(key) unless topmost.nil?
|
|
131
|
+
return @content.handle_key(key) unless @content.nil?
|
|
132
|
+
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Mouse events check popups in reverse stacking order (topmost first), and
|
|
137
|
+
# fall through to content only when no popup is hit *and* there are no
|
|
138
|
+
# popups open. This preserves modal click-blocking: an open popup eats
|
|
139
|
+
# clicks even outside its rect.
|
|
140
|
+
# @param event [MouseEvent]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def handle_mouse(event)
|
|
143
|
+
clicked = @popups.rfind { it.rect.contains?(event.point) }
|
|
144
|
+
clicked = @content if clicked.nil? && @popups.empty?
|
|
145
|
+
clicked&.handle_mouse(event)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Focus repair when a child detaches. Default {Component#on_child_removed}
|
|
149
|
+
# would refocus to `self` (the pane), which isn't a useful focus target.
|
|
150
|
+
# Instead, route focus to the now-topmost popup, then to the prior focus
|
|
151
|
+
# snapshotted when this popup was opened (if still attached), then to
|
|
152
|
+
# content, then nil.
|
|
153
|
+
# @param child [Component]
|
|
154
|
+
# @return [void]
|
|
155
|
+
def on_child_removed(child)
|
|
156
|
+
return unless attached?
|
|
157
|
+
|
|
158
|
+
f = screen.focused
|
|
159
|
+
return if f.nil?
|
|
160
|
+
|
|
161
|
+
cursor = f
|
|
162
|
+
while cursor
|
|
163
|
+
if cursor == child
|
|
164
|
+
fallback = @popups.last
|
|
165
|
+
fallback ||= @removing_popup_prior if @removing_popup_prior&.attached?
|
|
166
|
+
fallback ||= @content
|
|
167
|
+
screen.focused = fallback
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
cursor = cursor.parent
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/tuile/size.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A size with integer `width` and `height`.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] width
|
|
7
|
+
# @return [Integer] width.
|
|
8
|
+
# @!attribute [r] height
|
|
9
|
+
# @return [Integer] height.
|
|
10
|
+
class Size < Data.define(:width, :height)
|
|
11
|
+
# @return [String]
|
|
12
|
+
def to_s = "#{width}x#{height}"
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] true if either {#width} or {#height} is zero or negative.
|
|
15
|
+
def empty?
|
|
16
|
+
width <= 0 || height <= 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param width [Integer]
|
|
20
|
+
# @param height [Integer]
|
|
21
|
+
# @return [Size]
|
|
22
|
+
def plus(width, height) = Size.new(self.width + width, self.height + height)
|
|
23
|
+
|
|
24
|
+
# Clamp both width and height and return a size.
|
|
25
|
+
# @param max_size [Size] the max size
|
|
26
|
+
# @return [Size]
|
|
27
|
+
def clamp(max_size)
|
|
28
|
+
new_width = width.clamp(nil, max_size.width)
|
|
29
|
+
new_height = height.clamp(nil, max_size.height)
|
|
30
|
+
new_width == width && new_height == height ? self : Size.new(new_width, new_height)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Clamp height and return a size.
|
|
34
|
+
# @param max_height [Integer] the max height
|
|
35
|
+
# @return [Size]
|
|
36
|
+
def clamp_height(max_height) = clamp(Size.new(width, max_height))
|
|
37
|
+
|
|
38
|
+
# An empty size constant.
|
|
39
|
+
# @return [Size]
|
|
40
|
+
ZERO = Size.new(0, 0)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A vertical scrollbar that computes which character to draw at each row.
|
|
5
|
+
#
|
|
6
|
+
# Uses `█` for the handle (filled track) and `░` for the empty track. There
|
|
7
|
+
# are no up/down arrows; the full height is used as the track. Handle
|
|
8
|
+
# geometry is precomputed in the constructor as {#handle_height},
|
|
9
|
+
# {#handle_start}, and {#handle_end}.
|
|
10
|
+
class VerticalScrollBar
|
|
11
|
+
# @return [Integer] number of track rows the handle occupies (height >= 1
|
|
12
|
+
# only).
|
|
13
|
+
attr_reader :handle_height
|
|
14
|
+
# @return [Integer] 0-based row where the handle starts (height >= 1 only).
|
|
15
|
+
attr_reader :handle_start
|
|
16
|
+
# @return [Integer] 0-based row where the handle ends (height >= 1 only).
|
|
17
|
+
attr_reader :handle_end
|
|
18
|
+
|
|
19
|
+
# @param height [Integer] number of rows in the scrollbar (== viewport
|
|
20
|
+
# height).
|
|
21
|
+
# @param line_count [Integer] total number of content lines.
|
|
22
|
+
# @param top_line [Integer] index of the first visible content line.
|
|
23
|
+
def initialize(height, line_count:, top_line:)
|
|
24
|
+
@height = height
|
|
25
|
+
|
|
26
|
+
return unless height >= 1
|
|
27
|
+
|
|
28
|
+
if line_count <= height
|
|
29
|
+
@handle_height = height
|
|
30
|
+
@handle_start = 0
|
|
31
|
+
@handle_end = height - 1
|
|
32
|
+
else
|
|
33
|
+
@handle_height = [(height * height / line_count.to_f).ceil, 1].max
|
|
34
|
+
@handle_start = (height * top_line / line_count.to_f).floor
|
|
35
|
+
@handle_end = @handle_start + @handle_height - 1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the scrollbar character for the given viewport row.
|
|
40
|
+
# @param row_in_viewport [Integer] 0-based row index within the viewport.
|
|
41
|
+
# @return [String] single scrollbar character.
|
|
42
|
+
def scrollbar_char(row_in_viewport)
|
|
43
|
+
row_in_viewport >= @handle_start && row_in_viewport <= @handle_end ? "█" : "░"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/tuile.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "logger"
|
|
6
|
+
require "rainbow"
|
|
7
|
+
require "singleton"
|
|
8
|
+
require "strings-truncation"
|
|
9
|
+
require "tty-cursor"
|
|
10
|
+
require "tty-screen"
|
|
11
|
+
require "unicode/display_width"
|
|
12
|
+
require "zeitwerk"
|
|
13
|
+
|
|
14
|
+
# Tuile is a small component-oriented terminal UI framework, built on top of
|
|
15
|
+
# the TTY toolkit. The name is French for a roof tile — a small piece that
|
|
16
|
+
# composes into a larger whole, which mirrors how Tuile UIs are built from
|
|
17
|
+
# {Component}s nested under a single {Screen}.
|
|
18
|
+
module Tuile
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# The logger Tuile writes to. Defaults to a null logger, so the gem is
|
|
23
|
+
# silent unless the host app opts in via `Tuile.logger = ...`. Any object
|
|
24
|
+
# duck-typing the stdlib `Logger` interface (`debug/info/warn/error/fatal`
|
|
25
|
+
# taking a string) works — including `TTY::Logger`.
|
|
26
|
+
# @return [Logger]
|
|
27
|
+
attr_writer :logger
|
|
28
|
+
|
|
29
|
+
# @return [Logger]
|
|
30
|
+
def logger
|
|
31
|
+
@logger ||= Logger.new(IO::NULL)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
loader = Zeitwerk::Loader.for_gem
|
|
36
|
+
loader.setup
|
|
37
|
+
end
|