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
|
@@ -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
|
-
|
|
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
|
|
40
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
return [0.0, canvas_size.to_f] if
|
|
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 =
|
|
183
|
-
mx =
|
|
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.
|
|
33
|
-
# is reserved for the axis and
|
|
34
|
-
#
|
|
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
|
|
39
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
207
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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
|
|
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
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
255
|
+
if text.empty?
|
|
224
256
|
# Show placeholder
|
|
225
|
-
visible = @placeholder
|
|
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 =
|
|
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 +
|
|
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
|
data/lib/rubytui/widgets/list.rb
CHANGED
|
@@ -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
|
|
8
|
-
# first visible item. {List#render} clamps both in
|
|
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
|
|
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 =
|
|
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.
|
|
103
|
-
#
|
|
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 =
|
|
145
|
-
text_width =
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
state.
|
|
178
|
-
|
|
179
|
-
state.
|
|
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)
|