ratatat 1.0.0

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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +201 -0
  3. data/examples/log_tailer.rb +460 -0
  4. data/lib/ratatat/ansi_backend.rb +175 -0
  5. data/lib/ratatat/app.rb +342 -0
  6. data/lib/ratatat/binding.rb +74 -0
  7. data/lib/ratatat/buffer.rb +238 -0
  8. data/lib/ratatat/cell.rb +166 -0
  9. data/lib/ratatat/color.rb +191 -0
  10. data/lib/ratatat/css_parser.rb +192 -0
  11. data/lib/ratatat/dom_query.rb +124 -0
  12. data/lib/ratatat/driver.rb +200 -0
  13. data/lib/ratatat/input.rb +208 -0
  14. data/lib/ratatat/message.rb +147 -0
  15. data/lib/ratatat/reactive.rb +79 -0
  16. data/lib/ratatat/styles.rb +293 -0
  17. data/lib/ratatat/terminal.rb +168 -0
  18. data/lib/ratatat/version.rb +3 -0
  19. data/lib/ratatat/widget.rb +337 -0
  20. data/lib/ratatat/widgets/button.rb +43 -0
  21. data/lib/ratatat/widgets/checkbox.rb +68 -0
  22. data/lib/ratatat/widgets/container.rb +50 -0
  23. data/lib/ratatat/widgets/data_table.rb +123 -0
  24. data/lib/ratatat/widgets/grid.rb +40 -0
  25. data/lib/ratatat/widgets/horizontal.rb +52 -0
  26. data/lib/ratatat/widgets/log.rb +97 -0
  27. data/lib/ratatat/widgets/modal.rb +161 -0
  28. data/lib/ratatat/widgets/progress_bar.rb +80 -0
  29. data/lib/ratatat/widgets/radio_set.rb +91 -0
  30. data/lib/ratatat/widgets/scrollable_container.rb +88 -0
  31. data/lib/ratatat/widgets/select.rb +100 -0
  32. data/lib/ratatat/widgets/sparkline.rb +61 -0
  33. data/lib/ratatat/widgets/spinner.rb +93 -0
  34. data/lib/ratatat/widgets/static.rb +23 -0
  35. data/lib/ratatat/widgets/tabbed_content.rb +114 -0
  36. data/lib/ratatat/widgets/text_area.rb +183 -0
  37. data/lib/ratatat/widgets/text_input.rb +143 -0
  38. data/lib/ratatat/widgets/toast.rb +55 -0
  39. data/lib/ratatat/widgets/tooltip.rb +66 -0
  40. data/lib/ratatat/widgets/tree.rb +172 -0
  41. data/lib/ratatat/widgets/vertical.rb +52 -0
  42. data/lib/ratatat.rb +51 -0
  43. metadata +142 -0
