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.
@@ -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 decoded event, or nil if no input
31
- # arrived within the timeout or the bytes read were not recognised
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
- timeout_sec = timeout_ms / 1000.0
34
- ready = IO.select([@input], nil, nil, timeout_sec)
35
- return nil unless ready
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 indefinitely for an event.
92
+ # Blocking read - waits until an event arrives or the input ends.
41
93
  #
42
- # Calls {#poll} in a loop until it returns an event.
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 = poll(timeout_ms: 1000)
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
- def read_event
55
- # Read the first byte
56
- first = read_byte
57
- return nil unless first
58
-
59
- if first == "\e"
60
- # Could be standalone ESC or start of escape sequence.
61
- # Wait briefly for more bytes.
62
- if has_more_input?(ESC_TIMEOUT_MS)
63
- bytes = first + read_available
64
- @parser.parse(bytes)
65
- else
66
- @parser.parse("\e")
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
- def read_byte
78
- @input.read_nonblock(1)
79
- rescue IO::WaitReadable, EOFError
80
- nil
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
- def read_available
84
- buf = String.new
85
- loop do
86
- byte = @input.read_nonblock(1)
87
- buf << byte
88
- rescue IO::WaitReadable, EOFError
89
- break
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
- buf
169
+ nil
92
170
  end
93
171
 
94
- def has_more_input?(timeout_ms)
95
- ready = IO.select([@input], nil, nil, timeout_ms / 1000.0)
96
- !ready.nil?
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
- # Read remaining bytes of a UTF-8 multibyte character
100
- def read_utf8(first_byte)
101
- ord = first_byte.ord
102
- remaining = if ord >= 0xF0 then 3
103
- elsif ord >= 0xE0 then 2
104
- elsif ord >= 0xC0 then 1
105
- else 0
106
- end
107
-
108
- bytes = first_byte
109
- remaining.times do
110
- b = read_byte
111
- break unless b
112
-
113
- bytes << b
114
- end
115
- bytes
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
@@ -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 # Stretch fill constraints to consume all space (default)
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+. Space left
59
- # unallocated is placed according to {#flex}.
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, trailing = compute_flex_gaps(spare, n)
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
- # Adjust rounding errors
193
- allocated = sizes.sum
194
- diff = total - allocated
195
- if diff != 0 && fill_indices.any?
196
- sizes[fill_indices.last] += diff
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
 
@@ -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, :style
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
@@ -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 (default: {Style::DEFAULT})
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
@@ -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, :style
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)
@@ -96,7 +96,7 @@ module RubyTUI
96
96
  def truncate_to_width(string, max_width)
97
97
  return "" if max_width <= 0
98
98
 
99
- result = String.new
99
+ result = String.new(encoding: string.encoding)
100
100
  width = 0
101
101
 
102
102
  string.grapheme_clusters.each do |gc|
@@ -2,5 +2,5 @@
2
2
 
3
3
  module RubyTUI
4
4
  # Gem version string.
5
- VERSION = "1.2.3"
5
+ VERSION = "1.2.4"
6
6
  end
@@ -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; if it
55
- # already holds +key+, the widget is ready immediately and +loader+ is not
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 only while pending; while loading
60
- # the text <tt>"Loading..."</tt> is shown instead (default: nil)
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; not
65
- # called on a cache hit
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. On success the data is
111
- # stored in the cache and the state becomes +:ready+; if +loader+ raises a
112
- # StandardError the state becomes +:error+.
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 = Image.new(
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
- # Default: show text centered
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 (the Array is frozen in place); any other value
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 = Buffer.truncate_to_width(@title, available)
211
- title_width = Buffer.string_width(title_text)
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