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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +574 -0
  4. data/lib/rubytui/app.rb +174 -0
  5. data/lib/rubytui/backend.rb +158 -0
  6. data/lib/rubytui/backends/ansi_backend.rb +391 -0
  7. data/lib/rubytui/backends/test_backend.rb +223 -0
  8. data/lib/rubytui/buffer.rb +400 -0
  9. data/lib/rubytui/cell.rb +55 -0
  10. data/lib/rubytui/color.rb +153 -0
  11. data/lib/rubytui/color_mode.rb +203 -0
  12. data/lib/rubytui/errors.rb +13 -0
  13. data/lib/rubytui/event.rb +161 -0
  14. data/lib/rubytui/frame.rb +70 -0
  15. data/lib/rubytui/input/key.rb +93 -0
  16. data/lib/rubytui/input/parser.rb +231 -0
  17. data/lib/rubytui/input/reader.rb +119 -0
  18. data/lib/rubytui/layout/constraint.rb +83 -0
  19. data/lib/rubytui/layout/flex.rb +16 -0
  20. data/lib/rubytui/layout/layout.rb +205 -0
  21. data/lib/rubytui/modifier.rb +67 -0
  22. data/lib/rubytui/rect.rb +126 -0
  23. data/lib/rubytui/stateful_widget.rb +20 -0
  24. data/lib/rubytui/style.rb +143 -0
  25. data/lib/rubytui/symbols.rb +88 -0
  26. data/lib/rubytui/terminal.rb +218 -0
  27. data/lib/rubytui/text/line.rb +67 -0
  28. data/lib/rubytui/text/span.rb +34 -0
  29. data/lib/rubytui/text/text.rb +82 -0
  30. data/lib/rubytui/unicode.rb +162 -0
  31. data/lib/rubytui/version.rb +6 -0
  32. data/lib/rubytui/widget.rb +20 -0
  33. data/lib/rubytui/widgets/async_image.rb +248 -0
  34. data/lib/rubytui/widgets/block.rb +260 -0
  35. data/lib/rubytui/widgets/canvas.rb +248 -0
  36. data/lib/rubytui/widgets/chart.rb +224 -0
  37. data/lib/rubytui/widgets/gauge.rb +139 -0
  38. data/lib/rubytui/widgets/image.rb +330 -0
  39. data/lib/rubytui/widgets/input_field.rb +245 -0
  40. data/lib/rubytui/widgets/list.rb +186 -0
  41. data/lib/rubytui/widgets/paragraph.rb +181 -0
  42. data/lib/rubytui/widgets/popup.rb +140 -0
  43. data/lib/rubytui/widgets/scrollbar.rb +175 -0
  44. data/lib/rubytui/widgets/sparkline.rb +86 -0
  45. data/lib/rubytui/widgets/table.rb +231 -0
  46. data/lib/rubytui/widgets/tabs.rb +90 -0
  47. data/lib/rubytui.rb +389 -0
  48. 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