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.
@@ -30,14 +30,20 @@ module RubyTUI
30
30
  # Code point of the empty Braille pattern (U+2800); dot bits are added to it.
31
31
  BRAILLE_BASE = 0x2800
32
32
 
33
- attr_reader :width, :height
33
+ # @deprecated Always returns nil; will be removed in 2.0.
34
+ # @return [nil]
35
+ attr_reader :width
36
+
37
+ # @deprecated Always returns nil; will be removed in 2.0.
38
+ # @return [nil]
39
+ attr_reader :height
34
40
 
35
41
  # @param x_bounds [Array(Numeric, Numeric), nil] <tt>[min, max]</tt> data range
36
42
  # mapped to the canvas width (default: nil, computed from the x coordinates
37
43
  # of points and lines)
38
44
  # @param y_bounds [Array(Numeric, Numeric), nil] <tt>[min, max]</tt> data range
39
- # mapped to the canvas height, max at the top (default: nil; the automatic
40
- # range is currently also computed from the x coordinates of points and lines)
45
+ # mapped to the canvas height, max at the top (default: nil, computed from
46
+ # the y coordinates of points and lines)
41
47
  # @param style [Style] style for labels added without a style (default: {Style::DEFAULT})
42
48
  # @param marker_style [Style, nil] style for dots drawn without a style
43
49
  # (default: white foreground)
@@ -97,7 +103,9 @@ module RubyTUI
97
103
  #
98
104
  # @param x [Numeric] x data coordinate
99
105
  # @param y [Numeric] y data coordinate
100
- # @param text [String] text drawn starting at the cell that contains (x, y)
106
+ # @param text [String] text drawn starting at the cell that contains (x, y),
107
+ # clipped at the right edge of the area (inside +block+); a two-column
108
+ # character that would straddle the edge is not drawn
101
109
  # @param style [Style, nil] text style (default: nil, uses the canvas +style+)
102
110
  # @return [self]
103
111
  def label(x, y, text, style: nil)
@@ -109,7 +117,8 @@ module RubyTUI
109
117
  #
110
118
  # Lines are drawn first, then points, as Braille dots. Each cell takes the
111
119
  # style of the last styled dot drawn in it, otherwise +marker_style+. Labels
112
- # are drawn last, on top of the dots. Added shapes are kept between renders.
120
+ # are drawn last, on top of the dots, and clipped at the area's right edge,
121
+ # so nothing is drawn outside the area. Added shapes are kept between renders.
113
122
  #
114
123
  # @param area [Rect] the rectangular area to render into
115
124
  # @param buf [Buffer] the buffer to write cells into
@@ -134,8 +143,8 @@ module RubyTUI
134
143
  style_grid = Array.new(render_area.height) { Array.new(render_area.width, nil) }
135
144
 
136
145
  # Compute bounds
137
- x_min, x_max = compute_bounds(@x_bounds, canvas_w)
138
- y_min, y_max = compute_bounds(@y_bounds, canvas_h)
146
+ x_min, x_max = compute_bounds(@x_bounds, canvas_w, 0)
147
+ y_min, y_max = compute_bounds(@y_bounds, canvas_h, 1)
139
148
 
140
149
  # Rasterize lines
141
150
  @lines.each do |x1, y1, x2, y2, lstyle|
@@ -161,26 +170,29 @@ module RubyTUI
161
170
  end
162
171
  end
163
172
 
164
- # Render labels
173
+ # Render labels, clipped at the area's right edge (the start cell is always
174
+ # inside, as coordinates are clamped)
165
175
  @labels.each do |lx, ly, text, lstyle|
166
176
  cx, cy = data_to_canvas(lx, ly, x_min, x_max, y_min, y_max, canvas_w, canvas_h)
167
177
  cell_x = render_area.x + cx / 2
168
178
  cell_y = render_area.y + cy / 4
