tuile 0.1.0 → 0.2.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 +17 -0
- data/README.md +6 -9
- data/examples/file_commander.rb +0 -14
- data/examples/sampler.rb +287 -0
- data/lib/tuile/component/button.rb +86 -0
- data/lib/tuile/component/label.rb +2 -2
- data/lib/tuile/component/layout.rb +29 -12
- data/lib/tuile/component/list.rb +47 -4
- data/lib/tuile/component/text_area.rb +378 -0
- data/lib/tuile/component/text_field.rb +49 -4
- data/lib/tuile/component/window.rb +11 -3
- data/lib/tuile/component.rb +53 -5
- data/lib/tuile/event_queue.rb +14 -1
- data/lib/tuile/keys.rb +24 -4
- data/lib/tuile/screen.rb +127 -39
- data/lib/tuile/screen_pane.rb +29 -7
- data/lib/tuile/truncate.rb +83 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +1 -1
- data/sig/tuile.rbs +363 -13
- metadata +7 -17
data/lib/tuile/component.rb
CHANGED
|
@@ -44,14 +44,36 @@ module Tuile
|
|
|
44
44
|
screen.focused = self
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Repaints the component.
|
|
47
|
+
# Repaints the component.
|
|
48
48
|
#
|
|
49
|
-
# The
|
|
50
|
-
#
|
|
49
|
+
# The default does the bookkeeping that almost every component would
|
|
50
|
+
# otherwise have to remember: it clears the background and re-invalidates
|
|
51
|
+
# any direct children whose rects leave gaps in {#rect}. Concretely:
|
|
51
52
|
#
|
|
52
|
-
#
|
|
53
|
+
# - Leaf (no children): always clears, so subclasses can paint their
|
|
54
|
+
# content directly without an explicit `clear_background` call.
|
|
55
|
+
# - Container with children that fully tile {#rect}: skipped — the
|
|
56
|
+
# children themselves will repaint and cover everything.
|
|
57
|
+
# - Container with gappy children (e.g. a form layout where widgets
|
|
58
|
+
# don't tile): clears, then invalidates the children so they re-paint
|
|
59
|
+
# on top of the cleared background. This is what makes mixed
|
|
60
|
+
# field/button forms safe without each container learning a custom
|
|
61
|
+
# damage-tracking pass.
|
|
62
|
+
#
|
|
63
|
+
# Subclasses that paint their entire rect themselves (e.g. {Window}'s
|
|
64
|
+
# border draws over the area the default would clear; {Component::List}
|
|
65
|
+
# explicitly paints every row) may skip super and take full
|
|
66
|
+
# responsibility for {#rect}. Everything else should call super.
|
|
67
|
+
#
|
|
68
|
+
# A component must not draw outside of {#rect}.
|
|
53
69
|
# @return [void]
|
|
54
|
-
def repaint
|
|
70
|
+
def repaint
|
|
71
|
+
return if rect.empty? || rect.left.negative? || rect.top.negative?
|
|
72
|
+
return if children.any? && children_tile_rect?
|
|
73
|
+
|
|
74
|
+
clear_background
|
|
75
|
+
children.each { |c| screen.invalidate(c) }
|
|
76
|
+
end
|
|
55
77
|
|
|
56
78
|
# Called when a character is pressed on the keyboard.
|
|
57
79
|
#
|
|
@@ -123,9 +145,22 @@ module Tuile
|
|
|
123
145
|
# Independent from {#active?}: every component carries the active flag, but
|
|
124
146
|
# only focusable ones can become a focus target that puts themselves and
|
|
125
147
|
# their ancestors on the active chain.
|
|
148
|
+
#
|
|
149
|
+
# See also {#tab_stop?}: focusable controls _can_ receive focus (via click
|
|
150
|
+
# or programmatic assignment), but only tab stops participate in Tab /
|
|
151
|
+
# Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
|
|
152
|
+
# (so a click on chrome lands focus) but are not tab stops.
|
|
126
153
|
# @return [Boolean] true if this component can be focused.
|
|
127
154
|
def focusable? = false
|
|
128
155
|
|
|
156
|
+
# Whether this component participates in Tab / Shift+Tab focus cycling.
|
|
157
|
+
# `false` by default. Only true on components that accept direct user
|
|
158
|
+
# input (e.g. {TextField}, {List}, {Component::Button}). Implies
|
|
159
|
+
# {#focusable?} — Screen will skip non-focusable tab stops, but in
|
|
160
|
+
# practice every override should keep the two consistent.
|
|
161
|
+
# @return [Boolean] true if Tab / Shift+Tab should land on this component.
|
|
162
|
+
def tab_stop? = false
|
|
163
|
+
|
|
129
164
|
# @return [Component, nil] the parent component or nil if the component has
|
|
130
165
|
# no parent.
|
|
131
166
|
attr_reader :parent
|
|
@@ -221,6 +256,19 @@ module Tuile
|
|
|
221
256
|
screen.invalidate(self)
|
|
222
257
|
end
|
|
223
258
|
|
|
259
|
+
# Whether direct children fully tile {#rect}. Used by the default
|
|
260
|
+
# {#repaint} to decide whether the framework needs to wipe gaps.
|
|
261
|
+
#
|
|
262
|
+
# Approximated by area: sum of (non-empty) child areas vs the parent's
|
|
263
|
+
# area. Cheap, and correct as long as siblings don't overlap each other
|
|
264
|
+
# — which Tuile already requires (no clipping in the tiled tree).
|
|
265
|
+
# Children with empty rects contribute zero, since they paint nothing.
|
|
266
|
+
# @return [Boolean]
|
|
267
|
+
def children_tile_rect?
|
|
268
|
+
total = children.sum { |c| c.rect.empty? ? 0 : c.rect.width * c.rect.height }
|
|
269
|
+
total >= rect.width * rect.height
|
|
270
|
+
end
|
|
271
|
+
|
|
224
272
|
# Clears the background: prints spaces into all characters occupied by the
|
|
225
273
|
# component's rect.
|
|
226
274
|
# @return [void]
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -70,7 +70,20 @@ module Tuile
|
|
|
70
70
|
event_loop(&)
|
|
71
71
|
ensure
|
|
72
72
|
Signal.trap("WINCH", "SYSTEM_DEFAULT")
|
|
73
|
-
@key_thread
|
|
73
|
+
if @key_thread
|
|
74
|
+
# Kill returns immediately, but the key thread is typically
|
|
75
|
+
# blocked inside $stdin.getch with a termios snapshot saved in
|
|
76
|
+
# io-console's C-level ensure. If we let it run to completion
|
|
77
|
+
# *after* the outer $stdin.raw block has exited (e.g. when an
|
|
78
|
+
# exception is escaping run_event_loop), the late tcsetattr
|
|
79
|
+
# restores raw mode and leaves the terminal with ONLCR off —
|
|
80
|
+
# the stack trace then prints as one un-wrapped soft line.
|
|
81
|
+
# Joining here forces the restore to happen while we're still
|
|
82
|
+
# nested inside $stdin.raw, so raw's own restoration is the
|
|
83
|
+
# final write and the terminal lands in cooked mode.
|
|
84
|
+
@key_thread.kill
|
|
85
|
+
@key_thread.join
|
|
86
|
+
end
|
|
74
87
|
@queue.clear
|
|
75
88
|
end
|
|
76
89
|
end
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -18,17 +18,31 @@ module Tuile
|
|
|
18
18
|
# @return [String]
|
|
19
19
|
RIGHT_ARROW = "\e[C"
|
|
20
20
|
# @return [String]
|
|
21
|
+
CTRL_LEFT_ARROW = "\e[1;5D"
|
|
22
|
+
# @return [String]
|
|
23
|
+
CTRL_RIGHT_ARROW = "\e[1;5C"
|
|
24
|
+
# @return [String]
|
|
21
25
|
ESC = "\e"
|
|
22
26
|
# @return [String]
|
|
23
27
|
HOME = "\e[H"
|
|
24
28
|
# @return [String]
|
|
25
29
|
END_ = "\e[F"
|
|
30
|
+
# Home-key sequences. xterm-style (`HOME`) is the modern default, but the
|
|
31
|
+
# Linux console, rxvt, and tmux/screen in their default configuration emit
|
|
32
|
+
# the VT220-style `\e[1~` instead. Components that handle Home should
|
|
33
|
+
# match against this array so users see consistent behavior regardless of
|
|
34
|
+
# which sequence their terminal emits.
|
|
35
|
+
# @return [Array<String>]
|
|
36
|
+
HOMES = [HOME, "\e[1~"].freeze
|
|
37
|
+
# End-key sequences. See {HOMES} for why two are recognized.
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
ENDS_ = [END_, "\e[4~"].freeze
|
|
26
40
|
# @return [String]
|
|
27
41
|
PAGE_UP = "\e[5~"
|
|
28
42
|
# @return [String]
|
|
29
43
|
PAGE_DOWN = "\e[6~"
|
|
30
44
|
# @return [String]
|
|
31
|
-
BACKSPACE = "
|
|
45
|
+
BACKSPACE = ""
|
|
32
46
|
# @return [String]
|
|
33
47
|
DELETE = "\e[3~"
|
|
34
48
|
# @return [String]
|
|
@@ -36,11 +50,17 @@ module Tuile
|
|
|
36
50
|
# @return [Array<String>]
|
|
37
51
|
BACKSPACES = [BACKSPACE, CTRL_H].freeze
|
|
38
52
|
# @return [String]
|
|
39
|
-
CTRL_U = "
|
|
53
|
+
CTRL_U = ""
|
|
54
|
+
# @return [String]
|
|
55
|
+
CTRL_D = ""
|
|
56
|
+
# @return [String]
|
|
57
|
+
ENTER = "
|
|
40
58
|
# @return [String]
|
|
41
|
-
|
|
59
|
+
TAB = "\t"
|
|
60
|
+
# The terminal sequence emitted by Shift+Tab in xterm-style terminals
|
|
61
|
+
# (CSI Z). Used by {Screen} for reverse focus traversal.
|
|
42
62
|
# @return [String]
|
|
43
|
-
|
|
63
|
+
SHIFT_TAB = "\e[Z"
|
|
44
64
|
|
|
45
65
|
# Grabs a key from stdin and returns it. Blocks until the key is obtained.
|
|
46
66
|
# Reads a full ESC key sequence; see constants above for some values returned
|
data/lib/tuile/screen.rb
CHANGED
|
@@ -186,6 +186,17 @@ module Tuile
|
|
|
186
186
|
$stdin.echo = true
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Advances focus to the next {Component#tab_stop?} in tree order, wrapping
|
|
190
|
+
# around. Scope is the topmost popup if one is open, otherwise {#content}
|
|
191
|
+
# — this keeps Tab confined inside a modal popup. No-op (returns false) if
|
|
192
|
+
# the modal scope has no tab stops or no content at all.
|
|
193
|
+
# @return [Boolean] true if focus moved.
|
|
194
|
+
def focus_next = cycle_focus(forward: true)
|
|
195
|
+
|
|
196
|
+
# Mirror of {#focus_next} that walks backwards through the tab order.
|
|
197
|
+
# @return [Boolean] true if focus moved.
|
|
198
|
+
def focus_previous = cycle_focus(forward: false)
|
|
199
|
+
|
|
189
200
|
# @return [Component, nil] current active tiled component.
|
|
190
201
|
def active_window
|
|
191
202
|
check_locked
|
|
@@ -235,11 +246,31 @@ module Tuile
|
|
|
235
246
|
@@instance&.close
|
|
236
247
|
end
|
|
237
248
|
|
|
238
|
-
# Prints given strings.
|
|
249
|
+
# Prints given strings. While {#repaint} is running, writes are
|
|
250
|
+
# accumulated into a frame buffer and flushed to the terminal as a
|
|
251
|
+
# single `$stdout.write` at the end of the cycle. This stops the
|
|
252
|
+
# emulator from rendering half-finished frames (e.g. a layout's
|
|
253
|
+
# clear-background pass before its children have re-painted), which
|
|
254
|
+
# was visible as a brief flicker when the auto-clear path triggers.
|
|
255
|
+
#
|
|
256
|
+
# Outside repaint, writes go straight to stdout. We deliberately
|
|
257
|
+
# don't raise on a "print outside repaint" — that would be a useful
|
|
258
|
+
# guardrail against components painting outside the repaint loop,
|
|
259
|
+
# but it'd force terminal-housekeeping writes (`Screen#clear`,
|
|
260
|
+
# mouse-tracking start/stop, cursor-show on teardown) to bypass
|
|
261
|
+
# this method entirely and write directly to `$stdout`. {FakeScreen}
|
|
262
|
+
# overrides `print` to capture every byte into its `@prints` array,
|
|
263
|
+
# and tests that exercise `run_event_loop` against a real {Screen}
|
|
264
|
+
# would otherwise leak escape sequences to the test runner's stdout.
|
|
265
|
+
# Keeping `print` as the single sink preserves that override seam.
|
|
239
266
|
# @param args [String] stuff to print.
|
|
240
267
|
# @return [void]
|
|
241
268
|
def print(*args)
|
|
242
|
-
|
|
269
|
+
if @frame_buffer
|
|
270
|
+
args.each { |s| @frame_buffer << s.to_s }
|
|
271
|
+
else
|
|
272
|
+
Kernel.print(*args)
|
|
273
|
+
end
|
|
243
274
|
end
|
|
244
275
|
|
|
245
276
|
# Repaints the screen; tries to be as effective as possible, by only
|
|
@@ -255,43 +286,56 @@ module Tuile
|
|
|
255
286
|
# simple and very fast in common cases.
|
|
256
287
|
|
|
257
288
|
did_paint = false
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
289
|
+
@frame_buffer = +""
|
|
290
|
+
begin
|
|
291
|
+
until @invalidated.empty?
|
|
292
|
+
did_paint = true
|
|
293
|
+
popups = @pane.popups
|
|
294
|
+
|
|
295
|
+
# Partition invalidated components into tiled vs popup-tree. Sorting
|
|
296
|
+
# by depth across the whole tree would interleave them: a tiled
|
|
297
|
+
# grandchild (depth 3) sorts after a popup's content (depth 2) and
|
|
298
|
+
# overdraws it.
|
|
299
|
+
popup_tree = Set.new
|
|
300
|
+
popups.each { |p| p.on_tree { popup_tree << it } }
|
|
301
|
+
tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(it) }
|
|
302
|
+
|
|
303
|
+
# Within the tiled tree, paint parents before children.
|
|
304
|
+
tiled.sort_by!(&:depth)
|
|
305
|
+
|
|
306
|
+
repaint = if tiled.empty?
|
|
307
|
+
# Only popups need repaint — paint just their invalidated
|
|
308
|
+
# components in depth order.
|
|
309
|
+
popup_invalidated.sort_by(&:depth)
|
|
310
|
+
else
|
|
311
|
+
# Tiled components may overdraw popups; repaint each open
|
|
312
|
+
# popup's full subtree on top, in stacking order
|
|
313
|
+
# (parent-before-child within each popup).
|
|
314
|
+
tiled + popups.flat_map { |p| collect_subtree(p) }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
@repainting = repaint.to_set
|
|
318
|
+
@invalidated.clear
|
|
319
|
+
|
|
320
|
+
# Don't call {#clear} before repaint — causes flickering, and only
|
|
321
|
+
# needed when @content doesn't cover the entire screen.
|
|
322
|
+
repaint.each(&:repaint)
|
|
323
|
+
|
|
324
|
+
# Repaint done, mark all components as up-to-date.
|
|
325
|
+
@repainting.clear
|
|
326
|
+
end
|
|
327
|
+
position_cursor if did_paint
|
|
328
|
+
unless @frame_buffer.empty?
|
|
329
|
+
$stdout.write(@frame_buffer)
|
|
330
|
+
$stdout.flush
|
|
331
|
+
end
|
|
332
|
+
ensure
|
|
333
|
+
# Always release the frame buffer, even on exception, so any
|
|
334
|
+
# subsequent {#print} call (e.g. teardown emits during crash unwind)
|
|
335
|
+
# reaches stdout instead of being swallowed by a stranded buffer.
|
|
336
|
+
# The partial frame we hold here is incoherent — discard it.
|
|
337
|
+
@frame_buffer = nil
|
|
293
338
|
end
|
|
294
|
-
position_cursor if did_paint
|
|
295
339
|
end
|
|
296
340
|
|
|
297
341
|
# Returns the absolute screen coordinates where the hardware cursor should
|
|
@@ -303,6 +347,34 @@ module Tuile
|
|
|
303
347
|
|
|
304
348
|
private
|
|
305
349
|
|
|
350
|
+
# Walks the current modal scope in pre-order, collects tab stops, and
|
|
351
|
+
# advances focus by one (wrapping). When the focused component isn't in
|
|
352
|
+
# the tab order (e.g. focus is parked on a popup/window chrome with no
|
|
353
|
+
# interactable widgets), Tab goes to the first stop and Shift+Tab to the
|
|
354
|
+
# last.
|
|
355
|
+
# @param forward [Boolean]
|
|
356
|
+
# @return [Boolean] true if focus moved.
|
|
357
|
+
def cycle_focus(forward:)
|
|
358
|
+
check_locked
|
|
359
|
+
scope = @pane.popups.last || @pane.content
|
|
360
|
+
return false if scope.nil?
|
|
361
|
+
|
|
362
|
+
stops = []
|
|
363
|
+
scope.on_tree { |c| stops << c if c.tab_stop? }
|
|
364
|
+
return false if stops.empty?
|
|
365
|
+
|
|
366
|
+
idx = @focused.nil? ? nil : stops.index(@focused)
|
|
367
|
+
target = if idx.nil?
|
|
368
|
+
forward ? stops.first : stops.last
|
|
369
|
+
else
|
|
370
|
+
stops[(idx + (forward ? 1 : -1)) % stops.size]
|
|
371
|
+
end
|
|
372
|
+
return false if target.equal?(@focused)
|
|
373
|
+
|
|
374
|
+
self.focused = target
|
|
375
|
+
true
|
|
376
|
+
end
|
|
377
|
+
|
|
306
378
|
# Collects a component and all its descendants in tree order
|
|
307
379
|
# (parent before children).
|
|
308
380
|
# @param component [Component]
|
|
@@ -344,9 +416,25 @@ module Tuile
|
|
|
344
416
|
|
|
345
417
|
# A key has been pressed on the keyboard. Handle it, or forward to active
|
|
346
418
|
# window.
|
|
419
|
+
#
|
|
420
|
+
# Tab / Shift+Tab are reserved navigation keys: intercepted here before
|
|
421
|
+
# the pane sees them, so a focused {Component::TextField} (which would
|
|
422
|
+
# otherwise swallow printable keys via the standard cursor-owner
|
|
423
|
+
# suppression) doesn't trap them.
|
|
347
424
|
# @param key [String]
|
|
348
425
|
# @return [Boolean] true if the key was handled by some window.
|
|
349
|
-
def handle_key(key)
|
|
426
|
+
def handle_key(key)
|
|
427
|
+
case key
|
|
428
|
+
when Keys::TAB
|
|
429
|
+
focus_next
|
|
430
|
+
true
|
|
431
|
+
when Keys::SHIFT_TAB
|
|
432
|
+
focus_previous
|
|
433
|
+
true
|
|
434
|
+
else
|
|
435
|
+
@pane.handle_key(key)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
350
438
|
|
|
351
439
|
# Finds target window and calls {Component::Window#handle_mouse}.
|
|
352
440
|
# @param event [MouseEvent]
|
data/lib/tuile/screen_pane.rb
CHANGED
|
@@ -140,16 +140,22 @@ module Tuile
|
|
|
140
140
|
# @param event [MouseEvent]
|
|
141
141
|
# @return [void]
|
|
142
142
|
def handle_mouse(event)
|
|
143
|
-
clicked = @popups.
|
|
143
|
+
clicked = @popups.reverse_each.find { it.rect.contains?(event.point) }
|
|
144
144
|
clicked = @content if clicked.nil? && @popups.empty?
|
|
145
145
|
clicked&.handle_mouse(event)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# Focus repair when a child detaches. Default {Component#on_child_removed}
|
|
149
149
|
# would refocus to `self` (the pane), which isn't a useful focus target.
|
|
150
|
-
# Instead, route focus to the
|
|
151
|
-
# snapshotted when this popup was opened
|
|
152
|
-
#
|
|
150
|
+
# Instead, route focus to the first interactable widget in the now-topmost
|
|
151
|
+
# popup; falling back to the focus snapshotted when this popup was opened
|
|
152
|
+
# (if still attached and still focusable); then to the first interactable
|
|
153
|
+
# widget in {#content}; then to {#content} itself; then nil.
|
|
154
|
+
#
|
|
155
|
+
# "First interactable widget" = first {Component#tab_stop?} in pre-order;
|
|
156
|
+
# if a scope has no tab stops at all (a borderless ESC-to-close popup, or
|
|
157
|
+
# tiled content made entirely of {Label}s), we focus the scope's root so
|
|
158
|
+
# `q`/ESC still has somewhere to dispatch from.
|
|
153
159
|
# @param child [Component]
|
|
154
160
|
# @return [void]
|
|
155
161
|
def on_child_removed(child)
|
|
@@ -161,14 +167,30 @@ module Tuile
|
|
|
161
167
|
cursor = f
|
|
162
168
|
while cursor
|
|
163
169
|
if cursor == child
|
|
164
|
-
fallback = @popups.last
|
|
165
|
-
fallback
|
|
166
|
-
|
|
170
|
+
fallback = first_tab_stop_or_root(@popups.last)
|
|
171
|
+
if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable?
|
|
172
|
+
fallback = @removing_popup_prior
|
|
173
|
+
end
|
|
174
|
+
fallback ||= first_tab_stop_or_root(@content)
|
|
167
175
|
screen.focused = fallback
|
|
168
176
|
return
|
|
169
177
|
end
|
|
170
178
|
cursor = cursor.parent
|
|
171
179
|
end
|
|
172
180
|
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
|
|
185
|
+
# back to `root` itself when the subtree has no tab stops. Returns `nil`
|
|
186
|
+
# if `root` is `nil`.
|
|
187
|
+
# @param root [Component, nil]
|
|
188
|
+
# @return [Component, nil]
|
|
189
|
+
def first_tab_stop_or_root(root)
|
|
190
|
+
return nil if root.nil?
|
|
191
|
+
|
|
192
|
+
root.on_tree { |c| return c if c.tab_stop? }
|
|
193
|
+
root
|
|
194
|
+
end
|
|
173
195
|
end
|
|
174
196
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# Truncates a string to a given column width, preserving ANSI escape
|
|
5
|
+
# sequences and accounting for Unicode display width. Truncated output is
|
|
6
|
+
# suffixed with an ellipsis (`…`).
|
|
7
|
+
#
|
|
8
|
+
# Extracted from `strings-truncation` 0.1.0 (MIT, Piotr Murach) — only the
|
|
9
|
+
# default end-position, default-omission, no-separator path Tuile uses.
|
|
10
|
+
module Truncate
|
|
11
|
+
# @return [Regexp]
|
|
12
|
+
ANSI_REGEXP = /(\[)?\033(\[)?[;?\d]*[\dA-Za-z]([\];])?/
|
|
13
|
+
private_constant :ANSI_REGEXP
|
|
14
|
+
|
|
15
|
+
# @return [String]
|
|
16
|
+
RESET = "\e[0m"
|
|
17
|
+
private_constant :RESET
|
|
18
|
+
|
|
19
|
+
# @return [Regexp]
|
|
20
|
+
RESET_REGEXP = /#{Regexp.escape(RESET)}/
|
|
21
|
+
private_constant :RESET_REGEXP
|
|
22
|
+
|
|
23
|
+
# @return [Regexp]
|
|
24
|
+
END_REGEXP = /\A(#{ANSI_REGEXP})*\z/
|
|
25
|
+
private_constant :END_REGEXP
|
|
26
|
+
|
|
27
|
+
# @return [String]
|
|
28
|
+
OMISSION = "…"
|
|
29
|
+
private_constant :OMISSION
|
|
30
|
+
|
|
31
|
+
# @return [Integer]
|
|
32
|
+
OMISSION_WIDTH = 1
|
|
33
|
+
private_constant :OMISSION_WIDTH
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Truncate `text` to at most `length` display columns. ANSI escape
|
|
38
|
+
# sequences pass through without consuming budget; when characters are
|
|
39
|
+
# dropped, an ellipsis (`…`) is appended (and counts toward `length`).
|
|
40
|
+
#
|
|
41
|
+
# @param text [String]
|
|
42
|
+
# @param length [Integer, nil] target column width. A `nil` returns
|
|
43
|
+
# `text` unchanged.
|
|
44
|
+
# @return [String]
|
|
45
|
+
def truncate(text, length:)
|
|
46
|
+
return text if length.nil? || text.bytesize <= length
|
|
47
|
+
return "" if length.zero?
|
|
48
|
+
|
|
49
|
+
budget = length - OMISSION_WIDTH
|
|
50
|
+
scanner = StringScanner.new(text)
|
|
51
|
+
out = +""
|
|
52
|
+
visible = 0
|
|
53
|
+
ansi_open = false
|
|
54
|
+
stop = false
|
|
55
|
+
|
|
56
|
+
until scanner.eos? || stop
|
|
57
|
+
if scanner.scan(RESET_REGEXP)
|
|
58
|
+
unless scanner.eos?
|
|
59
|
+
out << scanner.matched
|
|
60
|
+
ansi_open = false
|
|
61
|
+
end
|
|
62
|
+
elsif scanner.scan(ANSI_REGEXP)
|
|
63
|
+
out << scanner.matched
|
|
64
|
+
ansi_open = true
|
|
65
|
+
else
|
|
66
|
+
char = scanner.getch
|
|
67
|
+
new_visible = visible + Unicode::DisplayWidth.of(char)
|
|
68
|
+
|
|
69
|
+
if new_visible <= budget || (scanner.check(END_REGEXP) && new_visible <= length)
|
|
70
|
+
out << char
|
|
71
|
+
visible = new_visible
|
|
72
|
+
else
|
|
73
|
+
stop = true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
out << RESET if ansi_open
|
|
79
|
+
out << OMISSION if stop
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/tuile/version.rb
CHANGED