tuile 0.6.0 → 0.8.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 +16 -0
- data/README.md +1 -1
- data/examples/sampler.rb +112 -3
- data/ideas/back-buffer.md +217 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +412 -0
- data/lib/tuile/component/button.rb +2 -5
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/label.rb +8 -8
- data/lib/tuile/component/layout.rb +1 -13
- data/lib/tuile/component/list.rb +45 -23
- data/lib/tuile/component/log_window.rb +21 -5
- data/lib/tuile/component/picker_window.rb +8 -6
- data/lib/tuile/component/popup.rb +69 -13
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +30 -10
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +30 -26
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/keys.rb +2 -6
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +109 -113
- data/lib/tuile/screen_pane.rb +81 -20
- data/lib/tuile/styled_string.rb +164 -59
- data/lib/tuile/version.rb +1 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +639 -133
- metadata +10 -4
data/lib/tuile/component/list.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Tuile
|
|
|
23
23
|
@padded_lines = []
|
|
24
24
|
@blank_padded = StyledString::EMPTY
|
|
25
25
|
@auto_scroll = false
|
|
26
|
+
@follow = true
|
|
26
27
|
@top_line = 0
|
|
27
28
|
@cursor = Cursor::None.new
|
|
28
29
|
@scrollbar_visibility = :gone
|
|
@@ -50,9 +51,18 @@ module Tuile
|
|
|
50
51
|
attr_accessor :on_cursor_changed
|
|
51
52
|
|
|
52
53
|
# @return [Boolean] if true and a line is added or new content is set,
|
|
53
|
-
# auto-scrolls to the bottom
|
|
54
|
+
# auto-scrolls to the bottom — but only while the viewport is already
|
|
55
|
+
# pinned to the last line (see {#following?}). Scroll up to read older
|
|
56
|
+
# content and appends stop yanking you back down; scroll back to the
|
|
57
|
+
# bottom and tailing resumes.
|
|
54
58
|
attr_reader :auto_scroll
|
|
55
59
|
|
|
60
|
+
# @return [Boolean] whether {#auto_scroll} is currently tailing. True
|
|
61
|
+
# while the viewport sits at the last line; flips to false the moment
|
|
62
|
+
# the user scrolls up, and back to true once they scroll to the bottom
|
|
63
|
+
# again. Only consulted when {#auto_scroll} is enabled.
|
|
64
|
+
def following? = @follow
|
|
65
|
+
|
|
56
66
|
# @return [Integer] top line of the viewport. 0 or positive.
|
|
57
67
|
attr_reader :top_line
|
|
58
68
|
|
|
@@ -87,10 +97,12 @@ module Tuile
|
|
|
87
97
|
invalidate
|
|
88
98
|
end
|
|
89
99
|
|
|
90
|
-
# Sets the new auto_scroll. If true,
|
|
100
|
+
# Sets the new auto_scroll. If true, re-engages tailing and immediately
|
|
101
|
+
# scrolls to the bottom.
|
|
91
102
|
# @param new_auto_scroll [Boolean]
|
|
92
103
|
def auto_scroll=(new_auto_scroll)
|
|
93
104
|
@auto_scroll = new_auto_scroll
|
|
105
|
+
@follow = true if new_auto_scroll
|
|
94
106
|
update_top_line_if_auto_scroll
|
|
95
107
|
end
|
|
96
108
|
|
|
@@ -113,6 +125,7 @@ module Tuile
|
|
|
113
125
|
return unless @top_line != new_top_line
|
|
114
126
|
|
|
115
127
|
@top_line = new_top_line
|
|
128
|
+
@follow = at_bottom?
|
|
116
129
|
invalidate
|
|
117
130
|
end
|
|
118
131
|
|
|
@@ -163,6 +176,7 @@ module Tuile
|
|
|
163
176
|
# @return [void]
|
|
164
177
|
def add_line(line)
|
|
165
178
|
raise ArgumentError, "line is nil" if line.nil?
|
|
179
|
+
|
|
166
180
|
add_lines [line]
|
|
167
181
|
end
|
|
168
182
|
|
|
@@ -190,11 +204,7 @@ module Tuile
|
|
|
190
204
|
# @param key [String] a key.
|
|
191
205
|
# @return [Boolean] true if the key was handled.
|
|
192
206
|
def handle_key(key)
|
|
193
|
-
if
|
|
194
|
-
false
|
|
195
|
-
elsif super
|
|
196
|
-
true
|
|
197
|
-
elsif key == Keys::PAGE_UP
|
|
207
|
+
if key == Keys::PAGE_UP
|
|
198
208
|
move_top_line_by(-viewport_lines)
|
|
199
209
|
true
|
|
200
210
|
elsif key == Keys::PAGE_DOWN
|
|
@@ -274,7 +284,7 @@ module Tuile
|
|
|
274
284
|
end
|
|
275
285
|
(0...rect.height).each do |row|
|
|
276
286
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
277
|
-
screen.
|
|
287
|
+
screen.buffer.set_line(rect.left, row + rect.top, line)
|
|
278
288
|
end
|
|
279
289
|
end
|
|
280
290
|
|
|
@@ -282,6 +292,8 @@ module Tuile
|
|
|
282
292
|
class Cursor
|
|
283
293
|
# @param position [Integer] the initial cursor position.
|
|
284
294
|
def initialize(position: 0)
|
|
295
|
+
raise "invalid position #{position}" unless position.is_a? Integer
|
|
296
|
+
|
|
285
297
|
@position = position
|
|
286
298
|
end
|
|
287
299
|
|
|
@@ -416,8 +428,10 @@ module Tuile
|
|
|
416
428
|
# empty.
|
|
417
429
|
# @param position [Integer] initial position.
|
|
418
430
|
def initialize(positions, position: positions[0])
|
|
431
|
+
raise "positions are empty" if positions.empty?
|
|
432
|
+
|
|
419
433
|
@positions = positions.sort
|
|
420
|
-
position = @positions[@positions.rindex {
|
|
434
|
+
position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
|
|
421
435
|
super(position: position)
|
|
422
436
|
end
|
|
423
437
|
|
|
@@ -427,7 +441,7 @@ module Tuile
|
|
|
427
441
|
# @return [Boolean]
|
|
428
442
|
def handle_mouse(line, event, _line_count)
|
|
429
443
|
if event.button == :left
|
|
430
|
-
prev_pos = @positions.reverse_each.find {
|
|
444
|
+
prev_pos = @positions.reverse_each.find { _1 <= line }
|
|
431
445
|
return go_to_first if prev_pos.nil?
|
|
432
446
|
|
|
433
447
|
go(prev_pos)
|
|
@@ -439,7 +453,7 @@ module Tuile
|
|
|
439
453
|
# @param line_count [Integer]
|
|
440
454
|
# @return [Array<Integer>]
|
|
441
455
|
def candidate_positions(line_count)
|
|
442
|
-
@positions.select {
|
|
456
|
+
@positions.select { _1 < line_count }
|
|
443
457
|
end
|
|
444
458
|
|
|
445
459
|
# @param _line_count [Integer]
|
|
@@ -454,7 +468,7 @@ module Tuile
|
|
|
454
468
|
# @param line_count [Integer]
|
|
455
469
|
# @return [Boolean]
|
|
456
470
|
def go_down_by(lines, line_count)
|
|
457
|
-
next_pos = @positions.find {
|
|
471
|
+
next_pos = @positions.find { _1 >= @position + lines }
|
|
458
472
|
return go_to_last(line_count) if next_pos.nil?
|
|
459
473
|
|
|
460
474
|
go(next_pos)
|
|
@@ -463,7 +477,7 @@ module Tuile
|
|
|
463
477
|
# @param lines [Integer]
|
|
464
478
|
# @return [Boolean]
|
|
465
479
|
def go_up_by(lines)
|
|
466
|
-
prev_pos = @positions.reverse_each.find {
|
|
480
|
+
prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
|
|
467
481
|
return go_to_first if prev_pos.nil?
|
|
468
482
|
|
|
469
483
|
go(prev_pos)
|
|
@@ -622,16 +636,16 @@ module Tuile
|
|
|
622
636
|
def order_for_search(candidates, current, include_current:, reverse:)
|
|
623
637
|
if reverse
|
|
624
638
|
before, after = if include_current
|
|
625
|
-
[candidates.select {
|
|
639
|
+
[candidates.select { _1 <= current }, candidates.select { _1 > current }]
|
|
626
640
|
else
|
|
627
|
-
[candidates.select {
|
|
641
|
+
[candidates.select { _1 < current }, candidates.select { _1 >= current }]
|
|
628
642
|
end
|
|
629
643
|
before.reverse + after.reverse
|
|
630
644
|
else
|
|
631
645
|
after, before = if include_current
|
|
632
|
-
[candidates.select {
|
|
646
|
+
[candidates.select { _1 >= current }, candidates.select { _1 < current }]
|
|
633
647
|
else
|
|
634
|
-
[candidates.select {
|
|
648
|
+
[candidates.select { _1 > current }, candidates.select { _1 <= current }]
|
|
635
649
|
end
|
|
636
650
|
after + before
|
|
637
651
|
end
|
|
@@ -653,6 +667,10 @@ module Tuile
|
|
|
653
667
|
# @return [Integer] the max value of {#top_line}.
|
|
654
668
|
def top_line_max = (@lines.size - rect.height).clamp(0, nil)
|
|
655
669
|
|
|
670
|
+
# @return [Boolean] whether the viewport is pinned to the last line.
|
|
671
|
+
# Drives {#following?}: re-evaluated on every {#top_line=}.
|
|
672
|
+
def at_bottom? = @top_line == top_line_max
|
|
673
|
+
|
|
656
674
|
# @return [Integer] the number of visible lines.
|
|
657
675
|
def viewport_lines = rect.height
|
|
658
676
|
|
|
@@ -675,9 +693,14 @@ module Tuile
|
|
|
675
693
|
# which would leave `top_line` past the last item once a real rect
|
|
676
694
|
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
677
695
|
# the snap-to-bottom intent is preserved.
|
|
696
|
+
#
|
|
697
|
+
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
698
|
+
# cursor snap and viewport pin are both skipped, so reading older
|
|
699
|
+
# content is not interrupted by incoming lines. {#top_line=} re-arms
|
|
700
|
+
# `@follow` when the viewport returns to the bottom.
|
|
678
701
|
# @return [void]
|
|
679
702
|
def update_top_line_if_auto_scroll
|
|
680
|
-
return unless @auto_scroll
|
|
703
|
+
return unless @auto_scroll && @follow
|
|
681
704
|
return if rect.empty?
|
|
682
705
|
|
|
683
706
|
notify_cursor_changed if @cursor.go_to_last(@lines.size)
|
|
@@ -736,15 +759,14 @@ module Tuile
|
|
|
736
759
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
737
760
|
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
|
|
738
761
|
# if not shown.
|
|
739
|
-
# @return [
|
|
740
|
-
#
|
|
762
|
+
# @return [StyledString] paintable line exactly `rect.width` columns wide;
|
|
763
|
+
# highlighted if cursor is here.
|
|
741
764
|
def paintable_line(index, row_in_viewport, scrollbar)
|
|
742
765
|
base = index < @lines.size ? @padded_lines[index] : @blank_padded
|
|
743
766
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
744
767
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
out
|
|
768
|
+
styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
|
|
769
|
+
styled
|
|
748
770
|
end
|
|
749
771
|
end
|
|
750
772
|
end
|
|
@@ -15,19 +15,35 @@ module Tuile
|
|
|
15
15
|
# @param caption [String]
|
|
16
16
|
def initialize(caption = "Log")
|
|
17
17
|
super
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
view = Component::TextView.new
|
|
19
|
+
# Word-wrap long lines (stacktraces, wide log records) rather than
|
|
20
|
+
# ellipsizing them as a {List} would — a truncated log line hides the
|
|
21
|
+
# very detail you opened the log to read.
|
|
22
|
+
view.auto_scroll = true
|
|
23
|
+
self.content = view
|
|
23
24
|
self.scrollbar = true
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# Keep the log pane at least half the screen tall even when only a few
|
|
28
|
+
# lines have been logged: a {Component::Popup} sizes to its content, which
|
|
29
|
+
# would collapse a near-empty log to two or three rows. Advice consulted
|
|
30
|
+
# by {Component::Popup#min_height} when this window is a popup's content.
|
|
31
|
+
# @return [Integer]
|
|
32
|
+
def popup_min_height = screen.size.height / 2
|
|
33
|
+
|
|
34
|
+
# Let a busy log grow past the popup's base 12-row cap (up to the
|
|
35
|
+
# 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
|
|
36
|
+
# diagnostic stream stays scrollable in a tall window. Advice consulted
|
|
37
|
+
# by {Component::Popup#max_height} when this window is a popup's content.
|
|
38
|
+
# @return [Integer]
|
|
39
|
+
def popup_max_height = screen.size.height
|
|
40
|
+
|
|
26
41
|
# Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
|
|
27
42
|
# @param string [String, nil] the line (or multiple lines) to log.
|
|
28
43
|
# @return [void]
|
|
29
44
|
def log(string)
|
|
30
45
|
return if string.nil?
|
|
46
|
+
|
|
31
47
|
screen.event_queue.submit do
|
|
32
48
|
content.add_line(string)
|
|
33
49
|
end
|
|
@@ -34,10 +34,10 @@ module Tuile
|
|
|
34
34
|
raise ArgumentError, "options must not be empty" if options.empty?
|
|
35
35
|
|
|
36
36
|
super(caption)
|
|
37
|
-
@options = options.map { Option.new(
|
|
37
|
+
@options = options.map { Option.new(_1[0], _1[1]) }
|
|
38
38
|
@block = block
|
|
39
39
|
list = Component::List.new
|
|
40
|
-
list.lines = @options.map { "#{
|
|
40
|
+
list.lines = @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }
|
|
41
41
|
list.cursor = Component::List::Cursor.new
|
|
42
42
|
list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
|
|
43
43
|
self.content = list
|
|
@@ -50,12 +50,14 @@ module Tuile
|
|
|
50
50
|
# @return [Proc, nil]
|
|
51
51
|
attr_accessor :on_pick
|
|
52
52
|
|
|
53
|
+
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
54
|
+
# (the focused component) sees the key first and handles cursor/Enter
|
|
55
|
+
# picks; anything it declines bubbles up here, where a key matching an
|
|
56
|
+
# option's `key` picks that option.
|
|
53
57
|
# @param key [String]
|
|
54
58
|
# @return [Boolean]
|
|
55
59
|
def handle_key(key)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if @options.any? { it.key == key }
|
|
60
|
+
if @options.any? { _1.key == key }
|
|
59
61
|
select_option(key)
|
|
60
62
|
true
|
|
61
63
|
else
|
|
@@ -65,7 +67,7 @@ module Tuile
|
|
|
65
67
|
|
|
66
68
|
# @return [String]
|
|
67
69
|
def keyboard_hint
|
|
68
|
-
@options.map { "#{
|
|
70
|
+
@options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
#
|
|
6
|
-
# paints nothing — it's a transparent host that handles
|
|
7
|
-
# ({#open} / {#close} / {#open?}, ESC/q to close)
|
|
8
|
-
#
|
|
5
|
+
# An overlay that wraps any {Component} as its content. Popup itself
|
|
6
|
+
# paints nothing — it's a transparent host that handles its lifecycle
|
|
7
|
+
# ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
|
|
8
|
+
# wrapped content.
|
|
9
|
+
#
|
|
10
|
+
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
11
|
+
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
12
|
+
# that floats above the content (still painted on top, still auto-sized)
|
|
13
|
+
# without taking focus or capturing input — the caller positions it (via
|
|
14
|
+
# {#rect=}) and drives it from app code. That is the building block for an
|
|
15
|
+
# autocomplete/slash-command list anchored to a {Component::TextField} or
|
|
16
|
+
# {Component::TextArea} caret: typing keeps focus (and the cursor) in the
|
|
17
|
+
# input, an {Component::TextInput#on_change} listener refills the list, and
|
|
18
|
+
# an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
|
|
9
19
|
#
|
|
10
20
|
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
11
21
|
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
@@ -27,14 +37,36 @@ module Tuile
|
|
|
27
37
|
|
|
28
38
|
# @param content [Component, nil] initial content; can be set later via
|
|
29
39
|
# {#content=}. When provided here, the popup auto-sizes to fit.
|
|
30
|
-
|
|
40
|
+
# @param modal [Boolean] true (default) for a centered, focus-grabbing,
|
|
41
|
+
# input-capturing modal; false for a non-modal overlay the caller
|
|
42
|
+
# positions and drives (see the class docs).
|
|
43
|
+
def initialize(content: nil, modal: true)
|
|
31
44
|
super()
|
|
45
|
+
@modal = modal
|
|
32
46
|
@content = nil
|
|
33
47
|
self.content = content unless content.nil?
|
|
34
48
|
end
|
|
35
49
|
|
|
50
|
+
# @return [Boolean] whether this popup is modal. See {#initialize}.
|
|
51
|
+
def modal? = @modal
|
|
52
|
+
|
|
36
53
|
def focusable? = true
|
|
37
54
|
|
|
55
|
+
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
56
|
+
# open popup shrinks or moves so its new rect no longer covers the cells
|
|
57
|
+
# it previously painted. A popup overdraws the scene without clipping and
|
|
58
|
+
# nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
|
|
59
|
+
# would repaint into the new rect and leave the vacated cells showing
|
|
60
|
+
# stale content. When the new rect fully covers the old one (the popup
|
|
61
|
+
# only grew), the fast path is correct and the full repaint is skipped.
|
|
62
|
+
# @param new_rect [Rect]
|
|
63
|
+
# @return [void]
|
|
64
|
+
def rect=(new_rect)
|
|
65
|
+
old_rect = rect
|
|
66
|
+
super
|
|
67
|
+
screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
|
|
68
|
+
end
|
|
69
|
+
|
|
38
70
|
# Mounts this popup on the {Screen}. Recomputes the popup's size from
|
|
39
71
|
# the current content first, so reopening a popup whose content has
|
|
40
72
|
# grown or shrunk while closed picks up the new size.
|
|
@@ -70,9 +102,20 @@ module Tuile
|
|
|
70
102
|
self.rect = rect.centered(screen.size)
|
|
71
103
|
end
|
|
72
104
|
|
|
73
|
-
# @return [Integer] max height the popup will grow to fit its content
|
|
74
|
-
#
|
|
75
|
-
|
|
105
|
+
# @return [Integer] max height the popup will grow to fit its content.
|
|
106
|
+
# Defers to the content's {Component#popup_max_height} advice when it
|
|
107
|
+
# gives one, else defaults to 12. Override in a subclass to allow
|
|
108
|
+
# taller popups regardless of content.
|
|
109
|
+
def max_height = @content&.popup_max_height || 12
|
|
110
|
+
|
|
111
|
+
# @return [Integer] min height the popup occupies even when its content
|
|
112
|
+
# is shorter. Defers to the content's {Component#popup_min_height}
|
|
113
|
+
# advice when it gives one, else defaults to 0 (size purely to
|
|
114
|
+
# content) — so a {Component::LogWindow} stays readable while only a
|
|
115
|
+
# few lines are in without callers wiring up a subclass. Override in a
|
|
116
|
+
# subclass to keep any popup from collapsing to a couple of rows.
|
|
117
|
+
# Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
|
|
118
|
+
def min_height = @content&.popup_min_height || 0
|
|
76
119
|
|
|
77
120
|
# Sets the popup's content and auto-sizes the popup to fit.
|
|
78
121
|
# @param new_content [Component, nil]
|
|
@@ -99,11 +142,12 @@ module Tuile
|
|
|
99
142
|
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
100
143
|
end
|
|
101
144
|
|
|
145
|
+
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
146
|
+
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
147
|
+
# focused content after that content declined to handle it.
|
|
102
148
|
# @param key [String]
|
|
103
149
|
# @return [Boolean] true if the key was handled.
|
|
104
150
|
def handle_key(key)
|
|
105
|
-
return true if super
|
|
106
|
-
|
|
107
151
|
if [Keys::ESC, "q"].include?(key)
|
|
108
152
|
close
|
|
109
153
|
true
|
|
@@ -125,12 +169,24 @@ module Tuile
|
|
|
125
169
|
|
|
126
170
|
# Recompute width/height from {#content}'s natural size and recenter
|
|
127
171
|
# if currently open. Called whenever content is (re)assigned.
|
|
172
|
+
#
|
|
173
|
+
# Computes the final (centered) rect and assigns it in one step rather
|
|
174
|
+
# than positioning at the origin and then centering: the intermediate
|
|
175
|
+
# origin rect rarely covers the previous one, which would make
|
|
176
|
+
# {#rect=}'s shrink/move detection fire a full repaint on every resize.
|
|
128
177
|
# @return [void]
|
|
129
178
|
def update_rect
|
|
179
|
+
ceiling = screen.size.height * 4 / 5
|
|
130
180
|
size = @content.content_size.clamp_height(max_height)
|
|
131
|
-
size = size.clamp(Size.new(screen.size.width * 4 / 5,
|
|
132
|
-
|
|
133
|
-
|
|
181
|
+
size = size.clamp(Size.new(screen.size.width * 4 / 5, ceiling))
|
|
182
|
+
floor = min_height.clamp(0, ceiling)
|
|
183
|
+
size = Size.new(size.width, floor) if size.height < floor
|
|
184
|
+
# A non-modal overlay is positioned by the caller, so an open one keeps
|
|
185
|
+
# its current top-left when its content resizes; a modal popup recenters.
|
|
186
|
+
origin = open? && !modal? ? Point.new(rect.left, rect.top) : Point.new(0, 0)
|
|
187
|
+
r = Rect.new(origin.x, origin.y, size.width, size.height)
|
|
188
|
+
r = r.centered(screen.size) if open? && modal?
|
|
189
|
+
self.rect = r
|
|
134
190
|
end
|
|
135
191
|
end
|
|
136
192
|
end
|
|
@@ -80,7 +80,7 @@ module Tuile
|
|
|
80
80
|
chunk = @text[r[:start], r[:length]] || ""
|
|
81
81
|
chunk + (" " * (rect.width - r[:length]))
|
|
82
82
|
end
|
|
83
|
-
screen.
|
|
83
|
+
screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -60,7 +60,7 @@ module Tuile
|
|
|
60
60
|
return if rect.empty?
|
|
61
61
|
|
|
62
62
|
padded = @text + (" " * (rect.width - @text.length))
|
|
63
|
-
screen.
|
|
63
|
+
screen.buffer.set_line(rect.left, rect.top, background(padded))
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
protected
|
|
@@ -31,6 +31,7 @@ module Tuile
|
|
|
31
31
|
@text = +""
|
|
32
32
|
@caret = 0
|
|
33
33
|
@on_change = nil
|
|
34
|
+
@on_key = nil
|
|
34
35
|
@on_escape = method(:default_on_escape)
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -49,6 +50,20 @@ module Tuile
|
|
|
49
50
|
# @return [Proc, Method, nil] one-arg callable, or nil.
|
|
50
51
|
attr_accessor :on_change
|
|
51
52
|
|
|
53
|
+
# Optional interceptor consulted before the input's own key handling.
|
|
54
|
+
# Receives the pressed key; return a truthy value to consume it (the
|
|
55
|
+
# input then ignores that key), falsy to let normal editing proceed.
|
|
56
|
+
#
|
|
57
|
+
# The keyboard analog of {#on_change}: it lets app code layer behavior
|
|
58
|
+
# onto an input without subclassing. The motivating case is an
|
|
59
|
+
# autocomplete / slash-command overlay (a non-modal {Component::Popup}):
|
|
60
|
+
# while it is open the interceptor claims Up/Down/Enter/ESC and forwards
|
|
61
|
+
# them to the overlay's list, but lets ordinary characters fall through
|
|
62
|
+
# so typing keeps editing the field (and {#on_change} keeps refilling the
|
|
63
|
+
# list).
|
|
64
|
+
# @return [Proc, Method, nil] one-arg callable, or nil.
|
|
65
|
+
attr_accessor :on_key
|
|
66
|
+
|
|
52
67
|
# Callback fired when ESC is pressed. Defaults to a closure that clears
|
|
53
68
|
# focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
|
|
54
69
|
# of bubbling to the parent — and, in particular, instead of reaching the
|
|
@@ -88,14 +103,15 @@ module Tuile
|
|
|
88
103
|
invalidate
|
|
89
104
|
end
|
|
90
105
|
|
|
91
|
-
# Handles a key.
|
|
92
|
-
#
|
|
93
|
-
#
|
|
106
|
+
# Handles a key. An {#on_key} interceptor (if set) gets first refusal —
|
|
107
|
+
# a truthy return consumes the key — otherwise it delegates to
|
|
108
|
+
# {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
|
|
109
|
+
# keys here when this input is on the focus chain, so there is no
|
|
110
|
+
# {#active?} gate.
|
|
94
111
|
# @param key [String]
|
|
95
112
|
# @return [Boolean]
|
|
96
113
|
def handle_key(key)
|
|
97
|
-
return
|
|
98
|
-
return true if super
|
|
114
|
+
return true if @on_key&.call(key)
|
|
99
115
|
|
|
100
116
|
handle_text_input_key(key)
|
|
101
117
|
end
|
|
@@ -103,13 +119,13 @@ module Tuile
|
|
|
103
119
|
protected
|
|
104
120
|
|
|
105
121
|
# Renders `text` on the field's background well, looked up from the
|
|
106
|
-
# current {Screen#theme} at paint time: {Theme#
|
|
107
|
-
# input is on the active (focus) chain, {Theme#
|
|
122
|
+
# current {Screen#theme} at paint time: {Theme#active_bg_color} when this
|
|
123
|
+
# input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
|
|
108
124
|
# visibly a field either way, distinctly highlighted when active.
|
|
109
125
|
# @param text [String]
|
|
110
|
-
# @return [
|
|
126
|
+
# @return [StyledString] text on the field's background well.
|
|
111
127
|
def background(text)
|
|
112
|
-
active? ? screen.theme.
|
|
128
|
+
StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
|
|
113
129
|
end
|
|
114
130
|
|
|
115
131
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
@@ -53,6 +53,7 @@ module Tuile
|
|
|
53
53
|
@blank_line = StyledString::EMPTY
|
|
54
54
|
@top_line = 0
|
|
55
55
|
@auto_scroll = false
|
|
56
|
+
@follow = true
|
|
56
57
|
@scrollbar_visibility = :gone
|
|
57
58
|
# The view always has at least one region — an implicit default. It
|
|
58
59
|
# owns whatever hard lines exist that no later region claims. App
|
|
@@ -80,9 +81,18 @@ module Tuile
|
|
|
80
81
|
attr_reader :scrollbar_visibility
|
|
81
82
|
|
|
82
83
|
# @return [Boolean] if true, mutating the text scrolls the viewport so
|
|
83
|
-
# the last line stays in view
|
|
84
|
+
# the last line stays in view — but only while the viewport is already
|
|
85
|
+
# pinned to the last line (see {#following?}). Scroll up to read older
|
|
86
|
+
# content and appends stop yanking you back down; scroll back to the
|
|
87
|
+
# bottom and tailing resumes. Default `false`.
|
|
84
88
|
attr_reader :auto_scroll
|
|
85
89
|
|
|
90
|
+
# @return [Boolean] whether {#auto_scroll} is currently tailing. True
|
|
91
|
+
# while the viewport sits at the last line; flips to false the moment
|
|
92
|
+
# the user scrolls up, and back to true once they scroll to the bottom
|
|
93
|
+
# again. Only consulted when {#auto_scroll} is enabled.
|
|
94
|
+
def following? = @follow
|
|
95
|
+
|
|
86
96
|
# Replaces the text. Embedded `\n` characters become hard line breaks.
|
|
87
97
|
# A `String` is parsed via {StyledString.parse} (so embedded ANSI is
|
|
88
98
|
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
@@ -347,6 +357,7 @@ module Tuile
|
|
|
347
357
|
return if @top_line == new_top_line
|
|
348
358
|
|
|
349
359
|
@top_line = new_top_line
|
|
360
|
+
@follow = at_bottom?
|
|
350
361
|
invalidate
|
|
351
362
|
end
|
|
352
363
|
|
|
@@ -361,11 +372,13 @@ module Tuile
|
|
|
361
372
|
invalidate
|
|
362
373
|
end
|
|
363
374
|
|
|
364
|
-
# Sets `auto_scroll`. If true,
|
|
375
|
+
# Sets `auto_scroll`. If true, re-engages tailing and immediately
|
|
376
|
+
# scrolls to the bottom.
|
|
365
377
|
# @param value [Boolean]
|
|
366
378
|
# @return [void]
|
|
367
379
|
def auto_scroll=(value)
|
|
368
380
|
@auto_scroll = value ? true : false
|
|
381
|
+
@follow = true if @auto_scroll
|
|
369
382
|
update_top_line_if_auto_scroll
|
|
370
383
|
end
|
|
371
384
|
|
|
@@ -424,7 +437,7 @@ module Tuile
|
|
|
424
437
|
end
|
|
425
438
|
(0...rect.height).each do |row|
|
|
426
439
|
line = paintable_line(row + @top_line, row, scrollbar)
|
|
427
|
-
screen.
|
|
440
|
+
screen.buffer.set_line(rect.left, rect.top + row, line)
|
|
428
441
|
end
|
|
429
442
|
end
|
|
430
443
|
|
|
@@ -848,14 +861,22 @@ module Tuile
|
|
|
848
861
|
self.top_line = clamped unless @top_line == clamped
|
|
849
862
|
end
|
|
850
863
|
|
|
864
|
+
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
865
|
+
# viewport pin is skipped, so reading older content is not interrupted
|
|
866
|
+
# by incoming lines. {#top_line=} re-arms `@follow` when the viewport
|
|
867
|
+
# returns to the bottom.
|
|
851
868
|
# @return [void]
|
|
852
869
|
def update_top_line_if_auto_scroll
|
|
853
|
-
return unless @auto_scroll
|
|
870
|
+
return unless @auto_scroll && @follow
|
|
854
871
|
|
|
855
872
|
target = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
856
873
|
self.top_line = target if @top_line != target
|
|
857
874
|
end
|
|
858
875
|
|
|
876
|
+
# @return [Boolean] whether the viewport is pinned to the last line.
|
|
877
|
+
# Drives {#following?}: re-evaluated on every {#top_line=}.
|
|
878
|
+
def at_bottom? = @top_line == top_line_max
|
|
879
|
+
|
|
859
880
|
# @return [Boolean]
|
|
860
881
|
def scrollbar_visible?
|
|
861
882
|
return false if rect.empty?
|
|
@@ -883,15 +904,14 @@ module Tuile
|
|
|
883
904
|
# @param index [Integer] 0-based index into `@physical_lines`.
|
|
884
905
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
885
906
|
# @param scrollbar [VerticalScrollBar, nil]
|
|
886
|
-
# @return [
|
|
887
|
-
#
|
|
888
|
-
#
|
|
889
|
-
# ASCII-string concat of the scrollbar glyph when one is present.
|
|
907
|
+
# @return [StyledString] paintable line exactly `rect.width` columns wide.
|
|
908
|
+
# Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
|
|
909
|
+
# plus a concat of the scrollbar glyph when one is present.
|
|
890
910
|
def paintable_line(index, row_in_viewport, scrollbar)
|
|
891
911
|
line = @physical_lines[index] || @blank_line
|
|
892
|
-
return line
|
|
912
|
+
return line unless scrollbar
|
|
893
913
|
|
|
894
|
-
line
|
|
914
|
+
line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
|
|
895
915
|
end
|
|
896
916
|
|
|
897
917
|
# A logical section of a {TextView}'s text — a contiguous run of
|