169
- buf.set_string(cell_x, cell_y, text, lstyle || @style)
179
+ clipped = Unicode.truncate_to_width(text, render_area.right - cell_x)
180
+ buf.set_string(cell_x, cell_y, clipped, lstyle || @style)
170
181
  end
171
182
  end
172
183
 
173
184
  private
174
185
 
175
- def compute_bounds(bounds, canvas_size)
186
+ # axis: 0 for x, 1 for y (index into points and into each line's endpoints)
187
+ def compute_bounds(bounds, canvas_size, axis)
176
188
  if bounds
177
189
  [bounds[0].to_f, bounds[1].to_f]
178
190
  else
179
- all_x = @points.map { |p| p[0] } + @lines.flat_map { |l| [l[0], l[2]] }
180
- return [0.0, canvas_size.to_f] if all_x.empty?
191
+ coords = @points.map { |p| p[axis] } + @lines.flat_map { |l| [l[axis], l[axis + 2]] }
192
+ return [0.0, canvas_size.to_f] if coords.empty?
181
193
 
182
- mn = all_x.min.to_f
183
- mx = all_x.max.to_f
194
+ mn = coords.min.to_f
195
+ mx = coords.max.to_f
184
196
  mx = mn + 1 if mx == mn
185
197
  [mn, mx]
186
198
  end
@@ -29,14 +29,24 @@ module RubyTUI
29
29
  # @param datasets [Array<Dataset>] series to plot; datasets with fewer than
30
30
  # 2 points are skipped (default: [])
31
31
  # @param x_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
32
- # +min+/+max+ may be nil for bounds computed from the data. When set, one row
33
- # is reserved for the axis and the label is drawn centered on it (default: nil,
34
- # bounds computed from the data and no row reserved)
32
+ # +min+/+max+ may be nil for bounds computed from the data. The bottom row of
33
+ # the area is always reserved for the x axis line and its min/max values;
34
+ # when set, the label is also drawn centered on it, over the values. The
35
+ # label and min value are truncated to the plot width (whole grapheme
36
+ # clusters), and the max value is omitted when it would overlap the min
37
+ # value (default: nil, bounds computed from the data and no label)
35
38
  # @param y_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
36
39
  # +min+/+max+ may be nil for bounds computed from the data. When set, the
37
40
  # min/max values are drawn left of a vertical axis line and the label's first
38
- # character at mid-height (default: nil, no y axis; bounds computed from the data)
39
- # @param style [Style] currently unused by rendering (default: {Style::DEFAULT})
41
+ # character at mid-height. The label column is as wide as the wider of the
42
+ # min/max values drawn, i.e. of the final bounds (computed bounds with equal
43
+ # min and max are widened to <tt>max = min + 1</tt>) (default: nil, no y
44
+ # axis; bounds computed from the data)
45
+ # @param style [Style, nil] base style of the area inside +block+: when it is
46
+ # not {Style::DEFAULT} it is patched onto every cell, and the axis lines,
47
+ # labels and plotted dots are drawn with this style patched ({Style#patch})
48
+ # with their own style, so e.g. a background shows through everywhere.
49
+ # +nil+ is treated as {Style::DEFAULT} (default: {Style::DEFAULT})
40
50
  # @param axis_style [Style, nil] axis line style (default: bright black foreground)
41
51
  # @param label_style [Style, nil] style of axis labels and min/max values
42
52
  # (default: white foreground)
@@ -53,15 +63,17 @@ module RubyTUI
53
63
  @datasets = datasets
54
64
  @x_axis = x_axis
55
65
  @y_axis = y_axis
56
- @style = style
57
- @axis_style = axis_style || Style.new.fg(Color::BRIGHT_BLACK)
58
- @label_style = label_style || Style.new.fg(Color::WHITE)
66
+ @style = style || Style::DEFAULT
67
+ @axis_style = @style.patch(axis_style || Style.new.fg(Color::BRIGHT_BLACK))
68
+ @label_style = @style.patch(label_style || Style.new.fg(Color::WHITE))
59
69
  @block = block