@@ -0,0 +1,238 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Ratatat
7
+ # A 2D grid of cells representing a terminal screen or region.
8
+ # Supports efficient diffing to minimize terminal I/O.
9
+ class Buffer
10
+ extend T::Sig
11
+
12
+ sig { returns(Integer) }
13
+ attr_reader :width, :height
14
+
15
+ sig { returns(T::Array[Cell]) }
16
+ attr_reader :cells
17
+
18
+ sig { params(width: Integer, height: Integer).void }
19
+ def initialize(width, height)
20
+ @width = width
21
+ @height = height
22
+ @cells = T.let(Array.new(width * height) { Cell::EMPTY }, T::Array[Cell])
23
+ end
24
+
25
+ # Get cell at (x, y) coordinates
26
+ sig { params(x: Integer, y: Integer).returns(T.nilable(Cell)) }
27
+ def get(x, y)
28
+ return nil unless in_bounds?(x, y)
29
+
30
+ @cells[index_of(x, y)]
31
+ end
32
+
33
+ # Alias for get
34
+ sig { params(x: Integer, y: Integer).returns(T.nilable(Cell)) }
35
+ def [](x, y)
36
+ get(x, y)
37
+ end
38
+
39
+ # Set cell at (x, y) coordinates
40
+ sig { params(x: Integer, y: Integer, cell: Cell).void }
41
+ def set(x, y, cell)
42
+ return unless in_bounds?(x, y)
43
+
44
+ @cells[index_of(x, y)] = cell
45
+ end
46
+
47
+ # Alias for set
48
+ sig { params(x: Integer, y: Integer, cell: Cell).void }
49
+ def []=(x, y, cell)
50
+ set(x, y, cell)
51
+ end
52
+
53
+ # Convert (x, y) to linear index
54
+ sig { params(x: Integer, y: Integer).returns(Integer) }
55
+ def index_of(x, y)
56
+ y * @width + x
57
+ end
58
+
59
+ # Convert linear index to (x, y)
60
+ sig { params(index: Integer).returns([Integer, Integer]) }
61
+ def pos_of(index)
62
+ [index % @width, index / @width]
63
+ end
64
+
65
+ # Check if coordinates are within bounds
66
+ sig { params(x: Integer, y: Integer).returns(T::Boolean) }
67
+ def in_bounds?(x, y)
68
+ x >= 0 && x < @width && y >= 0 && y < @height
69
+ end
70
+
71
+ # Write a string at position with optional styling
72
+ sig do
73
+ params(
74
+ x: Integer,
75
+ y: Integer,
76
+ text: String,
77
+ fg: Color::AnyColor,
78
+ bg: Color::AnyColor,
79
+ modifiers: T::Set[Modifier]
80
+ ).void
81
+ end
82
+ def put_string(x, y, text, fg: Color::Named::Reset, bg: Color::Named::Reset, modifiers: Set.new)
83
+ return unless in_bounds?(x, y)
84
+
85
+ col = x
86
+ text.each_grapheme_cluster do |grapheme|
87
+ break unless col < @width
88
+
89
+ cell = Cell.new(symbol: grapheme, fg: fg, bg: bg, modifiers: modifiers)
90
+ set(col, y, cell)
91
+
92
+ # Handle wide characters
93
+ char_width = cell.width
94
+ col += 1
95
+
96
+ # Mark continuation cells for wide chars
97
+ if char_width > 1 && col < @width
98
+ cont_cell = Cell.new(symbol: "", fg: fg, bg: bg, modifiers: modifiers)
99
+ set(col, y, cont_cell)
100
+ col += 1
101
+ end
102
+ end
103
+ end
104
+
105
+ # Clear buffer to empty cells
106
+ sig { void }
107
+ def clear
108
+ @cells = Array.new(@width * @height) { Cell::EMPTY }
109
+ end
110
+
111
+ # Resize buffer, preserving content where possible
112
+ sig { params(new_width: Integer, new_height: Integer).void }
113
+ def resize(new_width, new_height)
114
+ return if new_width == @width && new_height == @height
115
+
116
+ new_cells = Array.new(new_width * new_height) { Cell::EMPTY }
117
+
118
+ # Copy existing content
119
+ [height, new_height].min.times do |y|
120
+ [width, new_width].min.times do |x|
121
+ old_idx = y * @width + x
122
+ new_idx = y * new_width + x
123
+ new_cells[new_idx] = T.must(@cells[old_idx])
124
+ end
125
+ end
126
+
127
+ @width = new_width
128
+ @height = new_height
129
+ @cells = new_cells
130
+ end
131
+
132
+ # Compute diff between this buffer (previous) and another (current).
133
+ # Returns array of [x, y, cell] for cells that changed.
134
+ #
135
+ # This implements Ratatui's diffing algorithm with multi-width char handling.
136
+ # Optimized hot path - avoids method calls and type checks in inner loop.
137
+ sig { params(other: Buffer).returns(T::Array[[Integer, Integer, Cell]]) }
138
+ def diff(other)
139
+ raise ArgumentError, "Buffer size mismatch" unless @width == other.width && @height == other.height
140
+
141
+ updates = []
142
+ invalidated = 0 # Cells invalidated by previous wide char changing
143
+ to_skip = 0 # Cells to skip (continuation of current wide char)
144
+
145
+ # Cache for inner loop
146
+ prev_cells = @cells
147
+ curr_cells = other.cells
148
+ width = @width
149
+ total = prev_cells.length
150
+
151
+ x = 0
152
+ y = 0
153
+ i = 0
154
+
155
+ while i < total
156
+ prev_cell = prev_cells[i]
157
+ curr_cell = curr_cells[i]
158
+
159
+ # Fast path: check if we should emit this cell
160
+ # Skip if: marked to skip, is continuation cell, or unchanged and not invalidated
161
+ unless curr_cell.skip || to_skip > 0
162
+ # Inline visually_equal? check for speed
163
+ prev_sym = prev_cell.symbol
164
+ curr_sym = curr_cell.symbol
165
+ prev_sym = " " if prev_sym.empty?
166
+ curr_sym = " " if curr_sym.empty?
167
+
168
+ changed = invalidated > 0 ||
169
+ prev_sym != curr_sym ||
170
+ prev_cell.fg != curr_cell.fg ||
171
+ prev_cell.bg != curr_cell.bg ||
172
+ prev_cell.modifiers != curr_cell.modifiers
173
+
174
+ updates << [x, y, curr_cell] if changed
175
+ end
176
+
177
+ # Calculate widths for wide char tracking
178
+ # Fast path: ASCII chars (ord < 128) always have width 1
179
+ curr_sym = curr_cell.symbol
180
+ prev_sym = prev_cell.symbol
181
+ curr_ord = curr_sym.empty? ? 32 : curr_sym.ord
182
+ prev_ord = prev_sym.empty? ? 32 : prev_sym.ord
183
+
184
+ if curr_ord < 128 && prev_ord < 128
185
+ # ASCII fast path - both width 1, no wide char handling needed
186
+ to_skip = 0
187
+ invalidated = invalidated > 0 ? invalidated - 1 : 0
188
+ else
189
+ # Wide char path - need actual width calculation
190
+ curr_width = curr_cell.width
191
+ prev_width = prev_cell.width
192
+
193
+ # Track cells to skip (continuation of current wide char)
194
+ to_skip = curr_width > 1 ? curr_width - 1 : 0
195
+
196
+ # Track invalidated cells (when a wide char changes width)
197
+ affected = curr_width > prev_width ? curr_width : prev_width
198
+ invalidated = invalidated > affected ? invalidated - 1 : affected - 1
199
+ invalidated = 0 if invalidated < 0
200
+ end
201
+
202
+ # Update position (faster than pos_of)
203
+ x += 1
204
+ if x >= width
205
+ x = 0
206
+ y += 1
207
+ end
208
+
209
+ i += 1
210
+ end
211
+
212
+ updates
213
+ end
214
+
215
+ # Debug: render buffer to string (for testing)
216
+ sig { returns(String) }
217
+ def to_text
218
+ lines = T.let([], T::Array[String])
219
+ @height.times do |y|
220
+ line = +""
221
+ @width.times do |x|
222
+ cell = get(x, y)
223
+ next unless cell
224
+
225
+ sym = cell.normalized_symbol
226
+ line << (sym.empty? ? " " : sym)
227
+ end
228
+ lines << line.rstrip
229
+ end
230
+ lines.join("\n")
231
+ end
232
+
233
+ sig { returns(String) }
234
+ def to_s
235
+ "Buffer(#{@width}x#{@height})"
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,166 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Ratatat
7
+ # Text modifiers as a T::Enum for type safety
8
+ class Modifier < T::Enum
9
+ extend T::Sig
10
+
11
+ enums do
12
+ None = new
13
+ Bold = new
14
+ Dim = new
15
+ Italic = new
16
+ Underline = new
17
+ Blink = new
18
+ Reverse = new
19
+ Hidden = new
20
+ Strikethrough = new
21
+ end
22
+
23
+ # ANSI codes for enabling modifiers
24
+ ENABLE_CODES = T.let({
25
+ Bold => 1,
26
+ Dim => 2,
27
+ Italic => 3,
28
+ Underline => 4,
29
+ Blink => 5,
30
+ Reverse => 7,
31
+ Hidden => 8,
32
+ Strikethrough => 9
33
+ }.freeze, T::Hash[Modifier, Integer])
34
+
35
+ # ANSI codes for disabling modifiers
36
+ DISABLE_CODES = T.let({
37
+ Bold => 22,
38
+ Dim => 22,
39
+ Italic => 23,
40
+ Underline => 24,
41
+ Blink => 25,
42
+ Reverse => 27,
43
+ Hidden => 28,
44
+ Strikethrough => 29
45
+ }.freeze, T::Hash[Modifier, Integer])
46
+
47
+ sig { returns(T.nilable(Integer)) }
48
+ def enable_code
49
+ ENABLE_CODES[self]
50
+ end
51
+
52
+ sig { returns(T.nilable(Integer)) }
53
+ def disable_code
54
+ DISABLE_CODES[self]
55
+ end
56
+ end
57
+
58
+ # A single cell in the terminal buffer.
59
+ # Immutable struct containing symbol, colors, and modifiers.
60
+ class Cell < T::Struct
61
+ extend T::Sig
62
+
63
+ const :symbol, String, default: " "
64
+ const :fg, Color::AnyColor, default: Color::Named::Reset
65
+ const :bg, Color::AnyColor, default: Color::Named::Reset
66
+ const :modifiers, T::Set[Modifier], default: Set.new
67
+ const :skip, T::Boolean, default: false
68
+
69
+ # Empty cell constant
70
+ EMPTY = T.let(new, Cell)
71
+
72
+ # Display width of this cell (1 for most chars, 2 for CJK/emoji)
73
+ # Cached for performance
74
+ sig { returns(Integer) }
75
+ def width
76
+ @_width ||= compute_width
77
+ end
78
+
79
+ private
80
+
81
+ sig { returns(Integer) }
82
+ def compute_width
83
+ return 1 if symbol.empty?
84
+
85
+ # Fast path for ASCII
86
+ ord = symbol.ord
87
+ return 1 if ord < 128
88
+
89
+ # Use unicode-display_width if available
90
+ if defined?(Unicode::DisplayWidth)
91
+ Unicode::DisplayWidth.of(symbol)
92
+ else
93
+ wide_char?(ord) ? 2 : 1
94
+ end
95
+ end
96
+
97
+ public
98
+
99
+ # Normalize symbol for comparison (empty -> space)
100
+ sig { returns(String) }
101
+ def normalized_symbol
102
+ symbol.empty? ? " " : symbol
103
+ end
104
+
105
+ # Check if cell has a modifier
106
+ sig { params(mod: Modifier).returns(T::Boolean) }
107
+ def has_modifier?(mod)
108
+ modifiers.include?(mod)
109
+ end
110
+
111
+ sig { returns(T::Boolean) }
112
+ def bold? = has_modifier?(Modifier::Bold)
113
+
114
+ sig { returns(T::Boolean) }
115
+ def italic? = has_modifier?(Modifier::Italic)
116
+
117
+ sig { returns(T::Boolean) }
118
+ def underline? = has_modifier?(Modifier::Underline)
119
+
120
+ sig { returns(T::Boolean) }
121
+ def dim? = has_modifier?(Modifier::Dim)
122
+
123
+ sig { returns(T::Boolean) }
124
+ def reverse? = has_modifier?(Modifier::Reverse)
125
+
126
+ # Create a new cell with updated attributes
127
+ sig { params(attrs: T.untyped).returns(Cell) }
128
+ def with(**attrs)
129
+ Cell.new(
130
+ symbol: attrs.fetch(:symbol, symbol),
131
+ fg: attrs.fetch(:fg, fg),
132
+ bg: attrs.fetch(:bg, bg),
133
+ modifiers: attrs.fetch(:modifiers, modifiers),
134
+ skip: attrs.fetch(:skip, skip)
135
+ )
136
+ end
137
+
138
+ # Visual equality (for diffing)
139
+ sig { params(other: T.untyped).returns(T::Boolean) }
140
+ def visually_equal?(other)
141
+ return false unless other.is_a?(Cell)
142
+
143
+ normalized_symbol == other.normalized_symbol &&
144
+ fg == other.fg &&
145
+ bg == other.bg &&
146
+ modifiers == other.modifiers
147
+ end
148
+
149
+ private
150
+
151
+ # Simple check for wide characters (CJK ranges, emoji)
152
+ sig { params(codepoint: Integer).returns(T::Boolean) }
153
+ def wide_char?(codepoint)
154
+ # CJK Unified Ideographs
155
+ (codepoint >= 0x4E00 && codepoint <= 0x9FFF) ||
156
+ # CJK Extension A
157
+ (codepoint >= 0x3400 && codepoint <= 0x4DBF) ||
158
+ # Hangul Syllables
159
+ (codepoint >= 0xAC00 && codepoint <= 0xD7AF) ||
160
+ # Fullwidth Forms
161
+ (codepoint >= 0xFF00 && codepoint <= 0xFFEF) ||
162
+ # Emoji
163
+ (codepoint >= 0x1F300 && codepoint <= 0x1F9FF)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,191 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Ratatat
7
+ # Color types for terminal cells.
8
+ # Supports named ANSI colors, 256-color palette, and true color (RGB).
9
+ module Color
10
+ extend T::Sig
11
+
12
+ # Union type for any color
13
+ AnyColor = T.type_alias { T.any(Named, Indexed, Rgb) }
14
+
15
+ # Named ANSI colors (16 standard colors + reset)
16
+ class Named < T::Enum
17
+ extend T::Sig
18
+
19
+ enums do
20
+ # Reset to terminal default
21
+ Reset = new(:reset)
22
+
23
+ # Standard 8 colors (codes 0-7)
24
+ Black = new(:black)
25
+ Red = new(:red)
26
+ Green = new(:green)
27
+ Yellow = new(:yellow)
28
+ Blue = new(:blue)
29
+ Magenta = new(:magenta)
30
+ Cyan = new(:cyan)
31
+ White = new(:white)
32
+
33
+ # Bright variants (codes 8-15)
34
+ BrightBlack = new(:bright_black)
35
+ BrightRed = new(:bright_red)
36
+ BrightGreen = new(:bright_green)
37
+ BrightYellow = new(:bright_yellow)
38
+ BrightBlue = new(:bright_blue)
39
+ BrightMagenta = new(:bright_magenta)
40
+ BrightCyan = new(:bright_cyan)
41
+ BrightWhite = new(:bright_white)
42
+ end
43
+
44
+ # ANSI foreground code lookup
45
+ FG_CODES = T.let({
46
+ reset: 39,
47
+ black: 30, red: 31, green: 32, yellow: 33,
48
+ blue: 34, magenta: 35, cyan: 36, white: 37,
49
+ bright_black: 90, bright_red: 91, bright_green: 92, bright_yellow: 93,
50
+ bright_blue: 94, bright_magenta: 95, bright_cyan: 96, bright_white: 97
51
+ }.freeze, T::Hash[Symbol, Integer])
52
+
53
+ # ANSI background code lookup
54
+ BG_CODES = T.let({
55
+ reset: 49,
56
+ black: 40, red: 41, green: 42, yellow: 43,
57
+ blue: 44, magenta: 45, cyan: 46, white: 47,
58
+ bright_black: 100, bright_red: 101, bright_green: 102, bright_yellow: 103,
59
+ bright_blue: 104, bright_magenta: 105, bright_cyan: 106, bright_white: 107
60
+ }.freeze, T::Hash[Symbol, Integer])
61
+
62
+ sig { returns(Integer) }
63
+ def fg_code
64
+ T.must(FG_CODES[serialize])
65
+ end
66
+
67
+ sig { returns(Integer) }
68
+ def bg_code
69
+ T.must(BG_CODES[serialize])
70
+ end
71
+ end
72
+
73
+ # 256-color palette (0-255)
74
+ class Indexed
75
+ extend T::Sig
76
+
77
+ sig { returns(Integer) }
78
+ attr_reader :index
79
+
80
+ sig { params(index: Integer).void }
81
+ def initialize(index:)
82
+ raise ArgumentError, "Color index must be 0-255" unless index.between?(0, 255)
83
+
84
+ @index = index
85
+ end
86
+
87
+ sig { params(other: T.untyped).returns(T::Boolean) }
88
+ def ==(other)
89
+ other.is_a?(Indexed) && @index == other.index
90
+ end
91
+ alias eql? ==
92
+
93
+ sig { returns(Integer) }
94
+ def hash
95
+ @index.hash
96
+ end
97
+
98
+ sig { returns(String) }
99
+ def fg_code
100
+ "38;5;#{index}"
101
+ end
102
+
103
+ sig { returns(String) }
104
+ def bg_code
105
+ "48;5;#{index}"
106
+ end
107
+ end
108
+
109
+ # True color (24-bit RGB)
110
+ class Rgb
111
+ extend T::Sig
112
+
113
+ sig { returns(Integer) }
114
+ attr_reader :r, :g, :b
115
+
116
+ sig { params(r: Integer, g: Integer, b: Integer).void }
117
+ def initialize(r:, g:, b:)
118
+ raise ArgumentError, "Red must be 0-255" unless r.between?(0, 255)
119
+ raise ArgumentError, "Green must be 0-255" unless g.between?(0, 255)
120
+ raise ArgumentError, "Blue must be 0-255" unless b.between?(0, 255)
121
+
122
+ @r = r
123
+ @g = g
124
+ @b = b
125
+ end
126
+
127
+ sig { params(other: T.untyped).returns(T::Boolean) }
128
+ def ==(other)
129
+ other.is_a?(Rgb) && @r == other.r && @g == other.g && @b == other.b
130
+ end
131
+ alias eql? ==
132
+
133
+ sig { returns(Integer) }
134
+ def hash
135
+ [@r, @g, @b].hash
136
+ end
137
+
138
+ sig { returns(String) }
139
+ def fg_code
140
+ "38;2;#{r};#{g};#{b}"
141
+ end
142
+
143
+ sig { returns(String) }
144
+ def bg_code
145
+ "48;2;#{r};#{g};#{b}"
146
+ end
147
+ end
148
+
149
+ class << self
150
+ extend T::Sig
151
+
152
+ # Create RGB color from hex string
153
+ # Accepts: "#ff8000", "ff8000", "#f80", "f80"
154
+ sig { params(str: String).returns(Rgb) }
155
+ def hex(str)
156
+ str = str.delete_prefix("#")
157
+ r, g, b = case str.length
158
+ when 3
159
+ [str[0] * 2, str[1] * 2, str[2] * 2]
160
+ when 6
161
+ [str[0, 2], str[2, 2], str[4, 2]]
162
+ else
163
+ raise ArgumentError, "Invalid hex color: #{str}"
164
+ end
165
+ Rgb.new(r: T.must(r).to_i(16), g: T.must(g).to_i(16), b: T.must(b).to_i(16))
166
+ end
167
+
168
+ # Generate ANSI escape code for foreground
169
+ sig { params(color: AnyColor).returns(String) }
170
+ def fg_ansi(color)
171
+ case color
172
+ when Named then color.fg_code.to_s
173
+ when Indexed then color.fg_code
174
+ when Rgb then color.fg_code
175
+ else T.absurd(color)
176
+ end
177
+ end
178
+
179
+ # Generate ANSI escape code for background
180
+ sig { params(color: AnyColor).returns(String) }
181
+ def bg_ansi(color)
182
+ case color
183
+ when Named then color.bg_code.to_s
184
+ when Indexed then color.bg_code
185
+ when Rgb then color.bg_code
186
+ else T.absurd(color)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end