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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # @return [String]
5
+ VERSION = "0.1.0"
6
+ 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