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,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Built-in widgets.
|
|
5
|
+
#
|
|
6
|
+
# Stateless widgets include {Widget} and implement <tt>render(area, buf)</tt>.
|
|
7
|
+
# Stateful widgets include {StatefulWidget} and implement
|
|
8
|
+
# <tt>render(area, buf, state)</tt> with an application-owned state object
|
|
9
|
+
# such as {ListState}, {TableState} or {InputState}.
|
|
10
|
+
module Widgets
|
|
11
|
+
# Block is the most fundamental widget: a rectangular area with optional
|
|
12
|
+
# borders, title, and padding. Other widgets are typically rendered inside
|
|
13
|
+
# a Block's inner area.
|
|
14
|
+
#
|
|
15
|
+
# @example Bordered block with a centered title
|
|
16
|
+
# block = RubyTUI::Widgets::Block.new(title: " Status ", title_alignment: :center)
|
|
17
|
+
# frame.render_widget(block, area)
|
|
18
|
+
# content_area = block.inner(area)
|
|
19
|
+
class Block
|
|
20
|
+
include Widget
|
|
21
|
+
|
|
22
|
+
# Border configuration
|
|
23
|
+
|
|
24
|
+
# No borders.
|
|
25
|
+
NONE = :none
|
|
26
|
+
# All four borders.
|
|
27
|
+
ALL = :all
|
|
28
|
+
# Top border.
|
|
29
|
+
TOP = :top
|
|
30
|
+
# Bottom border.
|
|
31
|
+
BOTTOM = :bottom
|
|
32
|
+
# Left border.
|
|
33
|
+
LEFT = :left
|
|
34
|
+
# Right border.
|
|
35
|
+
RIGHT = :right
|
|
36
|
+
|
|
37
|
+
attr_reader :title, :title_alignment, :borders, :border_set,
|
|
38
|
+
:border_style, :style, :padding
|
|
39
|
+
|
|
40
|
+
# @param title [String, nil] title drawn on the top border, truncated to fit
|
|
41
|
+
# between the corners; not drawn when the top border is disabled
|
|
42
|
+
# @param title_alignment [Symbol] +:left+ (default, one cell in from the
|
|
43
|
+
# left corner), +:center+, or +:right+
|
|
44
|
+
# @param title_style [Style, nil] title style (default: nil, uses +border_style+)
|
|
45
|
+
# @param borders [Symbol, Array<Symbol>, Boolean, nil] sides to draw:
|
|
46
|
+
# +:all+ or +true+ (default: {ALL}), +:none+, +false+ or +nil+ for none,
|
|
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
|
|
50
|
+
# @param border_set [Symbols::BorderSet] border characters (default: {Symbols::ROUNDED})
|
|
51
|
+
# @param border_style [Style] style of the border characters (default: {Style::DEFAULT})
|
|
52
|
+
# @param style [Style] style patched onto every cell of the area when it is
|
|
53
|
+
# not {Style::DEFAULT} (default: {Style::DEFAULT})
|
|
54
|
+
# @param padding [Integer, Array<Integer>, nil] space inside the borders:
|
|
55
|
+
# an Integer for all sides, or an Array of 1 (all sides), 2
|
|
56
|
+
# (<tt>[vertical, horizontal]</tt>) or 4 (<tt>[top, right, bottom, left]</tt>)
|
|
57
|
+
# values; +nil+ or any other shape means no padding (default: nil)
|
|
58
|
+
def initialize(
|
|
59
|
+
title: nil,
|
|
60
|
+
title_alignment: :left,
|
|
61
|
+
title_style: nil,
|
|
62
|
+
borders: ALL,
|
|
63
|
+
border_set: Symbols::ROUNDED,
|
|
64
|
+
border_style: Style::DEFAULT,
|
|
65
|
+
style: Style::DEFAULT,
|
|
66
|
+
padding: nil # [top, right, bottom, left] or single integer
|
|
67
|
+
)
|
|
68
|
+
@title = title
|
|
69
|
+
@title_alignment = title_alignment
|
|
70
|
+
@title_style = title_style || border_style
|
|
71
|
+
@borders = normalize_borders(borders)
|
|
72
|
+
@border_set = border_set
|
|
73
|
+
@border_style = border_style
|
|
74
|
+
@style = style
|
|
75
|
+
@padding = normalize_padding(padding)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Calculate the inner area (area minus borders and padding)
|
|
79
|
+
#
|
|
80
|
+
# @param area [Rect] the outer area of the block
|
|
81
|
+
# @return [Rect] the area inside the enabled borders and padding (width and
|
|
82
|
+
# height clamped to >= 0), or {Rect::ZERO} if +area+ is empty
|
|
83
|
+
def inner(area)
|
|
84
|
+
return Rect::ZERO if area.empty?
|
|
85
|
+
|
|
86
|
+
x = area.x
|
|
87
|
+
y = area.y
|
|
88
|
+
w = area.width
|
|
89
|
+
h = area.height
|
|
90
|
+
|
|
91
|
+
if @borders.include?(:left)
|
|
92
|
+
x += 1
|
|
93
|
+
w -= 1
|
|
94
|
+
end
|
|
95
|
+
if @borders.include?(:right)
|
|
96
|
+
w -= 1
|
|
97
|
+
end
|
|
98
|
+
if @borders.include?(:top)
|
|
99
|
+
y += 1
|
|
100
|
+
h -= 1
|
|
101
|
+
end
|
|
102
|
+
if @borders.include?(:bottom)
|
|
103
|
+
h -= 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Apply padding
|
|
107
|
+
x += @padding[3] # left
|
|
108
|
+
y += @padding[0] # top
|
|
109
|
+
w -= @padding[1] + @padding[3] # right + left
|
|
110
|
+
h -= @padding[0] + @padding[2] # top + bottom
|
|
111
|
+
|
|
112
|
+
Rect.new(x: x, y: y, width: [w, 0].max, height: [h, 0].max)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Render the block's background, borders and title.
|
|
116
|
+
#
|
|
117
|
+
# The background +style+ is patched onto every cell of +area+ unless it is
|
|
118
|
+
# {Style::DEFAULT}. Child content is not rendered; draw it into {#inner}.
|
|
119
|
+
#
|
|
120
|
+
# @param area [Rect] the rectangular area to render into
|
|
121
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
122
|
+
# @return [void]
|
|
123
|
+
def render(area, buf)
|
|
124
|
+
return if area.empty?
|
|
125
|
+
|
|
126
|
+
# Fill background
|
|
127
|
+
buf.set_style(area, @style) if @style != Style::DEFAULT
|
|
128
|
+
|
|
129
|
+
# Draw borders
|
|
130
|
+
render_borders(area, buf)
|
|
131
|
+
|
|
132
|
+
# Draw title
|
|
133
|
+
render_title(area, buf) if @title
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def render_borders(area, buf)
|
|
139
|
+
has_top = @borders.include?(:top)
|
|
140
|
+
has_bottom = @borders.include?(:bottom)
|
|
141
|
+
has_left = @borders.include?(:left)
|
|
142
|
+
has_right = @borders.include?(:right)
|
|
143
|
+
|
|
144
|
+
# Top border
|
|
145
|
+
if has_top
|
|
146
|
+
# Top-left corner
|
|
147
|
+
if has_left
|
|
148
|
+
buf[area.x, area.y]&.set(@border_set.top_left, @border_style)
|
|
149
|
+
end
|
|
150
|
+
# Top horizontal line
|
|
151
|
+
x_start = area.x + (has_left ? 1 : 0)
|
|
152
|
+
x_end = area.right - (has_right ? 1 : 0)
|
|
153
|
+
(x_start...x_end).each do |x|
|
|
154
|
+
buf[x, area.y]&.set(@border_set.horizontal, @border_style)
|
|
155
|
+
end
|
|
156
|
+
# Top-right corner
|
|
157
|
+
if has_right
|
|
158
|
+
buf[area.right - 1, area.y]&.set(@border_set.top_right, @border_style)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Bottom border
|
|
163
|
+
if has_bottom
|
|
164
|
+
bottom_y = area.bottom - 1
|
|
165
|
+
if has_left
|
|
166
|
+
buf[area.x, bottom_y]&.set(@border_set.bottom_left, @border_style)
|
|
167
|
+
end
|
|
168
|
+
x_start = area.x + (has_left ? 1 : 0)
|
|
169
|
+
x_end = area.right - (has_right ? 1 : 0)
|
|
170
|
+
(x_start...x_end).each do |x|
|
|
171
|
+
buf[x, bottom_y]&.set(@border_set.horizontal, @border_style)
|
|
172
|
+
end
|
|
173
|
+
if has_right
|
|
174
|
+
buf[area.right - 1, bottom_y]&.set(@border_set.bottom_right, @border_style)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Left border (vertical)
|
|
179
|
+
if has_left
|
|
180
|
+
y_start = area.y + (has_top ? 1 : 0)
|
|
181
|
+
y_end = area.bottom - (has_bottom ? 1 : 0)
|
|
182
|
+
(y_start...y_end).each do |y|
|
|
183
|
+
buf[area.x, y]&.set(@border_set.vertical, @border_style)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Right border (vertical)
|
|
188
|
+
if has_right
|
|
189
|
+
y_start = area.y + (has_top ? 1 : 0)
|
|
190
|
+
y_end = area.bottom - (has_bottom ? 1 : 0)
|
|
191
|
+
(y_start...y_end).each do |y|
|
|
192
|
+
buf[area.right - 1, y]&.set(@border_set.vertical, @border_style)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def render_title(area, buf)
|
|
198
|
+
return unless @borders.include?(:top)
|
|
199
|
+
|
|
200
|
+
has_left = @borders.include?(:left)
|
|
201
|
+
has_right = @borders.include?(:right)
|
|
202
|
+
|
|
203
|
+
# Available width for title (between corners)
|
|
204
|
+
x_start = area.x + (has_left ? 1 : 0)
|
|
205
|
+
x_end = area.right - (has_right ? 1 : 0)
|
|
206
|
+
available = x_end - x_start
|
|
207
|
+
|
|
208
|
+
return if available <= 0
|
|
209
|
+
|
|
210
|
+
title_text = Buffer.truncate_to_width(@title, available)
|
|
211
|
+
title_width = Buffer.string_width(title_text)
|
|
212
|
+
|
|
213
|
+
x = case @title_alignment
|
|
214
|
+
when :center
|
|
215
|
+
x_start + (available - title_width) / 2
|
|
216
|
+
when :right
|
|
217
|
+
x_end - title_width
|
|
218
|
+
else # :left
|
|
219
|
+
x_start + 1 # 1 cell padding from left border
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
x = [x, x_start].max
|
|
223
|
+
buf.set_string(x, area.y, title_text, @title_style)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def normalize_borders(borders)
|
|
227
|
+
case borders
|
|
228
|
+
when :all, true
|
|
229
|
+
[:top, :bottom, :left, :right].freeze
|
|
230
|
+
when :none, false, nil
|
|
231
|
+
[].freeze
|
|
232
|
+
when Symbol
|
|
233
|
+
[borders].freeze
|
|
234
|
+
when Array
|
|
235
|
+
borders.freeze
|
|
236
|
+
else
|
|
237
|
+
[:top, :bottom, :left, :right].freeze
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def normalize_padding(padding)
|
|
242
|
+
case padding
|
|
243
|
+
when nil
|
|
244
|
+
[0, 0, 0, 0]
|
|
245
|
+
when Integer
|
|
246
|
+
[padding, padding, padding, padding]
|
|
247
|
+
when Array
|
|
248
|
+
case padding.length
|
|
249
|
+
when 1 then [padding[0]] * 4
|
|
250
|
+
when 2 then [padding[0], padding[1], padding[0], padding[1]]
|
|
251
|
+
when 4 then padding
|
|
252
|
+
else [0, 0, 0, 0]
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
[0, 0, 0, 0]
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Canvas provides sub-cell resolution drawing using Braille Unicode characters.
|
|
6
|
+
# Each terminal cell maps to a 2x4 grid of dots (U+2800 to U+28FF).
|
|
7
|
+
# This gives 2x horizontal and 4x vertical resolution compared to regular cells.
|
|
8
|
+
#
|
|
9
|
+
# Shapes are added in data coordinates and mapped onto the dot grid; coordinates
|
|
10
|
+
# outside the bounds are clamped to the nearest edge.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# canvas = RubyTUI::Widgets::Canvas.new(x_bounds: [0, 10], y_bounds: [0, 10])
|
|
14
|
+
# canvas.line(0, 0, 10, 10).point(5, 2).label(1, 9, "peak")
|
|
15
|
+
# frame.render_widget(canvas, area)
|
|
16
|
+
class Canvas
|
|
17
|
+
include Widget
|
|
18
|
+
|
|
19
|
+
# Braille dot positions within a cell:
|
|
20
|
+
# 0 3
|
|
21
|
+
# 1 4
|
|
22
|
+
# 2 5
|
|
23
|
+
# 6 7
|
|
24
|
+
# Bit positions map to these dot positions.
|
|
25
|
+
DOT_PATTERNS = [
|
|
26
|
+
0x01, 0x02, 0x04, 0x08,
|
|
27
|
+
0x10, 0x20, 0x40, 0x80
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Code point of the empty Braille pattern (U+2800); dot bits are added to it.
|
|
31
|
+
BRAILLE_BASE = 0x2800
|
|
32
|
+
|
|
33
|
+
attr_reader :width, :height
|
|
34
|
+
|
|
35
|
+
# @param x_bounds [Array(Numeric, Numeric), nil] <tt>[min, max]</tt> data range
|
|
36
|
+
# mapped to the canvas width (default: nil, computed from the x coordinates
|
|
37
|
+
# of points and lines)
|
|
38
|
+
# @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)
|
|
41
|
+
# @param style [Style] style for labels added without a style (default: {Style::DEFAULT})
|
|
42
|
+
# @param marker_style [Style, nil] style for dots drawn without a style
|
|
43
|
+
# (default: white foreground)
|
|
44
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
45
|
+
def initialize(
|
|
46
|
+
x_bounds: nil,
|
|
47
|
+
y_bounds: nil,
|
|
48
|
+
style: Style::DEFAULT,
|
|
49
|
+
marker_style: nil,
|
|
50
|
+
block: nil
|
|
51
|
+
)
|
|
52
|
+
@x_bounds = x_bounds # [min, max] for x coordinate mapping
|
|
53
|
+
@y_bounds = y_bounds # [min, max] for y coordinate mapping
|
|
54
|
+
@style = style
|
|
55
|
+
@marker_style = marker_style || Style.new.fg(Color::WHITE)
|
|
56
|
+
@block = block
|
|
57
|
+
@points = []
|
|
58
|
+
@lines = []
|
|
59
|
+
@labels = []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Add a point at (x, y) in data coordinates
|
|
63
|
+
#
|
|
64
|
+
# @param x [Numeric] x data coordinate
|
|
65
|
+
# @param y [Numeric] y data coordinate
|
|
66
|
+
# @param style [Style, nil] dot style (default: nil, uses +marker_style+)
|
|
67
|
+
# @return [self]
|
|
68
|
+
def point(x, y, style: nil)
|
|
69
|
+
@points << [x, y, style]
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Add multiple points
|
|
74
|
+
#
|
|
75
|
+
# @param coords [Array<Array(Numeric, Numeric)>] <tt>[x, y]</tt> pairs in data coordinates
|
|
76
|
+
# @param style [Style, nil] dot style for all points (default: nil, uses +marker_style+)
|
|
77
|
+
# @return [self]
|
|
78
|
+
def points(coords, style: nil)
|
|
79
|
+
coords.each { |x, y| @points << [x, y, style] }
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Add a line from (x1, y1) to (x2, y2)
|
|
84
|
+
#
|
|
85
|
+
# @param x1 [Numeric] start x data coordinate
|
|
86
|
+
# @param y1 [Numeric] start y data coordinate
|
|
87
|
+
# @param x2 [Numeric] end x data coordinate
|
|
88
|
+
# @param y2 [Numeric] end y data coordinate
|
|
89
|
+
# @param style [Style, nil] dot style for the line (default: nil, uses +marker_style+)
|
|
90
|
+
# @return [self]
|
|
91
|
+
def line(x1, y1, x2, y2, style: nil)
|
|
92
|
+
@lines << [x1, y1, x2, y2, style]
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Add a text label at (x, y) in data coordinates
|
|
97
|
+
#
|
|
98
|
+
# @param x [Numeric] x data coordinate
|
|
99
|
+
# @param y [Numeric] y data coordinate
|
|
100
|
+
# @param text [String] text drawn starting at the cell that contains (x, y)
|
|
101
|
+
# @param style [Style, nil] text style (default: nil, uses the canvas +style+)
|
|
102
|
+
# @return [self]
|
|
103
|
+
def label(x, y, text, style: nil)
|
|
104
|
+
@labels << [x, y, text, style]
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Rasterize all added lines, points and labels into the buffer.
|
|
109
|
+
#
|
|
110
|
+
# Lines are drawn first, then points, as Braille dots. Each cell takes the
|
|
111
|
+
# 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.
|
|
113
|
+
#
|
|
114
|
+
# @param area [Rect] the rectangular area to render into
|
|
115
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
116
|
+
# @return [void]
|
|
117
|
+
def render(area, buf)
|
|
118
|
+
return if area.empty?
|
|
119
|
+
|
|
120
|
+
render_area = area
|
|
121
|
+
if @block
|
|
122
|
+
@block.render(area, buf)
|
|
123
|
+
render_area = @block.inner(area)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
return if render_area.empty?
|
|
127
|
+
|
|
128
|
+
# Canvas resolution: each cell is 2 dots wide, 4 dots tall
|
|
129
|
+
canvas_w = render_area.width * 2
|
|
130
|
+
canvas_h = render_area.height * 4
|
|
131
|
+
|
|
132
|
+
# Initialize dot grid (indexed by canvas pixel coordinates)
|
|
133
|
+
grid = Array.new(render_area.height) { Array.new(render_area.width, 0) }
|
|
134
|
+
style_grid = Array.new(render_area.height) { Array.new(render_area.width, nil) }
|
|
135
|
+
|
|
136
|
+
# Compute bounds
|
|
137
|
+
x_min, x_max = compute_bounds(@x_bounds, canvas_w)
|
|
138
|
+
y_min, y_max = compute_bounds(@y_bounds, canvas_h)
|
|
139
|
+
|
|
140
|
+
# Rasterize lines
|
|
141
|
+
@lines.each do |x1, y1, x2, y2, lstyle|
|
|
142
|
+
rasterize_line(x1, y1, x2, y2, lstyle, grid, style_grid,
|
|
143
|
+
x_min, x_max, y_min, y_max, canvas_w, canvas_h)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Rasterize points
|
|
147
|
+
@points.each do |px, py, pstyle|
|
|
148
|
+
cx, cy = data_to_canvas(px, py, x_min, x_max, y_min, y_max, canvas_w, canvas_h)
|
|
149
|
+
set_dot(grid, style_grid, cx, cy, pstyle, render_area.width, render_area.height)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Render grid to buffer
|
|
153
|
+
render_area.height.times do |row|
|
|
154
|
+
render_area.width.times do |col|
|
|
155
|
+
pattern = grid[row][col]
|
|
156
|
+
next if pattern == 0
|
|
157
|
+
|
|
158
|
+
char = (BRAILLE_BASE + pattern).chr("UTF-8")
|
|
159
|
+
cell_style = style_grid[row][col] || @marker_style
|
|
160
|
+
buf[render_area.x + col, render_area.y + row]&.set(char, cell_style)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Render labels
|
|
165
|
+
@labels.each do |lx, ly, text, lstyle|
|
|
166
|
+
cx, cy = data_to_canvas(lx, ly, x_min, x_max, y_min, y_max, canvas_w, canvas_h)
|
|
167
|
+
cell_x = render_area.x + cx / 2
|
|
168
|
+
cell_y = render_area.y + cy / 4
|
|
169
|
+
buf.set_string(cell_x, cell_y, text, lstyle || @style)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def compute_bounds(bounds, canvas_size)
|
|
176
|
+
if bounds
|
|
177
|
+
[bounds[0].to_f, bounds[1].to_f]
|
|
178
|
+
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?
|
|
181
|
+
|
|
182
|
+
mn = all_x.min.to_f
|
|
183
|
+
mx = all_x.max.to_f
|
|
184
|
+
mx = mn + 1 if mx == mn
|
|
185
|
+
[mn, mx]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def data_to_canvas(dx, dy, x_min, x_max, y_min, y_max, cw, ch)
|
|
190
|
+
cx = ((dx - x_min) / (x_max - x_min) * (cw - 1)).round.clamp(0, cw - 1)
|
|
191
|
+
# Y is inverted: data y=max is canvas y=0 (top)
|
|
192
|
+
cy = ((y_max - dy) / (y_max - y_min) * (ch - 1)).round.clamp(0, ch - 1)
|
|
193
|
+
[cx, cy]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def set_dot(grid, style_grid, cx, cy, dot_style, grid_w, grid_h)
|
|
197
|
+
cell_col = cx / 2
|
|
198
|
+
cell_row = cy / 4
|
|
199
|
+
return if cell_col >= grid_w || cell_row >= grid_h
|
|
200
|
+
|
|
201
|
+
# Determine which dot within the cell
|
|
202
|
+
dot_x = cx % 2 # 0 or 1
|
|
203
|
+
dot_y = cy % 4 # 0, 1, 2, or 3
|
|
204
|
+
|
|
205
|
+
# Map to braille bit position
|
|
206
|
+
bit_index = if dot_y < 3
|
|
207
|
+
dot_y + (dot_x * 3) # positions 0-2 (left) or 3-5 (right)
|
|
208
|
+
else
|
|
209
|
+
6 + dot_x # positions 6 (left) or 7 (right)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
grid[cell_row][cell_col] |= DOT_PATTERNS[bit_index]
|
|
213
|
+
style_grid[cell_row][cell_col] = dot_style if dot_style
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Bresenham's line algorithm in canvas (dot) coordinates
|
|
217
|
+
def rasterize_line(x1, y1, x2, y2, lstyle, grid, style_grid,
|
|
218
|
+
x_min, x_max, y_min, y_max, cw, ch)
|
|
219
|
+
cx1, cy1 = data_to_canvas(x1, y1, x_min, x_max, y_min, y_max, cw, ch)
|
|
220
|
+
cx2, cy2 = data_to_canvas(x2, y2, x_min, x_max, y_min, y_max, cw, ch)
|
|
221
|
+
|
|
222
|
+
dx = (cx2 - cx1).abs
|
|
223
|
+
dy = -(cy2 - cy1).abs
|
|
224
|
+
sx = cx1 < cx2 ? 1 : -1
|
|
225
|
+
sy = cy1 < cy2 ? 1 : -1
|
|
226
|
+
err = dx + dy
|
|
227
|
+
|
|
228
|
+
gw = grid[0]&.length || 0
|
|
229
|
+
gh = grid.length
|
|
230
|
+
|
|
231
|
+
loop do
|
|
232
|
+
set_dot(grid, style_grid, cx1, cy1, lstyle, gw, gh)
|
|
233
|
+
break if cx1 == cx2 && cy1 == cy2
|
|
234
|
+
|
|
235
|
+
e2 = 2 * err
|
|
236
|
+
if e2 >= dy
|
|
237
|
+
err += dy
|
|
238
|
+
cx1 += sx
|
|
239
|
+
end
|
|
240
|
+
if e2 <= dx
|
|
241
|
+
err += dx
|
|
242
|
+
cy1 += sy
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|