rubytui 1.2.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +574 -0
- data/lib/rubytui/app.rb +174 -0
- data/lib/rubytui/backend.rb +158 -0
- data/lib/rubytui/backends/ansi_backend.rb +391 -0
- data/lib/rubytui/backends/test_backend.rb +223 -0
- data/lib/rubytui/buffer.rb +400 -0
- data/lib/rubytui/cell.rb +55 -0
- data/lib/rubytui/color.rb +153 -0
- data/lib/rubytui/color_mode.rb +203 -0
- data/lib/rubytui/errors.rb +13 -0
- data/lib/rubytui/event.rb +161 -0
- data/lib/rubytui/frame.rb +70 -0
- data/lib/rubytui/input/key.rb +93 -0
- data/lib/rubytui/input/parser.rb +231 -0
- data/lib/rubytui/input/reader.rb +119 -0
- data/lib/rubytui/layout/constraint.rb +83 -0
- data/lib/rubytui/layout/flex.rb +16 -0
- data/lib/rubytui/layout/layout.rb +205 -0
- data/lib/rubytui/modifier.rb +67 -0
- data/lib/rubytui/rect.rb +126 -0
- data/lib/rubytui/stateful_widget.rb +20 -0
- data/lib/rubytui/style.rb +143 -0
- data/lib/rubytui/symbols.rb +88 -0
- data/lib/rubytui/terminal.rb +218 -0
- data/lib/rubytui/text/line.rb +67 -0
- data/lib/rubytui/text/span.rb +34 -0
- data/lib/rubytui/text/text.rb +82 -0
- data/lib/rubytui/unicode.rb +162 -0
- data/lib/rubytui/version.rb +6 -0
- data/lib/rubytui/widget.rb +20 -0
- data/lib/rubytui/widgets/async_image.rb +248 -0
- data/lib/rubytui/widgets/block.rb +260 -0
- data/lib/rubytui/widgets/canvas.rb +248 -0
- data/lib/rubytui/widgets/chart.rb +224 -0
- data/lib/rubytui/widgets/gauge.rb +139 -0
- data/lib/rubytui/widgets/image.rb +330 -0
- data/lib/rubytui/widgets/input_field.rb +245 -0
- data/lib/rubytui/widgets/list.rb +186 -0
- data/lib/rubytui/widgets/paragraph.rb +181 -0
- data/lib/rubytui/widgets/popup.rb +140 -0
- data/lib/rubytui/widgets/scrollbar.rb +175 -0
- data/lib/rubytui/widgets/sparkline.rb +86 -0
- data/lib/rubytui/widgets/table.rb +231 -0
- data/lib/rubytui/widgets/tabs.rb +90 -0
- data/lib/rubytui.rb +389 -0
- metadata +89 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Dataset represents a series of data points for the Chart widget.
|
|
6
|
+
Dataset = Data.define(:name, :data, :style) do
|
|
7
|
+
# @param name [String] dataset name; not rendered, as {Chart} draws no legend
|
|
8
|
+
# (default: "")
|
|
9
|
+
# @param data [Array<Array(Numeric, Numeric)>] <tt>[x, y]</tt> points, joined by
|
|
10
|
+
# lines in the given order (default: [])
|
|
11
|
+
# @param style [Style] line style (default: {Style::DEFAULT})
|
|
12
|
+
def initialize(name: "", data: [], style: Style::DEFAULT)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Chart renders line charts using Canvas (Braille) for high-resolution plotting.
|
|
18
|
+
# Supports multiple datasets, labeled axes, and automatic scaling.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# ds = RubyTUI::Widgets::Dataset.new(name: "load", data: [[0, 1], [1, 3], [2, 2]])
|
|
22
|
+
# chart = RubyTUI::Widgets::Chart.new(datasets: [ds], x_axis: ["t", 0, 2], y_axis: ["y", 0, 4])
|
|
23
|
+
# frame.render_widget(chart, area)
|
|
24
|
+
#
|
|
25
|
+
# @see Dataset
|
|
26
|
+
class Chart
|
|
27
|
+
include Widget
|
|
28
|
+
|
|
29
|
+
# @param datasets [Array<Dataset>] series to plot; datasets with fewer than
|
|
30
|
+
# 2 points are skipped (default: [])
|
|
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)
|
|
35
|
+
# @param y_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
|
|
36
|
+
# +min+/+max+ may be nil for bounds computed from the data. When set, the
|
|
37
|
+
# 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})
|
|
40
|
+
# @param axis_style [Style, nil] axis line style (default: bright black foreground)
|
|
41
|
+
# @param label_style [Style, nil] style of axis labels and min/max values
|
|
42
|
+
# (default: white foreground)
|
|
43
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
44
|
+
def initialize(
|
|
45
|
+
datasets: [],
|
|
46
|
+
x_axis: nil, # [label, min, max] or nil for auto
|
|
47
|
+
y_axis: nil, # [label, min, max] or nil for auto
|
|
48
|
+
style: Style::DEFAULT,
|
|
49
|
+
axis_style: nil,
|
|
50
|
+
label_style: nil,
|
|
51
|
+
block: nil
|
|
52
|
+
)
|
|
53
|
+
@datasets = datasets
|
|
54
|
+
@x_axis = x_axis
|
|
55
|
+
@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)
|
|
59
|
+
@block = block
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Render the axes and draw each dataset as connected line segments on a {Canvas}.
|
|
63
|
+
#
|
|
64
|
+
# Nothing is drawn, not even +block+, when there are no datasets.
|
|
65
|
+
#
|
|
66
|
+
# @param area [Rect] the rectangular area to render into
|
|
67
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
68
|
+
# @return [void]
|
|
69
|
+
def render(area, buf)
|
|
70
|
+
return if area.empty? || @datasets.empty?
|
|
71
|
+
|
|
72
|
+
render_area = area
|
|
73
|
+
if @block
|
|
74
|
+
@block.render(area, buf)
|
|
75
|
+
render_area = @block.inner(area)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return if render_area.empty?
|
|
79
|
+
|
|
80
|
+
# Reserve space for axes
|
|
81
|
+
y_label_width = compute_y_label_width
|
|
82
|
+
x_label_height = @x_axis ? 1 : 0
|
|
83
|
+
y_label_area_width = y_label_width > 0 ? y_label_width + 1 : 0
|
|
84
|
+
|
|
85
|
+
chart_area = Rect.new(
|
|
86
|
+
x: render_area.x + y_label_area_width,
|
|
87
|
+
y: render_area.y,
|
|
88
|
+
width: [render_area.width - y_label_area_width, 0].max,
|
|
89
|
+
height: [render_area.height - x_label_height, 0].max
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return if chart_area.empty?
|
|
93
|
+
|
|
94
|
+
# Compute data bounds
|
|
95
|
+
x_min, x_max = compute_x_bounds
|
|
96
|
+
y_min, y_max = compute_y_bounds
|
|
97
|
+
|
|
98
|
+
# Draw axes
|
|
99
|
+
render_y_axis(render_area, chart_area, buf, y_min, y_max)
|
|
100
|
+
render_x_axis(render_area, chart_area, buf, x_min, x_max)
|
|
101
|
+
|
|
102
|
+
# Draw data using Canvas
|
|
103
|
+
canvas = Canvas.new(
|
|
104
|
+
x_bounds: [x_min, x_max],
|
|
105
|
+
y_bounds: [y_min, y_max]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@datasets.each do |ds|
|
|
109
|
+
points = ds.data
|
|
110
|
+
next if points.length < 2
|
|
111
|
+
|
|
112
|
+
# Draw lines between consecutive points
|
|
113
|
+
points.each_cons(2) do |(x1, y1), (x2, y2)|
|
|
114
|
+
canvas.line(x1, y1, x2, y2, style: ds.style)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
canvas.render(chart_area, buf)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def compute_y_label_width
|
|
124
|
+
return 0 unless @y_axis
|
|
125
|
+
|
|
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
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def compute_x_bounds
|
|
138
|
+
if @x_axis
|
|
139
|
+
_, mn, mx = @x_axis
|
|
140
|
+
return [mn, mx] if mn && mx
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
all_x = @datasets.flat_map { |ds| ds.data.map { |x, _| x } }
|
|
144
|
+
return [0.0, 1.0] if all_x.empty?
|
|
145
|
+
|
|
146
|
+
mn = all_x.min.to_f
|
|
147
|
+
mx = all_x.max.to_f
|
|
148
|
+
mx = mn + 1 if mx == mn
|
|
149
|
+
[mn, mx]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def compute_y_bounds
|
|
153
|
+
if @y_axis
|
|
154
|
+
_, mn, mx = @y_axis
|
|
155
|
+
return [mn, mx] if mn && mx
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
all_y = @datasets.flat_map { |ds| ds.data.map { |_, y| y } }
|
|
159
|
+
return [0.0, 1.0] if all_y.empty?
|
|
160
|
+
|
|
161
|
+
mn = all_y.min.to_f
|
|
162
|
+
mx = all_y.max.to_f
|
|
163
|
+
mx = mn + 1 if mx == mn
|
|
164
|
+
[mn, mx]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_y_axis(render_area, chart_area, buf, y_min, y_max)
|
|
168
|
+
return unless @y_axis
|
|
169
|
+
|
|
170
|
+
label = @y_axis[0]
|
|
171
|
+
|
|
172
|
+
# Draw vertical axis line
|
|
173
|
+
(chart_area.y...chart_area.bottom).each do |y|
|
|
174
|
+
buf[chart_area.x - 1, y]&.set("\u2502", @axis_style) if chart_area.x > render_area.x
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Y labels at top and bottom
|
|
178
|
+
y_label_x = render_area.x
|
|
179
|
+
buf.set_string(y_label_x, chart_area.y, format_num(y_max), @label_style)
|
|
180
|
+
buf.set_string(y_label_x, chart_area.bottom - 1, format_num(y_min), @label_style)
|
|
181
|
+
|
|
182
|
+
# Y axis label (vertically centered, first char)
|
|
183
|
+
if label && !label.empty?
|
|
184
|
+
mid_y = chart_area.y + chart_area.height / 2
|
|
185
|
+
buf.set_string(render_area.x, mid_y, label[0], @label_style)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def render_x_axis(render_area, chart_area, buf, x_min, x_max)
|
|
190
|
+
x_axis_y = chart_area.bottom
|
|
191
|
+
|
|
192
|
+
# Draw horizontal axis line
|
|
193
|
+
(chart_area.x...chart_area.right).each do |x|
|
|
194
|
+
buf[x, x_axis_y]&.set("\u2500", @axis_style)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Corner
|
|
198
|
+
if chart_area.x > render_area.x
|
|
199
|
+
buf[chart_area.x - 1, x_axis_y]&.set("\u2514", @axis_style)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# X labels at left and right
|
|
203
|
+
min_str = format_num(x_min)
|
|
204
|
+
max_str = format_num(x_max)
|
|
205
|
+
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)
|
|
208
|
+
|
|
209
|
+
# X axis label (centered)
|
|
210
|
+
if @x_axis
|
|
211
|
+
label = @x_axis[0]
|
|
212
|
+
if label && !label.empty?
|
|
213
|
+
lx = chart_area.x + (chart_area.width - Buffer.string_width(label)) / 2
|
|
214
|
+
buf.set_string(lx, x_axis_y, label, @label_style)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def format_num(n)
|
|
220
|
+
n == n.to_i ? n.to_i.to_s : format("%.1f", n)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Gauge renders a horizontal progress bar with an optional label.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# gauge = RubyTUI::Widgets::Gauge.new(ratio: 0.42) # label "42%"
|
|
9
|
+
# frame.render_widget(gauge, area)
|
|
10
|
+
class Gauge
|
|
11
|
+
include Widget
|
|
12
|
+
|
|
13
|
+
# Block characters for smooth progress rendering
|
|
14
|
+
|
|
15
|
+
# Full block (8/8).
|
|
16
|
+
FULL = "\u2588" # █
|
|
17
|
+
# Left seven-eighths block (7/8).
|
|
18
|
+
SEVEN = "\u2589" # ▉
|
|
19
|
+
# Left three-quarters block (6/8).
|
|
20
|
+
SIX = "\u258A" # ▊
|
|
21
|
+
# Left five-eighths block (5/8).
|
|
22
|
+
FIVE = "\u258B" # ▋
|
|
23
|
+
# Left half block (4/8).
|
|
24
|
+
FOUR = "\u258C" # ▌
|
|
25
|
+
# Left three-eighths block (3/8).
|
|
26
|
+
THREE = "\u258D" # ▍
|
|
27
|
+
# Left one-quarter block (2/8).
|
|
28
|
+
TWO = "\u258E" # ▎
|
|
29
|
+
# Left one-eighth block (1/8).
|
|
30
|
+
ONE = "\u258F" # ▏
|
|
31
|
+
# Empty cell (0/8).
|
|
32
|
+
EMPTY = " "
|
|
33
|
+
|
|
34
|
+
# Bar characters indexed by filled eighths, from {EMPTY} (0) to {FULL} (8).
|
|
35
|
+
EIGHTHS = [EMPTY, ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, FULL].freeze
|
|
36
|
+
|
|
37
|
+
# @param ratio [Numeric] fill ratio, clamped to 0.0..1.0 (default: 0.0)
|
|
38
|
+
# @param label [String, nil] text centered on the middle row, drawn only if it
|
|
39
|
+
# fits the width (default: nil, the rounded percentage, e.g. <tt>"42%"</tt>)
|
|
40
|
+
# @param label_style [Style, nil] style for every label character (default: nil,
|
|
41
|
+
# bold black on the gauge color over the filled part and bold gauge-colored
|
|
42
|
+
# text over the unfilled part)
|
|
43
|
+
# @param style [Style] style of the unfilled cells (default: {Style::DEFAULT})
|
|
44
|
+
# @param gauge_style [Style, nil] style of the filled blocks; its foreground color
|
|
45
|
+
# is also used by the default label styles (default: green foreground)
|
|
46
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
47
|
+
def initialize(
|
|
48
|
+
ratio: 0.0,
|
|
49
|
+
label: nil,
|
|
50
|
+
label_style: nil,
|
|
51
|
+
style: Style::DEFAULT,
|
|
52
|
+
gauge_style: nil,
|
|
53
|
+
block: nil
|
|
54
|
+
)
|
|
55
|
+
@ratio = ratio.clamp(0.0, 1.0)
|
|
56
|
+
@label = label
|
|
57
|
+
@label_style = label_style
|
|
58
|
+
@style = style
|
|
59
|
+
@gauge_style = gauge_style || Style.new.fg(Color::GREEN)
|
|
60
|
+
@block = block
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Render the bar on every row of the area with eighth-cell precision, then the
|
|
64
|
+
# label centered on the middle row.
|
|
65
|
+
#
|
|
66
|
+
# @param area [Rect] the rectangular area to render into
|
|
67
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
68
|
+
# @return [void]
|
|
69
|
+
def render(area, buf)
|
|
70
|
+
return if area.empty?
|
|
71
|
+
|
|
72
|
+
render_area = area
|
|
73
|
+
if @block
|
|
74
|
+
@block.render(area, buf)
|
|
75
|
+
render_area = @block.inner(area)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return if render_area.empty?
|
|
79
|
+
|
|
80
|
+
width = render_area.width
|
|
81
|
+
filled_exact = width * @ratio * 8.0
|
|
82
|
+
filled_full = (filled_exact / 8).floor
|
|
83
|
+
partial_eighth = (filled_exact % 8).round
|
|
84
|
+
# The boundary column (first non-full cell)
|
|
85
|
+
boundary = filled_full + (partial_eighth > 0 ? 1 : 0)
|
|
86
|
+
|
|
87
|
+
# Render on each row of the gauge area
|
|
88
|
+
(0...render_area.height).each do |row|
|
|
89
|
+
y = render_area.y + row
|
|
90
|
+
x = render_area.x
|
|
91
|
+
|
|
92
|
+
# Full blocks
|
|
93
|
+
filled_full.times do |i|
|
|
94
|
+
break if x + i >= render_area.right
|
|
95
|
+
buf[x + i, y]&.set(FULL, @gauge_style)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Partial block
|
|
99
|
+
if partial_eighth > 0 && filled_full < width
|
|
100
|
+
buf[x + filled_full, y]&.set(EIGHTHS[partial_eighth], @gauge_style)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Empty space
|
|
104
|
+
(boundary...width).each do |i|
|
|
105
|
+
buf[x + i, y]&.set(EMPTY, @style)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Render label centered, with per-character style blending
|
|
110
|
+
label_text = @label || "#{(@ratio * 100).round}%"
|
|
111
|
+
label_width = Buffer.string_width(label_text)
|
|
112
|
+
if label_width <= width
|
|
113
|
+
label_x = render_area.x + (width - label_width) / 2
|
|
114
|
+
label_y = render_area.y + render_area.height / 2
|
|
115
|
+
|
|
116
|
+
# Compute styles for label chars on filled vs empty portions
|
|
117
|
+
# On filled portion: gauge color as bg, contrasting fg
|
|
118
|
+
gauge_fg = @gauge_style.fg
|
|
119
|
+
filled_label_style = @label_style || Style.new.fg(Color::BLACK).bg(gauge_fg).bold
|
|
120
|
+
# On empty portion: gauge color as fg, default bg
|
|
121
|
+
empty_label_style = @label_style || Style.new.fg(gauge_fg).bold
|
|
122
|
+
|
|
123
|
+
cx = label_x
|
|
124
|
+
label_text.each_char do |ch|
|
|
125
|
+
ch_w = Buffer.char_width(ch)
|
|
126
|
+
col_offset = cx - render_area.x
|
|
127
|
+
char_style = col_offset < boundary ? filled_label_style : empty_label_style
|
|
128
|
+
buf[cx, label_y]&.set(ch, char_style)
|
|
129
|
+
# Mark spacer for wide chars
|
|
130
|
+
if ch_w == 2 && cx + 1 < render_area.right
|
|
131
|
+
buf[cx + 1, label_y]&.set("", char_style)
|
|
132
|
+
end
|
|
133
|
+
cx += ch_w
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RubyTUI
|
|
6
|
+
module Widgets
|
|
7
|
+
# Renders an image in the terminal using the Kitty graphics protocol.
|
|
8
|
+
#
|
|
9
|
+
# Supports PNG files natively (sent directly to terminal, no decoding needed)
|
|
10
|
+
# and raw RGBA pixel data. Works on terminals that support the Kitty graphics
|
|
11
|
+
# protocol: Kitty, Ghostty, WezTerm. Note: iTerm2 uses a different protocol.
|
|
12
|
+
#
|
|
13
|
+
# JPEG, WebP, GIF, and BMP are NOT supported — convert to PNG first:
|
|
14
|
+
# system("magick", "input.jpg", "-resize", "800x600>", "output.png")
|
|
15
|
+
#
|
|
16
|
+
# @example From a PNG file
|
|
17
|
+
# image = RubyTUI::Widgets::Image.new(path: "photo.png")
|
|
18
|
+
# frame.render_widget(image, area)
|
|
19
|
+
#
|
|
20
|
+
# @example From a PNG file with border
|
|
21
|
+
# block = RubyTUI::Widgets::Block.new(title: " Photo ", borders: :all)
|
|
22
|
+
# image = RubyTUI::Widgets::Image.new(path: "photo.png", block: block)
|
|
23
|
+
# frame.render_widget(image, area)
|
|
24
|
+
#
|
|
25
|
+
# @example From raw RGBA pixel data
|
|
26
|
+
# image = RubyTUI::Widgets::Image.new(
|
|
27
|
+
# data: rgba_bytes,
|
|
28
|
+
# pixel_width: 800,
|
|
29
|
+
# pixel_height: 600,
|
|
30
|
+
# format: :rgba
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Aspect-ratio-preserving fit
|
|
34
|
+
# image = RubyTUI::Widgets::Image.new(path: "photo.png", fit: :contain)
|
|
35
|
+
# frame.render_widget(image, area) # Image computes sub-rect
|
|
36
|
+
#
|
|
37
|
+
# @example Source-rect cropping (scrollable image)
|
|
38
|
+
# image = RubyTUI::Widgets::Image.new(
|
|
39
|
+
# path: "tall_image.png",
|
|
40
|
+
# src_rect: Rect.new(0, scroll_offset, pixel_width, visible_height)
|
|
41
|
+
# )
|
|
42
|
+
class Image
|
|
43
|
+
include Widget
|
|
44
|
+
|
|
45
|
+
# PNG file signature (first 8 bytes)
|
|
46
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
|
|
47
|
+
|
|
48
|
+
# JPEG signatures (SOI marker)
|
|
49
|
+
JPEG_SIGNATURE = "\xFF\xD8\xFF".b.freeze
|
|
50
|
+
|
|
51
|
+
# WebP signature (RIFF....WEBP)
|
|
52
|
+
|
|
53
|
+
# RIFF container tag at byte offset 0 of a WebP file.
|
|
54
|
+
WEBP_RIFF = "RIFF".b.freeze
|
|
55
|
+
# WebP form type at byte offset 8 of a WebP file.
|
|
56
|
+
WEBP_MARKER = "WEBP".b.freeze
|
|
57
|
+
|
|
58
|
+
# GIF signatures
|
|
59
|
+
|
|
60
|
+
# GIF87a signature.
|
|
61
|
+
GIF87A_SIGNATURE = "GIF87a".b.freeze
|
|
62
|
+
# GIF89a signature.
|
|
63
|
+
GIF89A_SIGNATURE = "GIF89a".b.freeze
|
|
64
|
+
|
|
65
|
+
# BMP signature
|
|
66
|
+
BMP_SIGNATURE = "BM".b.freeze
|
|
67
|
+
|
|
68
|
+
# Fit modes for aspect-ratio-preserving sizing
|
|
69
|
+
FIT_MODES = %i[stretch contain cover].freeze
|
|
70
|
+
|
|
71
|
+
# @return [Integer] image width in pixels
|
|
72
|
+
attr_reader :pixel_width
|
|
73
|
+
|
|
74
|
+
# @return [Integer] image height in pixels
|
|
75
|
+
attr_reader :pixel_height
|
|
76
|
+
|
|
77
|
+
# @return [Symbol] image format (:png or :rgba)
|
|
78
|
+
attr_reader :format
|
|
79
|
+
|
|
80
|
+
# @param path [String, nil] path to a PNG image file
|
|
81
|
+
# @param data [String, nil] raw image bytes (PNG or RGBA)
|
|
82
|
+
# @param pixel_width [Integer, nil] image width in pixels (required for :rgba, auto-detected for :png)
|
|
83
|
+
# @param pixel_height [Integer, nil] image height in pixels (required for :rgba, auto-detected for :png)
|
|
84
|
+
# @param format [Symbol] image format — +:png+ or +:rgba+. Auto-detected when +:png+ (default).
|
|
85
|
+
# Ignored when +path+ is given (the format is always detected).
|
|
86
|
+
# @param style [Style] background style for the image area (default: {Style::DEFAULT})
|
|
87
|
+
# @param block [Widgets::Block, nil] optional wrapping block for borders/title
|
|
88
|
+
# @param fit [Symbol, nil] aspect-ratio mode — +:stretch+ (default), +:contain+, +:cover+
|
|
89
|
+
# @param src_rect [Rect, nil] source rectangle for cropping (pixel coordinates within the image)
|
|
90
|
+
# @param clip [Boolean] if true, clamp rendering to the available area (default: true)
|
|
91
|
+
# @param cell_size [Array(Integer, Integer), nil] cell pixel dimensions [width, height] for aspect calculations
|
|
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
|
|
97
|
+
# @raise [SystemCallError] if +path+ cannot be read
|
|
98
|
+
def initialize(path: nil, data: nil, pixel_width: nil, pixel_height: nil,
|
|
99
|
+
format: :png, style: Style::DEFAULT, block: nil,
|
|
100
|
+
fit: nil, src_rect: nil, clip: true, cell_size: nil)
|
|
101
|
+
@style = style
|
|
102
|
+
@block = block
|
|
103
|
+
@fit = fit
|
|
104
|
+
@src_rect = src_rect
|
|
105
|
+
@clip = clip
|
|
106
|
+
@cell_size = cell_size
|
|
107
|
+
|
|
108
|
+
if path
|
|
109
|
+
@image_data = File.binread(path)
|
|
110
|
+
@format = self.class.detect_format(@image_data)
|
|
111
|
+
elsif data
|
|
112
|
+
@image_data = data
|
|
113
|
+
@format = format == :png ? self.class.detect_format(data) : format
|
|
114
|
+
else
|
|
115
|
+
raise ArgumentError, "Image requires either path: or data:"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
validate_format!(@format)
|
|
119
|
+
|
|
120
|
+
if @format == :png
|
|
121
|
+
dims = self.class.png_dimensions(@image_data)
|
|
122
|
+
raise ArgumentError, "Invalid PNG data" unless dims
|
|
123
|
+
|
|
124
|
+
@pixel_width = pixel_width || dims[0]
|
|
125
|
+
@pixel_height = pixel_height || dims[1]
|
|
126
|
+
else
|
|
127
|
+
raise ArgumentError, "pixel_width and pixel_height required for :rgba format" unless pixel_width && pixel_height
|
|
128
|
+
|
|
129
|
+
@pixel_width = pixel_width
|
|
130
|
+
@pixel_height = pixel_height
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@content_hash = Digest::MD5.hexdigest(@image_data)[0, 8]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Render this image into the buffer.
|
|
137
|
+
#
|
|
138
|
+
# Fills the area with placeholder cells and registers an image placement
|
|
139
|
+
# via {::RubyTUI::Buffer#set_image}. The backend emits the Kitty graphics protocol
|
|
140
|
+
# sequences during {::RubyTUI::Terminal#draw}.
|
|
141
|
+
#
|
|
142
|
+
# @param area [Rect] the rectangular area to render into
|
|
143
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
144
|
+
# @return [void]
|
|
145
|
+
def render(area, buf)
|
|
146
|
+
return if area.empty?
|
|
147
|
+
|
|
148
|
+
render_area = area
|
|
149
|
+
if @block
|
|
150
|
+
@block.render(area, buf)
|
|
151
|
+
render_area = @block.inner(area)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return if render_area.empty?
|
|
155
|
+
|
|
156
|
+
# Apply fit mode to compute actual image placement
|
|
157
|
+
if @fit && @fit != :stretch
|
|
158
|
+
render_area = compute_fit_area(render_area)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Clip to buffer bounds if requested
|
|
162
|
+
if @clip
|
|
163
|
+
render_area = clip_to_buffer(render_area, buf.area)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return if render_area.empty?
|
|
167
|
+
|
|
168
|
+
# Fill the area with spaces to claim it in the buffer
|
|
169
|
+
render_area.height.times do |row|
|
|
170
|
+
render_area.width.times do |col|
|
|
171
|
+
buf[render_area.x + col, render_area.y + row]&.set(" ", @style)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Build placement hash
|
|
176
|
+
placement = {
|
|
177
|
+
x: render_area.x,
|
|
178
|
+
y: render_area.y,
|
|
179
|
+
cols: render_area.width,
|
|
180
|
+
rows: render_area.height,
|
|
181
|
+
data: @image_data,
|
|
182
|
+
pixel_width: @pixel_width,
|
|
183
|
+
pixel_height: @pixel_height,
|
|
184
|
+
format: @format,
|
|
185
|
+
content_hash: @content_hash
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Add source rectangle if specified (for cropping/scrolling)
|
|
189
|
+
if @src_rect
|
|
190
|
+
placement[:src_x] = @src_rect.x
|
|
191
|
+
placement[:src_y] = @src_rect.y
|
|
192
|
+
placement[:src_w] = @src_rect.width
|
|
193
|
+
placement[:src_h] = @src_rect.height
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Register the image placement on the buffer
|
|
197
|
+
buf.set_image(**placement)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Read width and height from a PNG file's IHDR chunk.
|
|
201
|
+
#
|
|
202
|
+
# @param data [String] raw PNG file bytes
|
|
203
|
+
# @return [Array(Integer, Integer), nil] [width, height] or nil if not valid PNG
|
|
204
|
+
def self.png_dimensions(data)
|
|
205
|
+
return nil unless data.bytesize >= 24
|
|
206
|
+
return nil unless data.byteslice(0, 8) == PNG_SIGNATURE
|
|
207
|
+
|
|
208
|
+
width = data.byteslice(16, 4).unpack1("N")
|
|
209
|
+
height = data.byteslice(20, 4).unpack1("N")
|
|
210
|
+
[width, height]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Detect image format from magic bytes.
|
|
214
|
+
#
|
|
215
|
+
# @param data [String] raw image bytes
|
|
216
|
+
# @return [Symbol] detected format — +:png+, +:jpeg+, +:webp+, +:gif+, +:bmp+, or +:rgba+ (unknown)
|
|
217
|
+
def self.detect_format(data)
|
|
218
|
+
return :rgba if data.nil? || data.bytesize < 8
|
|
219
|
+
|
|
220
|
+
# Force binary encoding for comparison
|
|
221
|
+
header = data.byteslice(0, 12).b
|
|
222
|
+
|
|
223
|
+
if header.start_with?(PNG_SIGNATURE)
|
|
224
|
+
:png
|
|
225
|
+
elsif header.start_with?(JPEG_SIGNATURE)
|
|
226
|
+
:jpeg
|
|
227
|
+
elsif header.start_with?(WEBP_RIFF) && data.byteslice(8, 4).b == WEBP_MARKER
|
|
228
|
+
:webp
|
|
229
|
+
elsif header.start_with?(GIF87A_SIGNATURE) || header.start_with?(GIF89A_SIGNATURE)
|
|
230
|
+
:gif
|
|
231
|
+
elsif header.start_with?(BMP_SIGNATURE)
|
|
232
|
+
:bmp
|
|
233
|
+
else
|
|
234
|
+
:rgba
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Compute area that preserves aspect ratio.
|
|
239
|
+
#
|
|
240
|
+
# @param area [Rect] available area in terminal cells
|
|
241
|
+
# @param pixel_width [Integer] image width in pixels
|
|
242
|
+
# @param pixel_height [Integer] image height in pixels
|
|
243
|
+
# @param cell_size [Array(Integer, Integer)] cell dimensions [width_px, height_px]
|
|
244
|
+
# @param mode [Symbol] +:contain+ (fit inside, preserving aspect ratio) or
|
|
245
|
+
# +:cover+ (computes an overflowing size, then clamps it to +area+; nothing
|
|
246
|
+
# is cropped, so the result is the whole area, as with +:stretch+)
|
|
247
|
+
# @return [Rect] computed placement within +area+
|
|
248
|
+
def self.fit_area(area, pixel_width, pixel_height, cell_size: [9, 18], mode: :contain)
|
|
249
|
+
cell_w, cell_h = cell_size
|
|
250
|
+
cell_ratio = cell_h.to_f / cell_w
|
|
251
|
+
|
|
252
|
+
image_aspect = pixel_width.to_f / pixel_height
|
|
253
|
+
area_aspect = (area.width * cell_w).to_f / (area.height * cell_h)
|
|
254
|
+
|
|
255
|
+
if mode == :contain
|
|
256
|
+
if image_aspect > area_aspect
|
|
257
|
+
# Image is wider — constrain by width
|
|
258
|
+
cols = area.width
|
|
259
|
+
rows = (cols / image_aspect / cell_ratio).round
|
|
260
|
+
else
|
|
261
|
+
# Image is taller — constrain by height
|
|
262
|
+
rows = area.height
|
|
263
|
+
cols = (rows * image_aspect * cell_ratio).round
|
|
264
|
+
end
|
|
265
|
+
else # :cover
|
|
266
|
+
if image_aspect > area_aspect
|
|
267
|
+
# Image is wider — constrain by height (will overflow width)
|
|
268
|
+
rows = area.height
|
|
269
|
+
cols = (rows * image_aspect * cell_ratio).round
|
|
270
|
+
else
|
|
271
|
+
# Image is taller — constrain by width (will overflow height)
|
|
272
|
+
cols = area.width
|
|
273
|
+
rows = (cols / image_aspect / cell_ratio).round
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
cols = [[cols, 1].max, area.width].min
|
|
278
|
+
rows = [[rows, 1].max, area.height].min
|
|
279
|
+
|
|
280
|
+
# Center within area
|
|
281
|
+
x = area.x + (area.width - cols) / 2
|
|
282
|
+
y = area.y + (area.height - rows) / 2
|
|
283
|
+
|
|
284
|
+
Rect.new(x, y, cols, rows)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def validate_format!(fmt)
|
|
290
|
+
case fmt
|
|
291
|
+
when :png, :rgba
|
|
292
|
+
# OK
|
|
293
|
+
when :jpeg
|
|
294
|
+
raise UnsupportedImageFormat,
|
|
295
|
+
"JPEG format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
296
|
+
" system(\"magick\", \"input.jpg\", \"output.png\")"
|
|
297
|
+
when :webp
|
|
298
|
+
raise UnsupportedImageFormat,
|
|
299
|
+
"WebP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
300
|
+
" system(\"magick\", \"input.webp\", \"output.png\")"
|
|
301
|
+
when :gif
|
|
302
|
+
raise UnsupportedImageFormat,
|
|
303
|
+
"GIF format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
304
|
+
" system(\"magick\", \"input.gif[0]\", \"output.png\") # [0] = first frame"
|
|
305
|
+
when :bmp
|
|
306
|
+
raise UnsupportedImageFormat,
|
|
307
|
+
"BMP format not supported by Kitty graphics protocol. Convert to PNG first:\n" \
|
|
308
|
+
" system(\"magick\", \"input.bmp\", \"output.png\")"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def compute_fit_area(area)
|
|
313
|
+
cell_size = @cell_size || [9, 18]
|
|
314
|
+
self.class.fit_area(area, @pixel_width, @pixel_height, cell_size: cell_size, mode: @fit)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def clip_to_buffer(area, buf_area)
|
|
318
|
+
x1 = [area.x, buf_area.x].max
|
|
319
|
+
y1 = [area.y, buf_area.y].max
|
|
320
|
+
x2 = [area.right, buf_area.right].min
|
|
321
|
+
y2 = [area.bottom, buf_area.bottom].min
|
|
322
|
+
|
|
323
|
+
width = [x2 - x1, 0].max
|
|
324
|
+
height = [y2 - y1, 0].max
|
|
325
|
+
|
|
326
|
+
Rect.new(x1, y1, width, height)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|