rubytui 1.2.3 → 1.2.4
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/README.md +17 -15
- data/lib/rubytui/app.rb +8 -6
- data/lib/rubytui/backends/ansi_backend.rb +32 -27
- data/lib/rubytui/buffer.rb +1 -7
- data/lib/rubytui/errors.rb +2 -0
- data/lib/rubytui/event.rb +14 -4
- data/lib/rubytui/input/parser.rb +224 -14
- data/lib/rubytui/input/reader.rb +156 -65
- data/lib/rubytui/layout/flex.rb +1 -1
- data/lib/rubytui/layout/layout.rb +25 -10
- data/lib/rubytui/text/line.rb +8 -4
- data/lib/rubytui/text/span.rb +3 -2
- data/lib/rubytui/text/text.rb +10 -5
- data/lib/rubytui/unicode.rb +1 -1
- data/lib/rubytui/version.rb +1 -1
- data/lib/rubytui/widgets/async_image.rb +42 -30
- data/lib/rubytui/widgets/block.rb +5 -5
- data/lib/rubytui/widgets/canvas.rb +26 -14
- data/lib/rubytui/widgets/chart.rb +48 -35
- data/lib/rubytui/widgets/gauge.rb +2 -2
- data/lib/rubytui/widgets/image.rb +8 -4
- data/lib/rubytui/widgets/input_field.rb +59 -17
- data/lib/rubytui/widgets/list.rb +28 -19
- data/lib/rubytui/widgets/paragraph.rb +53 -24
- data/lib/rubytui/widgets/scrollbar.rb +8 -5
- data/lib/rubytui/widgets/table.rb +26 -17
- data/lib/rubytui/widgets/tabs.rb +7 -5
- data/lib/rubytui.rb +14 -9
- metadata +3 -3
data/lib/rubytui/input/reader.rb
CHANGED
|
@@ -6,113 +6,204 @@ module RubyTUI
|
|
|
6
6
|
module Input
|
|
7
7
|
# Reads terminal input without blocking and decodes it with a {Parser}.
|
|
8
8
|
#
|
|
9
|
+
# Input is read one byte at a time, only as far as the current event needs.
|
|
10
|
+
# Key presses or mouse reports that arrive together are each returned by
|
|
11
|
+
# successive calls to {#poll}, and input typed after an event stays unread
|
|
12
|
+
# in the input, so a later Reader or a child process can still read it
|
|
13
|
+
# once this Reader is discarded.
|
|
14
|
+
#
|
|
9
15
|
# A lone ESC byte is told apart from the start of an escape sequence by
|
|
10
|
-
# waiting up to {ESC_TIMEOUT_MS} for more bytes.
|
|
16
|
+
# waiting up to {ESC_TIMEOUT_MS} for more bytes. The same wait applies to
|
|
17
|
+
# each further byte of an unfinished sequence; once no byte arrives for
|
|
18
|
+
# {ESC_TIMEOUT_MS}, an incomplete sequence is decoded as it stands (see
|
|
19
|
+
# {Parser#parse_prefix} with +partial+ false).
|
|
20
|
+
#
|
|
21
|
+
# String sequences that terminals write as replies (APC such as Kitty
|
|
22
|
+
# graphics responses, OSC, DCS, SOS, PM; see {Parser}) are read and
|
|
23
|
+
# discarded without an event when their terminator (ST or BEL) arrives.
|
|
24
|
+
# An ESC inside one that does not start ST ends it just before that ESC,
|
|
25
|
+
# also without an event.
|
|
26
|
+
#
|
|
27
|
+
# If no byte arrives for {ESC_TIMEOUT_MS}, or the input ends, before
|
|
28
|
+
# either happens, the sequence was typed, not sent: its introducer is
|
|
29
|
+
# returned as an Alt key (<tt>ESC P</tt> as Alt+P, likewise Alt+X,
|
|
30
|
+
# Alt+], Alt+^ and Alt+_) and every byte after it, a final lone ESC
|
|
31
|
+
# included, is decoded as normal input.
|
|
32
|
+
#
|
|
33
|
+
# If one reaches 4096 bytes with no terminator while bytes keep
|
|
34
|
+
# arriving, it is taken to be a reply: those bytes are discarded, and so
|
|
35
|
+
# is every further byte up to and including its terminator. Discarding
|
|
36
|
+
# also stops just before an ESC followed by a byte other than a
|
|
37
|
+
# backslash, and when no byte arrives for {ESC_TIMEOUT_MS} or the input
|
|
38
|
+
# ends; an ESC not yet discarded at that point is decoded as normal
|
|
39
|
+
# input.
|
|
40
|
+
#
|
|
41
|
+
# A CSI sequence with no final byte in its first 256 bytes is returned as
|
|
42
|
+
# Alt+[, and the bytes after <tt>ESC [</tt> are decoded as normal input
|
|
43
|
+
# (see {Parser#parse_prefix}).
|
|
44
|
+
#
|
|
45
|
+
# When the input is a TTY that reports its size (+winsize+), {#poll}
|
|
46
|
+
# also returns a {ResizeEvent} after the terminal size changes.
|
|
11
47
|
#
|
|
12
48
|
# @example
|
|
13
49
|
# reader = Input::Reader.new
|
|
14
|
-
# event = reader.poll(timeout_ms: 50) # => KeyEvent, MouseEvent or nil
|
|
50
|
+
# event = reader.poll(timeout_ms: 50) # => KeyEvent, MouseEvent, ResizeEvent or nil
|
|
15
51
|
class Reader
|
|
16
52
|
# Timeout for disambiguating standalone ESC from escape sequences (ms)
|
|
17
53
|
ESC_TIMEOUT_MS = 50
|
|
18
54
|
|
|
55
|
+
# Size at which an unterminated string sequence is taken to be a
|
|
56
|
+
# terminal reply and discarded, in bytes. No other sequence is left
|
|
57
|
+
# unfinished by {Parser#parse_prefix} at this length.
|
|
58
|
+
MAX_REPLY_BYTES = 4096
|
|
59
|
+
private_constant :MAX_REPLY_BYTES
|
|
60
|
+
|
|
19
61
|
# @param input [IO] input stream; must support <tt>IO.select</tt> and
|
|
20
62
|
# +read_nonblock+ (default: +$stdin+)
|
|
21
63
|
def initialize(input: $stdin)
|
|
22
64
|
@input = input
|
|
23
65
|
@parser = Parser.new
|
|
66
|
+
@buffer = String.new
|
|
67
|
+
@eof = false
|
|
68
|
+
@size = nil
|
|
24
69
|
end
|
|
25
70
|
|
|
26
71
|
# Poll for an event with the given timeout.
|
|
27
72
|
#
|
|
73
|
+
# Returns at once if a terminal resize is pending or input is already
|
|
74
|
+
# waiting; otherwise waits up to +timeout_ms+ for input. Each call
|
|
75
|
+
# returns at most one event; input after it is left unread for the next
|
|
76
|
+
# call. Input that yields no event (a terminal reply or an unrecognised
|
|
77
|
+
# sequence) is skipped and decoding goes on with whatever input is
|
|
78
|
+
# already waiting. At end of input (see {#eof?}) it waits the full
|
|
79
|
+
# +timeout_ms+ and returns nil, so polling loops do not spin.
|
|
80
|
+
#
|
|
28
81
|
# @param timeout_ms [Integer] maximum time to wait for input, in milliseconds
|
|
29
82
|
# (default: 100)
|
|
30
|
-
# @return [KeyEvent, MouseEvent, nil] the
|
|
31
|
-
# arrived within the timeout
|
|
83
|
+
# @return [KeyEvent, MouseEvent, ResizeEvent, nil] the next event, or nil
|
|
84
|
+
# if no input arrived within the timeout, the input read held no event
|
|
85
|
+
# and nothing more is waiting, or the input has ended
|
|
32
86
|
def poll(timeout_ms: 100)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
read_event
|
|
87
|
+
event = next_event(timeout_ms)
|
|
88
|
+
sleep(timeout_ms / 1000.0) if event.nil? && @eof && @buffer.empty?
|
|
89
|
+
event
|
|
38
90
|
end
|
|
39
91
|
|
|
40
|
-
# Blocking read - waits
|
|
92
|
+
# Blocking read - waits until an event arrives or the input ends.
|
|
41
93
|
#
|
|
42
|
-
#
|
|
94
|
+
# Waits like {#poll}, in steps of one second, until it has an event.
|
|
95
|
+
# Unlike {#poll}, it does not wait at end of input: once every buffered
|
|
96
|
+
# event has been returned it raises.
|
|
43
97
|
#
|
|
44
|
-
# @return [KeyEvent, MouseEvent] the next recognised event
|
|
98
|
+
# @return [KeyEvent, MouseEvent, ResizeEvent] the next recognised event
|
|
99
|
+
# @raise [EOFError] if the input has ended and every buffered event has
|
|
100
|
+
# been returned
|
|
45
101
|
def read
|
|
46
102
|
loop do
|
|
47
|
-
event =
|
|
103
|
+
event = next_event(1000)
|
|
48
104
|
return event if event
|
|
105
|
+
raise EOFError, "end of input" if @eof && @buffer.empty?
|
|
49
106
|
end
|
|
50
107
|
end
|
|
51
108
|
|
|
109
|
+
# Whether the end of the input has been reached.
|
|
110
|
+
#
|
|
111
|
+
# Events still buffered when this becomes true are returned by later
|
|
112
|
+
# calls to {#poll} and {#read}.
|
|
113
|
+
#
|
|
114
|
+
# @return [Boolean] true once a read has hit end of file
|
|
115
|
+
def eof?
|
|
116
|
+
@eof
|
|
117
|
+
end
|
|
118
|
+
|
|
52
119
|
private
|
|
53
120
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
end
|
|
68
|
-
elsif first.ord < 128
|
|
69
|
-
@parser.parse(first)
|
|
70
|
-
else
|
|
71
|
-
# Multi-byte UTF-8: read continuation bytes
|
|
72
|
-
bytes = read_utf8(first)
|
|
73
|
-
@parser.parse(bytes)
|
|
121
|
+
# One {#poll} step without the end-of-input wait.
|
|
122
|
+
def next_event(timeout_ms)
|
|
123
|
+
resize = resize_event
|
|
124
|
+
return resize if resize
|
|
125
|
+
|
|
126
|
+
return nil if @buffer.empty? && !fill(timeout_ms)
|
|
127
|
+
|
|
128
|
+
loop do
|
|
129
|
+
event = decode_one
|
|
130
|
+
return event if event
|
|
131
|
+
# No event (terminal reply, unrecognised sequence): go on with any
|
|
132
|
+
# input that is already waiting.
|
|
133
|
+
return nil if @buffer.empty? && !fill(0)
|
|
74
134
|
end
|
|
75
135
|
end
|
|
76
136
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
137
|
+
# Decode and drop the first sequence in the buffer, waiting up to
|
|
138
|
+
# ESC_TIMEOUT_MS at a time for the rest of an incomplete one.
|
|
139
|
+
def decode_one
|
|
140
|
+
event, length = @parser.parse_prefix(@buffer, partial: !@eof)
|
|
141
|
+
while length.zero?
|
|
142
|
+
return skip_reply if @buffer.bytesize >= MAX_REPLY_BYTES
|
|
143
|
+
|
|
144
|
+
more = fill(ESC_TIMEOUT_MS)
|
|
145
|
+
event, length = @parser.parse_prefix(@buffer, partial: more && !@eof)
|
|
146
|
+
end
|
|
147
|
+
@buffer = @buffer.byteslice(length..)
|
|
148
|
+
event
|
|
81
149
|
end
|
|
82
150
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
151
|
+
# Discard an unterminated string sequence of MAX_REPLY_BYTES and the
|
|
152
|
+
# rest of it as it arrives: through BEL or ST, or up to an ESC followed
|
|
153
|
+
# by another byte, a pause of ESC_TIMEOUT_MS or the end of input. A
|
|
154
|
+
# byte at a time, so the cost is linear. Returns nil (no event).
|
|
155
|
+
def skip_reply
|
|
156
|
+
@buffer = @buffer.end_with?("\e") ? "\e".b : String.new
|
|
157
|
+
while fill(ESC_TIMEOUT_MS)
|
|
158
|
+
case @buffer
|
|
159
|
+
when "\e" then next # may start ST
|
|
160
|
+
when "\a", "\e\\"
|
|
161
|
+
@buffer.clear
|
|
162
|
+
break
|
|
163
|
+
else
|
|
164
|
+
break if @buffer.start_with?("\e") # ESC that is not ST: decode from it
|
|
165
|
+
|
|
166
|
+
@buffer.clear
|
|
167
|
+
end
|
|
90
168
|
end
|
|
91
|
-
|
|
169
|
+
nil
|
|
92
170
|
end
|
|
93
171
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
172
|
+
# Wait up to +timeout_ms+ for input and append one byte of it to the
|
|
173
|
+
# buffer, leaving the rest unread. Returns true if a byte was read.
|
|
174
|
+
def fill(timeout_ms)
|
|
175
|
+
return false if @eof
|
|
176
|
+
return false unless IO.select([@input], nil, nil, timeout_ms / 1000.0)
|
|
177
|
+
|
|
178
|
+
@buffer << @input.read_nonblock(1)
|
|
179
|
+
true
|
|
180
|
+
rescue IO::WaitReadable
|
|
181
|
+
false
|
|
182
|
+
rescue EOFError
|
|
183
|
+
@eof = true
|
|
184
|
+
false
|
|
97
185
|
end
|
|
98
186
|
|
|
99
|
-
#
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
187
|
+
# ResizeEvent if the input terminal's size changed since the last call.
|
|
188
|
+
def resize_event
|
|
189
|
+
size = input_size
|
|
190
|
+
return nil unless size
|
|
191
|
+
|
|
192
|
+
last = @size
|
|
193
|
+
@size = size
|
|
194
|
+
return nil if last.nil? || last == size
|
|
195
|
+
|
|
196
|
+
rows, cols = size
|
|
197
|
+
ResizeEvent.new(width: cols, height: rows)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# [rows, cols] of the input terminal, or nil if unavailable.
|
|
201
|
+
def input_size
|
|
202
|
+
return nil unless @input.respond_to?(:winsize) && @input.respond_to?(:tty?) && @input.tty?
|
|
203
|
+
|
|
204
|
+
@input.winsize
|
|
205
|
+
rescue SystemCallError, IOError
|
|
206
|
+
nil
|
|
116
207
|
end
|
|
117
208
|
end
|
|
118
209
|
end
|
data/lib/rubytui/layout/flex.rb
CHANGED
|
@@ -10,7 +10,7 @@ module RubyTUI
|
|
|
10
10
|
CENTER = :center # Center items, spare space split around edges
|
|
11
11
|
SPACE_BETWEEN = :space_between # Distribute spare space between items
|
|
12
12
|
SPACE_AROUND = :space_around # Distribute spare space around items
|
|
13
|
-
STRETCH = :stretch #
|
|
13
|
+
STRETCH = :stretch # Default; currently identical to START: spare space at end
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -55,8 +55,12 @@ module RubyTUI
|
|
|
55
55
|
# Split a Rect into sub-Rects according to constraints.
|
|
56
56
|
#
|
|
57
57
|
# Sizes are solved along {#direction} over the area's length minus the
|
|
58
|
-
# total spacing; the other dimension is taken from +area+.
|
|
59
|
-
#
|
|
58
|
+
# total spacing; the other dimension is taken from +area+. +max+
|
|
59
|
+
# constraints never exceed their cap and +min+ constraints never shrink
|
|
60
|
+
# below their minimum (when the space allows it). Space no constraint can
|
|
61
|
+
# absorb (for example when every flexible constraint is a capped +max+) is
|
|
62
|
+
# left unallocated and placed according to {#flex}. The sizes never sum to
|
|
63
|
+
# more than the available length.
|
|
60
64
|
#
|
|
61
65
|
# @param area [Rect] area to split
|
|
62
66
|
# @return [Array<Rect>] one Rect per constraint, in order (empty if there
|
|
@@ -83,7 +87,7 @@ module RubyTUI
|
|
|
83
87
|
rects = []
|
|
84
88
|
|
|
85
89
|
# Calculate gaps based on flex mode
|
|
86
|
-
leading, between,
|
|
90
|
+
leading, between, _trailing = compute_flex_gaps(spare, n)
|
|
87
91
|
|
|
88
92
|
offset = leading
|
|
89
93
|
sizes.each_with_index do |size, i|
|
|
@@ -104,7 +108,7 @@ module RubyTUI
|
|
|
104
108
|
return [0, 0, 0] if spare <= 0 || n <= 0
|
|
105
109
|
|
|
106
110
|
case @flex
|
|
107
|
-
when Flex::START, Flex::STRETCH
|
|
111
|
+
when Flex::START, Flex::STRETCH # STRETCH currently behaves like START
|
|
108
112
|
[0, 0, spare]
|
|
109
113
|
when Flex::END_POS
|
|
110
114
|
[spare, 0, 0]
|
|
@@ -136,7 +140,8 @@ module RubyTUI
|
|
|
136
140
|
|
|
137
141
|
# Two-pass constraint solver:
|
|
138
142
|
# Pass 1: Resolve fixed constraints (Length, Percentage, Ratio)
|
|
139
|
-
# Pass 2: Distribute remaining space to Fill, apply Min/Max clamping
|
|
143
|
+
# Pass 2: Distribute remaining space to Fill, apply Min/Max clamping,
|
|
144
|
+
# then hand the leftover to constraints that can absorb it
|
|
140
145
|
def solve(total)
|
|
141
146
|
n = @constraints.length
|
|
142
147
|
sizes = Array.new(n, 0)
|
|
@@ -169,6 +174,7 @@ module RubyTUI
|
|
|
169
174
|
|
|
170
175
|
# Pass 2: Distribute remaining space among fill/min/max constraints
|
|
171
176
|
if fill_indices.any? && remaining > 0
|
|
177
|
+
floors = sizes.dup # min constraints keep their pass-1 size
|
|
172
178
|
total_weight = fill_indices.sum do |i|
|
|
173
179
|
c = @constraints[i]
|
|
174
180
|
c.kind == :fill ? c.value : 1
|
|
@@ -189,11 +195,20 @@ module RubyTUI
|
|
|
189
195
|
end
|
|
190
196
|
end
|
|
191
197
|
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
# Hand the leftover (rounding error, or space a max did not take) to
|
|
199
|
+
# the last flexible constraint, then fills, mins and maxes, each only
|
|
200
|
+
# within its bounds. Space nobody can absorb stays unallocated.
|
|
201
|
+
diff = total - sizes.sum
|
|
202
|
+
order = %i[fill min max].flat_map do |kind|
|
|
203
|
+
fill_indices.select { |i| @constraints[i].kind == kind }.reverse
|
|
204
|
+
end
|
|
205
|
+
[fill_indices.last, *order].each do |i|
|
|
206
|
+
break if diff.zero?
|
|
207
|
+
|
|
208
|
+
c = @constraints[i]
|
|
209
|
+
size = (sizes[i] + diff).clamp(floors[i], c.kind == :max ? c.value : Float::INFINITY)
|
|
210
|
+
diff -= size - sizes[i]
|
|
211
|
+
sizes[i] = size
|
|
197
212
|
end
|
|
198
213
|
end
|
|
199
214
|
|
data/lib/rubytui/text/line.rb
CHANGED
|
@@ -6,13 +6,17 @@ module RubyTUI
|
|
|
6
6
|
# @example
|
|
7
7
|
# Line.new([Span.new("Name: ", Style.new.bold), Span.new("Ada")])
|
|
8
8
|
class Line
|
|
9
|
-
attr_reader :spans
|
|
9
|
+
attr_reader :spans
|
|
10
|
+
|
|
11
|
+
# @return [Style] line-level style. {Widgets::Paragraph} honours it: it is
|
|
12
|
+
# patched over {Text#style} and under each {Span#style}
|
|
13
|
+
attr_reader :style
|
|
10
14
|
|
|
11
15
|
# @param spans [String, Span, Array<Span>] content; a String becomes one
|
|
12
16
|
# unstyled Span, a single Span is wrapped in an Array, an Array is used
|
|
13
17
|
# as-is (default: empty)
|
|
14
|
-
# @param style [Style] line-level style, stored as {#style}
|
|
15
|
-
# (default: {Style::DEFAULT})
|
|
18
|
+
# @param style [Style, nil] line-level style, stored as {#style} (+nil+ is
|
|
19
|
+
# stored as {Style::DEFAULT}); not applied to the spans (default: {Style::DEFAULT})
|
|
16
20
|
# @raise [ArgumentError] if +spans+ is not a String, Span or Array
|
|
17
21
|
def initialize(spans = [], style = Style::DEFAULT)
|
|
18
22
|
@spans = case spans
|
|
@@ -21,7 +25,7 @@ module RubyTUI
|
|
|
21
25
|
when Array then spans
|
|
22
26
|
else raise ArgumentError, "Line expects String, Span, or Array, got #{spans.class}"
|
|
23
27
|
end
|
|
24
|
-
@style = style
|
|
28
|
+
@style = style || Style::DEFAULT
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
# Factory: from a plain string
|
data/lib/rubytui/text/span.rb
CHANGED
|
@@ -10,10 +10,11 @@ module RubyTUI
|
|
|
10
10
|
attr_reader :content, :style
|
|
11
11
|
|
|
12
12
|
# @param content [String] text of the span
|
|
13
|
-
# @param style [Style] style applied to the text
|
|
13
|
+
# @param style [Style, nil] style applied to the text; +nil+ is stored as
|
|
14
|
+
# {Style::DEFAULT} (default: {Style::DEFAULT})
|
|
14
15
|
def initialize(content, style = Style::DEFAULT)
|
|
15
16
|
@content = content
|
|
16
|
-
@style = style
|
|
17
|
+
@style = style || Style::DEFAULT
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# @return [Integer] display width of {#content} in terminal columns
|
data/lib/rubytui/text/text.rb
CHANGED
|
@@ -6,14 +6,19 @@ module RubyTUI
|
|
|
6
6
|
# @example
|
|
7
7
|
# Text.new("first\nsecond").height # => 2
|
|
8
8
|
class Text
|
|
9
|
-
attr_reader :lines
|
|
9
|
+
attr_reader :lines
|
|
10
|
+
|
|
11
|
+
# @return [Style] text-level style. {Widgets::Paragraph} honours it: each
|
|
12
|
+
# span is drawn with the paragraph style patched with this style, then
|
|
13
|
+
# {Line#style}, then {Span#style}
|
|
14
|
+
attr_reader :style
|
|
10
15
|
|
|
11
16
|
# @param lines [String, Line, Array<Line, #to_s>] content; a String is split
|
|
12
17
|
# on newlines into unstyled Lines (trailing empty lines are dropped), a
|
|
13
18
|
# single Line is wrapped in an Array, and Array elements that are not
|
|
14
19
|
# Lines become unstyled Lines of their +to_s+ (default: empty)
|
|
15
|
-
# @param style [Style] text-level style, stored as {#style}
|
|
16
|
-
# (default: {Style::DEFAULT})
|
|
20
|
+
# @param style [Style, nil] text-level style, stored as {#style} (+nil+ is
|
|
21
|
+
# stored as {Style::DEFAULT}); not applied to the spans (default: {Style::DEFAULT})
|
|
17
22
|
# @raise [ArgumentError] if +lines+ is not a String, Line or Array
|
|
18
23
|
def initialize(lines = [], style = Style::DEFAULT)
|
|
19
24
|
@lines = case lines
|
|
@@ -22,14 +27,14 @@ module RubyTUI
|
|
|
22
27
|
when Array then lines.map { |l| l.is_a?(Line) ? l : Line.new(l.to_s) }
|
|
23
28
|
else raise ArgumentError, "Text expects String, Line, or Array, got #{lines.class}"
|
|
24
29
|
end
|
|
25
|
-
@style = style
|
|
30
|
+
@style = style || Style::DEFAULT
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
# Factory: from a plain string (splits on newlines)
|
|
29
34
|
#
|
|
30
35
|
# @param content [String] text; see {#initialize} for the other accepted types
|
|
31
36
|
# @param style [Style] text-level style stored as {#style}; not applied to
|
|
32
|
-
# the spans (default: {Style::DEFAULT})
|
|
37
|
+
# the spans, but honoured by {Widgets::Paragraph} (default: {Style::DEFAULT})
|
|
33
38
|
# @return [Text]
|
|
34
39
|
# @raise [ArgumentError] if +content+ is not a String, Line or Array
|
|
35
40
|
def self.from(content, style = Style::DEFAULT)
|
data/lib/rubytui/unicode.rb
CHANGED
data/lib/rubytui/version.rb
CHANGED
|
@@ -51,20 +51,29 @@ module RubyTUI
|
|
|
51
51
|
|
|
52
52
|
# @param loader [Proc] proc that returns PNG data (called in background thread)
|
|
53
53
|
# @param key [Object, nil] cache key (uses loader.object_id if nil)
|
|
54
|
-
# @param cache [Hash, nil] shared cache hash for storing loaded images;
|
|
55
|
-
#
|
|
54
|
+
# @param cache [Hash, nil] shared cache hash for storing loaded images; only
|
|
55
|
+
# data that passes {Image} validation is stored. If it already holds +key+,
|
|
56
|
+
# +loader+ is not called and the widget is ready immediately, or in the
|
|
57
|
+
# error state if the cached data fails validation; neither callback is
|
|
56
58
|
# called (default: nil, uses a private Hash)
|
|
57
59
|
# @param placeholder [String, Widget, nil] widget or text to show while the
|
|
58
60
|
# image is not ready. A {Widget} is rendered in the pending, loading and
|
|
59
|
-
# error states. A String is drawn centered
|
|
60
|
-
# the
|
|
61
|
+
# error states. A String is drawn centered on the middle row, truncated to
|
|
62
|
+
# the area width, while pending and while loading; in the error state
|
|
63
|
+
# +error_placeholder+ is drawn the same way instead (default: nil,
|
|
64
|
+
# nothing while pending and <tt>"Loading..."</tt> while loading)
|
|
61
65
|
# @param error_placeholder [String, nil] text to show on error unless
|
|
62
66
|
# +placeholder+ is a {Widget} (default: nil, shows <tt>"[Error]"</tt>)
|
|
63
67
|
# @param on_ready [Proc, nil] callback invoked when image is ready (for triggering redraw).
|
|
64
|
-
# Called with no arguments on the loading thread after +loader+ returns
|
|
65
|
-
#
|
|
68
|
+
# Called once, with no arguments, on the loading thread after +loader+ returns
|
|
69
|
+
# data that passes {Image} validation and the state is +:ready+; not called on
|
|
70
|
+
# a cache hit or on error. An exception it raises does not change the state
|
|
71
|
+
# and does not call +on_error+: it ends the loading thread, so Ruby reports it
|
|
72
|
+
# on $stderr (Thread#report_on_exception) and re-raises it in a thread that
|
|
73
|
+
# joins the loader
|
|
66
74
|
# @param on_error [Proc, nil] callback invoked on load error. Called on the loading
|
|
67
|
-
# thread with the exception raised by +loader+
|
|
75
|
+
# thread with the exception raised by +loader+ or by {Image} validation of the
|
|
76
|
+
# returned data (e.g. {UnsupportedImageFormat} for JPEG bytes)
|
|
68
77
|
# @param style [Style] background style, also used for placeholder text
|
|
69
78
|
# (default: {Style::DEFAULT})
|
|
70
79
|
# @param block [Widgets::Block, nil] optional wrapping block
|
|
@@ -107,9 +116,13 @@ module RubyTUI
|
|
|
107
116
|
# Start background loading if not already started.
|
|
108
117
|
#
|
|
109
118
|
# Does nothing unless the state is +:pending+. Otherwise sets the state to
|
|
110
|
-
# +:loading+ and calls +loader+ on a new thread
|
|
111
|
-
#
|
|
112
|
-
#
|
|
119
|
+
# +:loading+ and calls +loader+ on a new thread, then validates the returned
|
|
120
|
+
# data by building an {Image}. On success the data is stored in the cache,
|
|
121
|
+
# the state becomes +:ready+ and then +on_ready+ is called, outside the error
|
|
122
|
+
# handling: an exception from +on_ready+ propagates out of the thread and
|
|
123
|
+
# leaves the widget +:ready+. If +loader+ or the validation raises a
|
|
124
|
+
# StandardError, nothing is cached, the state becomes +:error+, {#error} is
|
|
125
|
+
# set to the message and +on_error+ is called with the exception.
|
|
113
126
|
#
|
|
114
127
|
# @return [self]
|
|
115
128
|
def start_loading
|
|
@@ -122,19 +135,22 @@ module RubyTUI
|
|
|
122
135
|
@thread = Thread.new do
|
|
123
136
|
begin
|
|
124
137
|
data = @loader.call
|
|
138
|
+
image = new_image(data)
|
|
125
139
|
@mutex.synchronize do
|
|
126
140
|
@image_data = data
|
|
141
|
+
@image_widget = image
|
|
127
142
|
@cache[@key] = data
|
|
128
143
|
@state = STATE_READY
|
|
129
144
|
end
|
|
130
|
-
build_image_widget
|
|
131
|
-
@on_ready&.call
|
|
132
145
|
rescue => e
|
|
133
146
|
@mutex.synchronize do
|
|
134
147
|
@error = e.message
|
|
135
148
|
@state = STATE_ERROR
|
|
136
149
|
end
|
|
137
150
|
@on_error&.call(e)
|
|
151
|
+
else
|
|
152
|
+
# Outside the rescue: an exception from the app's callback is not a load failure
|
|
153
|
+
@on_ready&.call
|
|
138
154
|
end
|
|
139
155
|
end
|
|
140
156
|
|
|
@@ -196,7 +212,7 @@ module RubyTUI
|
|
|
196
212
|
when STATE_READY
|
|
197
213
|
@image_widget&.render(render_area, buf)
|
|
198
214
|
when STATE_LOADING
|
|
199
|
-
render_placeholder(render_area, buf, "Loading...")
|
|
215
|
+
render_placeholder(render_area, buf, @placeholder || "Loading...")
|
|
200
216
|
when STATE_ERROR
|
|
201
217
|
render_placeholder(render_area, buf, @error_placeholder)
|
|
202
218
|
when STATE_PENDING
|
|
@@ -207,16 +223,8 @@ module RubyTUI
|
|
|
207
223
|
private
|
|
208
224
|
|
|
209
225
|
def build_image_widget
|
|
210
|
-
return unless @image_data
|
|
211
|
-
|
|
212
226
|
@mutex.synchronize do
|
|
213
|
-
@image_widget =
|
|
214
|
-
data: @image_data,
|
|
215
|
-
style: @style,
|
|
216
|
-
fit: @fit,
|
|
217
|
-
cell_size: @cell_size,
|
|
218
|
-
clip: true
|
|
219
|
-
)
|
|
227
|
+
@image_widget = new_image(@image_data)
|
|
220
228
|
end
|
|
221
229
|
rescue ArgumentError, UnsupportedImageFormat => e
|
|
222
230
|
@mutex.synchronize do
|
|
@@ -225,19 +233,23 @@ module RubyTUI
|
|
|
225
233
|
end
|
|
226
234
|
end
|
|
227
235
|
|
|
236
|
+
def new_image(data)
|
|
237
|
+
Image.new(
|
|
238
|
+
data: data,
|
|
239
|
+
style: @style,
|
|
240
|
+
fit: @fit,
|
|
241
|
+
cell_size: @cell_size,
|
|
242
|
+
clip: true
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Render a Widget placeholder, otherwise draw +text+ truncated to the area width and centered.
|
|
228
247
|
def render_placeholder(area, buf, text)
|
|
229
248
|
case @placeholder
|
|
230
249
|
when Widget
|
|
231
250
|
@placeholder.render(area, buf)
|
|
232
|
-
when String
|
|
233
|
-
display_text = text || @placeholder
|
|
234
|
-
# Center the placeholder text
|
|
235
|
-
x = area.x + [(area.width - Unicode.string_width(display_text)) / 2, 0].max
|
|
236
|
-
y = area.y + area.height / 2
|
|
237
|
-
buf.set_string(x, y, display_text, @style)
|
|
238
251
|
else
|
|
239
|
-
|
|
240
|
-
display_text = text.to_s
|
|
252
|
+
display_text = Unicode.truncate_to_width(text.to_s, area.width)
|
|
241
253
|
x = area.x + [(area.width - Unicode.string_width(display_text)) / 2, 0].max
|
|
242
254
|
y = area.y + area.height / 2
|
|
243
255
|
buf.set_string(x, y, display_text, @style)
|
|
@@ -45,8 +45,8 @@ module RubyTUI
|
|
|
45
45
|
# @param borders [Symbol, Array<Symbol>, Boolean, nil] sides to draw:
|
|
46
46
|
# +:all+ or +true+ (default: {ALL}), +:none+, +false+ or +nil+ for none,
|
|
47
47
|
# a single side symbol (+:top+, +:bottom+, +:left+, +:right+), or an
|
|
48
|
-
# Array of side symbols (
|
|
49
|
-
# draws all sides
|
|
48
|
+
# Array of side symbols (a frozen copy is kept; the caller's Array is not
|
|
49
|
+
# modified); any other value draws all sides
|
|
50
50
|
# @param border_set [Symbols::BorderSet] border characters (default: {Symbols::ROUNDED})
|
|
51
51
|
# @param border_style [Style] style of the border characters (default: {Style::DEFAULT})
|
|
52
52
|
# @param style [Style] style patched onto every cell of the area when it is
|
|
@@ -207,8 +207,8 @@ module RubyTUI
|
|
|
207
207
|
|
|
208
208
|
return if available <= 0
|
|
209
209
|
|
|
210
|
-
title_text =
|
|
211
|
-
title_width =
|
|
210
|
+
title_text = Unicode.truncate_to_width(@title, available)
|
|
211
|
+
title_width = Unicode.string_width(title_text)
|
|
212
212
|
|
|
213
213
|
x = case @title_alignment
|
|
214
214
|
when :center
|
|
@@ -232,7 +232,7 @@ module RubyTUI
|
|
|
232
232
|
when Symbol
|
|
233
233
|
[borders].freeze
|
|
234
234
|
when Array
|
|
235
|
-
borders.freeze
|
|
235
|
+
borders.dup.freeze
|
|
236
236
|
else
|
|
237
237
|
[:top, :bottom, :left, :right].freeze
|
|
238
238
|
end
|