60
70
  end
61
71
 
62
72
  # Render the axes and draw each dataset as connected line segments on a {Canvas}.
63
73
  #
64
- # Nothing is drawn, not even +block+, when there are no datasets.
74
+ # The x axis occupies the bottom row of the area (inside +block+), so the
75
+ # plot is one row shorter than the area. Nothing is drawn outside the area;
76
+ # nothing at all, not even +block+, when there are no datasets.
65
77
  #
66
78
  # @param area [Rect] the rectangular area to render into
67
79
  # @param buf [Buffer] the buffer to write cells into
@@ -77,9 +89,15 @@ module RubyTUI
77
89
 
78
90
  return if render_area.empty?
79
91
 
80
- # Reserve space for axes
81
- y_label_width = compute_y_label_width
82
- x_label_height = @x_axis ? 1 : 0
92
+ buf.set_style(render_area, @style) if @style != Style::DEFAULT
93
+
94
+ # Compute data bounds
95
+ x_min, x_max = compute_x_bounds
96
+ y_min, y_max = compute_y_bounds
97
+
98
+ # Reserve space for axes; the x axis row is drawn even without +x_axis+
99
+ y_label_width = compute_y_label_width(y_min, y_max)
100
+ x_label_height = 1
83
101
  y_label_area_width = y_label_width > 0 ? y_label_width + 1 : 0
84
102
 
85
103
  chart_area = Rect.new(
@@ -91,27 +109,25 @@ module RubyTUI
91
109
 
92
110
  return if chart_area.empty?
93
111
 
94
- # Compute data bounds
95
- x_min, x_max = compute_x_bounds
96
- y_min, y_max = compute_y_bounds
97
-
98
112
  # Draw axes
99
113
  render_y_axis(render_area, chart_area, buf, y_min, y_max)
100
114
  render_x_axis(render_area, chart_area, buf, x_min, x_max)
101
115
 
102
- # Draw data using Canvas
116
+ # Draw data using Canvas; a nil dataset style falls back to the marker style
103
117
  canvas = Canvas.new(
104
118
  x_bounds: [x_min, x_max],
105
- y_bounds: [y_min, y_max]
119
+ y_bounds: [y_min, y_max],
120
+ marker_style: @style.patch(Style.new.fg(Color::WHITE))
106
121
  )
107
122
 
108
123
  @datasets.each do |ds|
109
124
  points = ds.data
110
125
  next if points.length < 2
111
126
 
127
+ line_style = ds.style && @style.patch(ds.style)
112
128
  # Draw lines between consecutive points
113
129
  points.each_cons(2) do |(x1, y1), (x2, y2)|
114
- canvas.line(x1, y1, x2, y2, style: ds.style)
130
+ canvas.line(x1, y1, x2, y2, style: line_style)
115
131
  end
116
132
  end
117
133
 
@@ -120,18 +136,11 @@ module RubyTUI
120
136
 
121
137
  private
122
138
 
123
- def compute_y_label_width
139
+ # Width of the y min/max labels as drawn, i.e. from the final bounds
140
+ def compute_y_label_width(y_min, y_max)
124
141
  return 0 unless @y_axis
125
142
 
126
- _, y_min, y_max = @y_axis
127
- if y_min && y_max
128
- [format_num(y_min).length, format_num(y_max).length].max
129
- else
130
- all_y = @datasets.flat_map { |ds| ds.data.map { |_, y| y } }
131
- return 0 if all_y.empty?
132
-
133
- [format_num(all_y.min).length, format_num(all_y.max).length].max
134
- end
143
+ [format_num(y_min).length, format_num(y_max).length].max
135
144
  end
136
145
 
137
146
  def compute_x_bounds
@@ -199,18 +208,22 @@ module RubyTUI
199
208
  buf[chart_area.x - 1, x_axis_y]&.set("\u2514", @axis_style)
200
209
  end
201
210
 
202
- # X labels at left and right
203
- min_str = format_num(x_min)
211
+ # X labels at left and right, truncated to the plot width; the max label
212
+ # is omitted when it would overlap the min label
213
+ min_str = Unicode.truncate_to_width(format_num(x_min), chart_area.width)
204
214
  max_str = format_num(x_max)
205
215
  buf.set_string(chart_area.x, x_axis_y, min_str, @label_style)
206
- max_x = chart_area.right - Buffer.string_width(max_str)
207
- buf.set_string([max_x, chart_area.x].max, x_axis_y, max_str, @label_style)
216
+ max_x = chart_area.right - Unicode.string_width(max_str)
217
+ if max_x >= chart_area.x + Unicode.string_width(min_str)
218
+ buf.set_string(max_x, x_axis_y, max_str, @label_style)
219
+ end
208
220
 
209
- # X axis label (centered)
221
+ # X axis label (centered, truncated to the plot width)
210
222
  if @x_axis
211
223
  label = @x_axis[0]
212
224
  if label && !label.empty?
213
- lx = chart_area.x + (chart_area.width - Buffer.string_width(label)) / 2
225
+ label = Unicode.truncate_to_width(label, chart_area.width)
226
+ lx = chart_area.x + (chart_area.width - Unicode.string_width(label)) / 2
214
227
  buf.set_string(lx, x_axis_y, label, @label_style)
215
228
  end
216
229
  end
@@ -108,7 +108,7 @@ module RubyTUI
108
108
 
109
109
  # Render label centered, with per-character style blending
110
110
  label_text = @label || "#{(@ratio * 100).round}%"
111
- label_width = Buffer.string_width(label_text)
111
+ label_width = Unicode.string_width(label_text)
112
112
  if label_width <= width
113
113
  label_x = render_area.x + (width - label_width) / 2
114
114
  label_y = render_area.y + render_area.height / 2
@@ -122,7 +122,7 @@ module RubyTUI
122
122
 
123
123
  cx = label_x
124
124
  label_text.each_char do |ch|
125
- ch_w = Buffer.char_width(ch)
125
+ ch_w = Unicode.char_width(ch)
126
126
  col_offset = cx - render_area.x
127
127
  char_style = col_offset < boundary ? filled_label_style : empty_label_style
128
128
  buf[cx, label_y]&.set(ch, char_style)
@@ -90,10 +90,12 @@ module RubyTUI
90
90
  # @param clip [Boolean] if true, clamp rendering to the available area (default: true)
91
91
  # @param cell_size [Array(Integer, Integer), nil] cell pixel dimensions [width, height] for aspect calculations
92
92
  # (default: nil, uses <tt>[9, 18]</tt>)
93
- # @raise [ArgumentError] if neither +path+ nor +data+ is given, if PNG data is
94
- # truncated, or if +pixel_width+/+pixel_height+ are missing for +:rgba+ data
95
- # (including data whose format is not recognized)
96
- # @raise [UnsupportedImageFormat] if the image is JPEG, WebP, GIF or BMP
93
+ # @raise [ArgumentError] if neither +path+ nor +data+ is given, if +data+ is
94
+ # given with a +format+ other than +:png+ or +:rgba+ (e.g. +:tiff+, a String
95
+ # or +nil+), if PNG data is truncated, or if +pixel_width+/+pixel_height+ are
96
+ # missing for +:rgba+ data (including data whose format is not recognized)
97
+ # @raise [UnsupportedImageFormat] if the image is JPEG, WebP, GIF or BMP (detected,
98
+ # or given as +format+ with +data+)
97
99
  # @raise [SystemCallError] if +path+ cannot be read
98
100
  def initialize(path: nil, data: nil, pixel_width: nil, pixel_height: nil,
99
101
  format: :png, style: Style::DEFAULT, block: nil,
@@ -306,6 +308,8 @@ module RubyTUI
306
308
  raise UnsupportedImageFormat,
307
309
  "BMP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
308
310
  " system(\"magick\", \"input.bmp\", \"output.png\")"
311
+ else
312
+ raise ArgumentError, "Unknown image format #{fmt.inspect}; expected :png or :rgba"
309
313
  end
310
314
  end
311
315
 
@@ -9,6 +9,10 @@ module RubyTUI
9
9
  #
10
10
  # @see InputField
11
11
  class InputState
12
+ # Characters that separate words for {#delete_word_back} (readline's
13
+ # whitespace: space and tab).
14
+ WORD_SEPARATORS = [" ", "\t"].freeze
15
+
12
16
  attr_accessor :text, :cursor, :scroll_offset
13
17
 
14
18
  # @param text [String] initial text; a mutable copy is stored (default: "")
@@ -101,19 +105,25 @@ module RubyTUI
101
105
 
102
106
  # Delete the word before the cursor
103
107
  #
104
- # Deletes any spaces directly before the cursor and the run of non-space
105
- # characters before them. Only the space character counts as a separator.
106
- # Does nothing when the cursor is at the start.
108
+ # Readline-style Ctrl-W (unix-word-rubout): deletes the whitespace directly
109
+ # before the cursor, then the run of non-whitespace characters before it,
110
+ # stopping at (and keeping) the preceding whitespace. Space and tab count as
111
+ # whitespace. The cursor moves to the start of the deleted range. Does
112
+ # nothing when the cursor is at the start.
113
+ #
114
+ # @example
115
+ # state = InputState.new(text: " foo bar ", cursor: 10)
116
+ # state.delete_word_back # text: " foo ", cursor: 5
117
+ # state.delete_word_back # text: " ", cursor: 1
107
118
  #
108
119
  # @return [void]
109
120
  def delete_word_back
110
121
  return if @cursor == 0
111
122
 
112
123
  # Find start of previous word
113
- pos = @cursor - 1
114
- pos -= 1 while pos > 0 && @text[pos] == " "
115
- pos -= 1 while pos > 0 && @text[pos] != " "
116
- pos += 1 if pos > 0
124
+ pos = @cursor
125
+ pos -= 1 while pos > 0 && WORD_SEPARATORS.include?(@text[pos - 1])
126
+ pos -= 1 while pos > 0 && !WORD_SEPARATORS.include?(@text[pos - 1])
117
127
 
118
128
  @text.slice!(pos...@cursor)
119
129
  @cursor = pos
@@ -194,7 +204,14 @@ module RubyTUI
194
204
  # the area and apply +cursor_style+ to the cell at the cursor.
195
205
  #
196
206
  # Updates <tt>state.scroll_offset</tt> in place so the cursor stays visible.
197
- # Scrolling and cursor placement count characters, not display columns.
207
+ # Scrolling and cursor placement count display columns per grapheme
208
+ # cluster: wide characters (CJK, emoji) take two columns, and the visible
209
+ # text and placeholder are truncated to the area width. The cursor is drawn
210
+ # at the column after the clusters that precede it; a cursor inside a
211
+ # multi-codepoint cluster (e.g. a letter plus a combining mark) is drawn on
212
+ # that cluster's cell. <tt>state.cursor</tt> and <tt>state.scroll_offset</tt>
213
+ # remain character indices; +scroll_offset+ is moved to the start of a
214
+ # cluster. Positions past the end of the text count as one column each.
198
215
  #
199
216
  # @param area [Rect] the rectangular area to render into
200
217
  # @param buf [Buffer] the buffer to write cells into
@@ -212,26 +229,41 @@ module RubyTUI
212
229
  return if render_area.empty?
213
230
 
214
231
  width = render_area.width
232
+ text = state.text
233
+
234
+ # Character index where each grapheme cluster starts, and its display width
235
+ starts = []
236
+ widths = []
237
+ pos = 0
238
+ text.grapheme_clusters.each do |gc|
239
+ starts << pos
240
+ widths << Unicode.cluster_width(gc)
241
+ pos += gc.length
242
+ end
215
243
 
216
- # Adjust scroll offset to keep cursor visible
217
- if state.cursor < state.scroll_offset
218
- state.scroll_offset = state.cursor
219
- elsif state.cursor >= state.scroll_offset + width
220
- state.scroll_offset = state.cursor - width + 1
244
+ # Adjust scroll offset (in clusters) so the cursor cell fits in the area
245
+ cursor = cluster_index(starts, text.length, state.cursor)
246
+ first = [cluster_index(starts, text.length, state.scroll_offset), cursor].min
247
+ cursor_width = [widths[cursor] || 1, 1].max
248
+ cursor_col = (first...cursor).sum { |i| widths[i] || 1 }
249
+ while first < cursor && cursor_col + cursor_width > width
250
+ cursor_col -= widths[first] || 1
251
+ first += 1
221
252
  end
253
+ state.scroll_offset = first < starts.size ? starts[first] : text.length + first - starts.size
222
254
 
223
- if state.text.empty?
255
+ if text.empty?
224
256
  # Show placeholder
225
- visible = @placeholder[0, width]
257
+ visible = Unicode.truncate_to_width(@placeholder, width)
226
258
  buf.set_string(render_area.x, render_area.y, visible, @placeholder_style)
227
259
  else
228
260
  # Show text with scrolling
229
- visible = state.text[state.scroll_offset, width] || ""
261
+ visible = Unicode.truncate_to_width(text[state.scroll_offset..] || "", width)
230
262
  buf.set_string(render_area.x, render_area.y, visible, @style)
231
263
  end
232
264
 
233
265
  # Draw cursor
234
- cursor_screen_x = render_area.x + state.cursor - state.scroll_offset
266
+ cursor_screen_x = render_area.x + cursor_col
235
267
  if cursor_screen_x >= render_area.x && cursor_screen_x < render_area.right
236
268
  cell = buf[cursor_screen_x, render_area.y]
237
269
  if cell
@@ -240,6 +272,16 @@ module RubyTUI
240
272
  end
241
273
  end
242
274
  end
275
+
276
+ private
277
+
278
+ # Index of the grapheme cluster containing character +index+. Positions at
279
+ # or past the end of the text count as one single-column cluster each.
280
+ def cluster_index(starts, length, index)
281
+ return starts.size + index - length if index >= length
282
+
283
+ (starts.bsearch_index { |s| s > index } || starts.size) - 1
284
+ end
243
285
  end
244
286
  end
245
287
  end
@@ -4,38 +4,42 @@ module RubyTUI
4
4
  module Widgets
5
5
  # ListState holds external state for the List widget.
6
6
  #
7
- # +selected+ is the index of the selected item and +offset+ the index of the
8
- # first visible item. {List#render} clamps both in place.
7
+ # +selected+ is the index of the selected item, or +nil+ for no selection, and
8
+ # +offset+ the index of the first visible item. {List#render} clamps both in
9
+ # place; a +nil+ +selected+ is left as +nil+.
9
10
  #
10
11
  # @see List
11
12
  class ListState
12
13
  attr_accessor :selected, :offset
13
14
 
14
- # @param selected [Integer] initial selected index (default: 0)
15
+ # @param selected [Integer, nil] initial selected index; +nil+ selects no item
16
+ # (default: 0)
15
17
  # @param offset [Integer] initial scroll offset (default: 0)
16
18
  def initialize(selected: 0, offset: 0)
17
19
  @selected = selected
18
20
  @offset = offset
19
21
  end
20
22
 
21
- # Select the next item, wrapping from the last item to the first.
23
+ # Select the next item, wrapping from the last item to the first. With no
24
+ # selection (+nil+), selects the first item.
22
25
  #
23
26
  # Does nothing when +total+ is 0 or negative.
24
27
  #
25
28
  # @param total [Integer] number of items in the list
26
29
  # @return [void]
27
30
  def select_next(total)
28
- @selected = (@selected + 1) % total if total > 0
31
+ @selected = ((@selected || -1) + 1) % total if total > 0
29
32
  end
30
33
 
31
- # Select the previous item, wrapping from the first item to the last.
34
+ # Select the previous item, wrapping from the first item to the last. With no
35
+ # selection (+nil+), selects the last item.
32
36
  #
33
37
  # Does nothing when +total+ is 0 or negative.
34
38
  #
35
39
  # @param total [Integer] number of items in the list
36
40
  # @return [void]
37
41
  def select_previous(total)
38
- @selected = (@selected - 1) % total if total > 0
42
+ @selected = ((@selected || 0) - 1) % total if total > 0
39
43
  end
40
44
 
41
45
  # Select the first item and reset the scroll offset to 0.
@@ -90,7 +94,7 @@ module RubyTUI
90
94
  @style = style
91
95
  @highlight_style = highlight_style || Style.new.fg(Color::YELLOW).bold
92
96
  @highlight_symbol = highlight_symbol
93
- @highlight_symbol_width = Buffer.string_width(@highlight_symbol)
97
+ @highlight_symbol_width = Unicode.string_width(@highlight_symbol)
94
98
  @normal_symbol = " " * @highlight_symbol_width
95
99
  @block = block
96
100
  end
@@ -99,8 +103,11 @@ module RubyTUI
99
103
  # +highlight_symbol+ and +highlight_style+.
100
104
  #
101
105
  # Clamps <tt>state.selected</tt> to the item range and adjusts
102
- # <tt>state.offset</tt> in place so the selected item is visible. Item text is
103
- # truncated to the area width; span styles are patched onto the item style.
106
+ # <tt>state.offset</tt> in place so the selected item is visible. When
107
+ # <tt>state.selected</tt> is +nil+, no item is highlighted or prefixed with
108
+ # +highlight_symbol+, +selected+ stays +nil+ and +offset+ is only clamped to the
109
+ # item range. Item text is truncated to the area width; span styles are patched
110
+ # onto the item style.
104
111
  #
105
112
  # @param area [Rect] the rectangular area to render into
106
113
  # @param buf [Buffer] the buffer to write cells into
@@ -141,8 +148,8 @@ module RubyTUI
141
148
  item.spans.each do |span|
142
149
  break if available <= 0
143
150
 
144
- text = Buffer.truncate_to_width(span.content, available)
145
- text_width = Buffer.string_width(text)
151
+ text = Unicode.truncate_to_width(span.content, available)
152
+ text_width = Unicode.string_width(text)
146
153
  effective_style = item_style.patch(span.style)
147
154
  buf.set_string(x, y, text, effective_style)
148
155
  x += text_width
@@ -170,13 +177,15 @@ module RubyTUI
170
177
 
171
178
  def clamp_state(state, visible_height)
172
179
  total = @items.length
173
- state.selected = state.selected.clamp(0, [total - 1, 0].max)
174
-
175
- # Scroll to keep selected visible
176
- if state.selected < state.offset
177
- state.offset = state.selected
178
- elsif state.selected >= state.offset + visible_height
179
- state.offset = state.selected - visible_height + 1
180
+ if state.selected
181
+ state.selected = state.selected.clamp(0, [total - 1, 0].max)
182
+
183
+ # Scroll to keep selected visible
184
+ if state.selected < state.offset
185
+ state.offset = state.selected
186
+ elsif state.selected >= state.offset + visible_height
187
+ state.offset = state.selected - visible_height + 1
188
+ end
180
189
  end
181
190
 
182
191
  state.offset = state.offset.clamp(0, [total - visible_height, 0].max)