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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd58d956c1baf1f1eed8df8f58bf527bfc8798cbc07fd8f9ce6a6f6d622a6061
4
+ data.tar.gz: 9807963133e4e2dfde9474db12ae220aa5d23a931107ab6f1a7eae8ad960e052
5
+ SHA512:
6
+ metadata.gz: 5fcc913570f03e35870f648372fbcbf652b4c6eed204e93aa755ec17748acfb645db719fef1633b3557703ba64207a8ceb7b4eaec6493ca4f1e2ed36f6c301ca
7
+ data.tar.gz: 3d03737ef1ad6ec6ed72c5db6194966689f3b502832182cfe4ea370e37e68478994d244dec479b359caecdfe2d15bb0f9ac66a5334c7e6228df6c897749cb5ad
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # Ratatat
2
+
3
+ > A pure Ruby TUI framework inspired by Python's Textual. Build terminal user interfaces with a reactive, component-based architecture. No FFI required.
4
+
5
+ Ratatat provides a Textual-like development experience in Ruby: reactive properties, CSS-like styling, message-driven communication, and a rich widget library. It uses double-buffered rendering with cell-based diffing for flicker-free 60+ fps output.
6
+
7
+ ## Core Concepts
8
+
9
+ **Architecture**: "Attributes Down, Messages Up"
10
+ - Parents set child attributes or call child methods directly
11
+ - Children communicate to parents ONLY via messages (bubbling)
12
+ - Siblings communicate through parent intermediary
13
+
14
+ **Reactive Properties**: Declare with `reactive :name, default: value, repaint: true`. Changes automatically trigger re-renders.
15
+
16
+ **Widget Tree**: Apps compose widgets via `compose` method. Query with CSS-like selectors: `query("#id")`, `query(".class")`, `query(WidgetClass)`.
17
+
18
+ **Message System**: Key presses, focus changes, and custom events bubble up the tree. Handle with `on_<message_type>` methods.
19
+
20
+ ## Quick Start
21
+
22
+ ```ruby
23
+ require "ratatat"
24
+
25
+ class MyApp < Ratatat::App
26
+ def compose
27
+ [
28
+ Ratatat::Vertical.new.tap do |v|
29
+ v.mount(
30
+ Ratatat::Static.new("Hello, World!"),
31
+ Ratatat::Button.new("Click me", id: "btn")
32
+ )
33
+ end
34
+ ]
35
+ end
36
+
37
+ def on_button_pressed(message)
38
+ query_one("#btn").label = "Clicked!"
39
+ end
40
+ end
41
+
42
+ MyApp.new.run
43
+ ```
44
+
45
+ ## Project Structure
46
+
47
+ - [lib/ratatat.rb](lib/ratatat.rb): Main entry point, requires all components
48
+ - [lib/ratatat/app.rb](lib/ratatat/app.rb): Application base class with event loop
49
+ - [lib/ratatat/widget.rb](lib/ratatat/widget.rb): Base widget class
50
+ - [lib/ratatat/buffer.rb](lib/ratatat/buffer.rb): Double-buffered rendering with diffing
51
+ - [lib/ratatat/cell.rb](lib/ratatat/cell.rb): Terminal cell (symbol, colors, modifiers)
52
+ - [lib/ratatat/terminal.rb](lib/ratatat/terminal.rb): Terminal abstraction layer
53
+ - [lib/ratatat/message.rb](lib/ratatat/message.rb): Message types (Key, Focus, Blur, Quit)
54
+ - [lib/ratatat/reactive.rb](lib/ratatat/reactive.rb): Reactive property system
55
+ - [lib/ratatat/dom_query.rb](lib/ratatat/dom_query.rb): jQuery-like chainable queries
56
+ - [lib/ratatat/styles.rb](lib/ratatat/styles.rb): Inline styling system
57
+ - [lib/ratatat/css_parser.rb](lib/ratatat/css_parser.rb): CSS stylesheet parser
58
+
59
+ ## Widgets
60
+
61
+ Layout:
62
+ - [Vertical](lib/ratatat/widgets/vertical.rb): Stack children vertically with optional ratios
63
+ - [Horizontal](lib/ratatat/widgets/horizontal.rb): Stack children horizontally with optional ratios
64
+ - [Grid](lib/ratatat/widgets/grid.rb): CSS Grid-like layout
65
+ - [Container](lib/ratatat/widgets/container.rb): Generic container
66
+ - [ScrollableContainer](lib/ratatat/widgets/scrollable_container.rb): Scrollable viewport
67
+
68
+ Input:
69
+ - [Button](lib/ratatat/widgets/button.rb): Clickable button with variants
70
+ - [TextInput](lib/ratatat/widgets/text_input.rb): Single-line text input
71
+ - [TextArea](lib/ratatat/widgets/text_area.rb): Multi-line text editor
72
+ - [Checkbox](lib/ratatat/widgets/checkbox.rb): Toggle checkbox
73
+ - [RadioSet](lib/ratatat/widgets/radio_set.rb): Radio button group
74
+ - [Select](lib/ratatat/widgets/select.rb): Dropdown selection
75
+
76
+ Display:
77
+ - [Static](lib/ratatat/widgets/static.rb): Static text display
78
+ - [ProgressBar](lib/ratatat/widgets/progress_bar.rb): Progress indicator
79
+ - [Sparkline](lib/ratatat/widgets/sparkline.rb): Inline charts
80
+ - [Spinner](lib/ratatat/widgets/spinner.rb): Animated loading indicator
81
+ - [DataTable](lib/ratatat/widgets/data_table.rb): Tabular data display
82
+ - [Tree](lib/ratatat/widgets/tree.rb): Hierarchical tree view
83
+ - [Log](lib/ratatat/widgets/log.rb): Scrolling log output
84
+
85
+ Overlay:
86
+ - [Modal](lib/ratatat/widgets/modal.rb): Modal dialogs
87
+ - [Toast](lib/ratatat/widgets/toast.rb): Notification toasts
88
+ - [Tooltip](lib/ratatat/widgets/tooltip.rb): Hover tooltips
89
+ - [TabbedContent](lib/ratatat/widgets/tabbed_content.rb): Tabbed panels
90
+
91
+ ## Examples
92
+
93
+ - [examples/log_tailer.rb](examples/log_tailer.rb): Two-pane log viewer with filtering
94
+
95
+ ## Documentation
96
+
97
+ - [docs/textual-features-plan.md](docs/textual-features-plan.md): Full feature implementation plan and status
98
+
99
+ ## API Patterns
100
+
101
+ ### Creating Widgets
102
+
103
+ ```ruby
104
+ class MyWidget < Ratatat::Widget
105
+ CAN_FOCUS = true # Enable focus for this widget
106
+
107
+ reactive :count, default: 0, repaint: true
108
+
109
+ def render(buffer, x:, y:, width:, height:)
110
+ buffer.put_string(x, y, "Count: #{count}")
111
+ end
112
+
113
+ def on_key(message)
114
+ case message.key
115
+ when "up" then self.count += 1
116
+ when "down" then self.count -= 1
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Key Bindings
123
+
124
+ ```ruby
125
+ class MyApp < Ratatat::App
126
+ BINDINGS = [
127
+ Ratatat::Binding.new("q", "quit", "Quit application"),
128
+ Ratatat::Binding.new("r", "refresh", "Refresh data"),
129
+ ]
130
+
131
+ def action_quit = exit
132
+ def action_refresh = reload_data
133
+ end
134
+ ```
135
+
136
+ ### Async Operations
137
+
138
+ ```ruby
139
+ # Timers
140
+ set_timer(2.0) { show_message("2 seconds passed") }
141
+ set_interval(1.0) { update_clock }
142
+
143
+ # Background workers
144
+ run_worker(:fetch_data) { HTTP.get(url) }
145
+
146
+ def on_worker_done(message)
147
+ return unless message.name == :fetch_data
148
+ self.data = message.result
149
+ end
150
+ ```
151
+
152
+ ### Querying Widgets
153
+
154
+ ```ruby
155
+ query("#my-id") # By ID
156
+ query(".my-class") # By class
157
+ query(Button) # By type
158
+ query_one("#unique") # Single result
159
+ query(".items").remove # Bulk operations
160
+ ```
161
+
162
+ ## Performance
163
+
164
+ Buffer diffing optimized for 60+ fps:
165
+ - 80x24 terminal: 0.6ms diff time
166
+ - 180x48 terminal: 2.6ms diff time
167
+ - Rendering: 1600+ fps achievable
168
+
169
+ ## Optional
170
+
171
+ ### Development
172
+
173
+ ```bash
174
+ bundle install
175
+ bundle exec rspec # Run tests (419 specs)
176
+ bundle exec ruby examples/log_tailer.rb # Run example
177
+ ```
178
+
179
+ ### Colors
180
+
181
+ ```ruby
182
+ # Named colors
183
+ Ratatat::Color::Named::Red
184
+ Ratatat::Color::Named::BrightBlue
185
+
186
+ # 256-color palette
187
+ Ratatat::Color::Indexed.new(196)
188
+
189
+ # True color (RGB)
190
+ Ratatat::Color::Rgb.new(255, 128, 0)
191
+ ```
192
+
193
+ ### Modifiers
194
+
195
+ ```ruby
196
+ modifiers = Set.new([
197
+ Ratatat::Modifier::Bold,
198
+ Ratatat::Modifier::Italic,
199
+ Ratatat::Modifier::Underline,
200
+ ])
201
+ ```
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Log Tailer Example
5
+ # A two-pane log viewer demonstrating the Ratatat framework
6
+ #
7
+ # Usage:
8
+ # ruby examples/log_tailer.rb # Demo mode with sample data
9
+ # ruby examples/log_tailer.rb /var/log/system.log # Tail a file
10
+ # tail -f /var/log/system.log | ruby examples/log_tailer.rb # Pipe input
11
+ #
12
+ # Keys:
13
+ # j/k, Up/Down - Navigate lines
14
+ # g/G - Go to first/last line
15
+ # f - Toggle follow mode
16
+ # / - Filter lines (type pattern, Enter to apply, Esc to cancel)
17
+ # c - Clear filter
18
+ # q, Ctrl+C - Quit
19
+
20
+ require_relative "../lib/ratatat"
21
+
22
+ # Custom widget for displaying log lines with selection
23
+ class LogList < Ratatat::Widget
24
+ extend T::Sig
25
+
26
+ CAN_FOCUS = true
27
+
28
+ class LineSelected < Ratatat::Message
29
+ extend T::Sig
30
+ sig { returns(Integer) }
31
+ attr_reader :index
32
+ sig { returns(String) }
33
+ attr_reader :line
34
+ sig { params(sender: Ratatat::Widget, index: Integer, line: String).void }
35
+ def initialize(sender:, index:, line:)
36
+ super(sender: sender)
37
+ @index = index
38
+ @line = line
39
+ end
40
+ end
41
+
42
+ sig { returns(T::Array[String]) }
43
+ attr_reader :lines
44
+
45
+ sig { returns(T.nilable(String)) }
46
+ attr_reader :filter
47
+
48
+ reactive :cursor, default: 0, repaint: true
49
+ reactive :scroll_offset, default: 0, repaint: true
50
+
51
+ sig { params(id: T.nilable(String), classes: T::Array[String]).void }
52
+ def initialize(id: nil, classes: [])
53
+ super(id: id, classes: classes)
54
+ @lines = T.let([], T::Array[String])
55
+ @all_lines = T.let([], T::Array[String])
56
+ @filter = T.let(nil, T.nilable(String))
57
+ @cursor = 0
58
+ @scroll_offset = 0
59
+ @view_height = T.let(10, Integer)
60
+ end
61
+
62
+ sig { params(line: String).void }
63
+ def add_line(line)
64
+ @all_lines << line
65
+ apply_filter
66
+ refresh
67
+ end
68
+
69
+ sig { params(pattern: T.nilable(String)).void }
70
+ def set_filter(pattern)
71
+ @filter = pattern.nil? || pattern.empty? ? nil : pattern
72
+ apply_filter
73
+ @cursor = [cursor, @lines.length - 1].min
74
+ @cursor = 0 if @cursor.negative?
75
+ refresh
76
+ end
77
+
78
+ sig { returns(T.nilable(String)) }
79
+ def selected_line
80
+ @lines[cursor]
81
+ end
82
+
83
+ sig { void }
84
+ def go_to_end
85
+ @cursor = [@lines.length - 1, 0].max
86
+ ensure_visible
87
+ end
88
+
89
+ sig { params(message: Ratatat::Key).void }
90
+ def on_key(message)
91
+ case message.key
92
+ when "up", "k"
93
+ move_cursor(-1)
94
+ message.stop
95
+ when "down", "j"
96
+ move_cursor(1)
97
+ message.stop
98
+ when "page_up"
99
+ move_cursor(-@view_height)
100
+ message.stop
101
+ when "page_down"
102
+ move_cursor(@view_height)
103
+ message.stop
104
+ when "g"
105
+ @cursor = 0
106
+ ensure_visible
107
+ emit_selection
108
+ message.stop
109
+ when "G"
110
+ go_to_end
111
+ emit_selection
112
+ message.stop
113
+ end
114
+ end
115
+
116
+ sig { params(buffer: Ratatat::Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
117
+ def render(buffer, x:, y:, width:, height:)
118
+ @view_height = height
119
+
120
+ visible_lines = @lines[@scroll_offset, height] || []
121
+ visible_lines.each_with_index do |line, i|
122
+ actual_index = @scroll_offset + i
123
+ is_selected = actual_index == cursor
124
+
125
+ # Highlight selected line
126
+ if is_selected
127
+ (0...width).each { |col| buffer.set(x + col, y + i, Ratatat::Cell.new(symbol: " ", bg: Ratatat::Color::Named::Blue)) }
128
+ buffer.put_string(x, y + i, line[0, width] || "", bg: Ratatat::Color::Named::Blue, fg: Ratatat::Color::Named::White)
129
+ else
130
+ buffer.put_string(x, y + i, line[0, width] || "")
131
+ end
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ sig { params(delta: Integer).void }
138
+ def move_cursor(delta)
139
+ return if @lines.empty?
140
+
141
+ @cursor = (cursor + delta).clamp(0, @lines.length - 1)
142
+ ensure_visible
143
+ emit_selection
144
+ end
145
+
146
+ sig { void }
147
+ def ensure_visible
148
+ if cursor < @scroll_offset
149
+ @scroll_offset = cursor
150
+ elsif cursor >= @scroll_offset + @view_height
151
+ @scroll_offset = cursor - @view_height + 1
152
+ end
153
+ end
154
+
155
+ sig { void }
156
+ def emit_selection
157
+ line = selected_line
158
+ parent&.dispatch(LineSelected.new(sender: self, index: cursor, line: line || ""))
159
+ end
160
+
161
+ sig { void }
162
+ def apply_filter
163
+ @lines = if @filter
164
+ @all_lines.select { |l| l.include?(@filter) }
165
+ else
166
+ @all_lines.dup
167
+ end
168
+ end
169
+ end
170
+
171
+ # Detail pane showing selected line info
172
+ class DetailPane < Ratatat::Widget
173
+ extend T::Sig
174
+
175
+ reactive :content, default: "", repaint: true
176
+
177
+ sig { params(id: T.nilable(String), classes: T::Array[String]).void }
178
+ def initialize(id: nil, classes: [])
179
+ super(id: id, classes: classes)
180
+ @content = ""
181
+ end
182
+
183
+ sig { params(line: String, index: Integer).void }
184
+ def show_line(line, index)
185
+ @content = build_detail(line, index)
186
+ end
187
+
188
+ sig { params(buffer: Ratatat::Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
189
+ def render(buffer, x:, y:, width:, height:)
190
+ buffer.put_string(x, y, "─" * width)
191
+ buffer.put_string(x, y, "┤ Details ├")
192
+
193
+ lines = @content.split("\n")
194
+ lines.each_with_index do |line, i|
195
+ break if i + 1 >= height
196
+ buffer.put_string(x, y + i + 1, line[0, width] || "")
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ sig { params(line: String, index: Integer).returns(String) }
203
+ def build_detail(line, index)
204
+ return "No line selected" if line.empty?
205
+
206
+ parts = []
207
+ parts << "Line ##{index + 1}"
208
+ parts << "Length: #{line.length} chars"
209
+ parts << ""
210
+
211
+ # Try to parse as JSON
212
+ if line.include?("{") && line.include?("}")
213
+ begin
214
+ require "json"
215
+ json_match = line.match(/\{.*\}/m)
216
+ if json_match
217
+ parsed = JSON.parse(json_match[0])
218
+ parts << "JSON detected:"
219
+ parsed.each { |k, v| parts << " #{k}: #{v}" }
220
+ end
221
+ rescue JSON::ParserError
222
+ # Not valid JSON
223
+ end
224
+ end
225
+
226
+ parts << ""
227
+ parts << "Raw:"
228
+ parts << line[0, 200]
229
+
230
+ parts.join("\n")
231
+ end
232
+ end
233
+
234
+ # Status bar
235
+ class StatusBar < Ratatat::Widget
236
+ extend T::Sig
237
+
238
+ reactive :total_lines, default: 0, repaint: true
239
+ reactive :filtered_lines, default: 0, repaint: true
240
+ reactive :cursor_pos, default: 0, repaint: true
241
+ reactive :follow_mode, default: true, repaint: true
242
+ reactive :filter_text, default: "", repaint: true
243
+ reactive :mode, default: :normal, repaint: true
244
+
245
+ sig { params(id: T.nilable(String), classes: T::Array[String]).void }
246
+ def initialize(id: nil, classes: [])
247
+ super(id: id, classes: classes)
248
+ @total_lines = 0
249
+ @filtered_lines = 0
250
+ @cursor_pos = 0
251
+ @follow_mode = true
252
+ @filter_text = ""
253
+ @mode = :normal
254
+ @filter_input = T.let("", String)
255
+ end
256
+
257
+ sig { returns(String) }
258
+ attr_accessor :filter_input
259
+
260
+ sig { params(buffer: Ratatat::Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
261
+ def render(buffer, x:, y:, width:, height:)
262
+ # Background
263
+ (0...width).each { |col| buffer.set(x + col, y, Ratatat::Cell.new(symbol: " ", bg: Ratatat::Color::Named::Blue)) }
264
+
265
+ text = case @mode
266
+ when :filter
267
+ "Filter: #{@filter_input}█ (Enter to apply, Esc to cancel)"
268
+ else
269
+ parts = []
270
+ parts << "Lines: #{@cursor_pos + 1}/#{@filtered_lines}"
271
+ parts << "(#{@total_lines} total)" if @filter_text && !@filter_text.empty?
272
+ parts << "│ Follow: #{@follow_mode ? 'ON' : 'OFF'}"
273
+ parts << "│ Filter: '#{@filter_text}'" if @filter_text && !@filter_text.empty?
274
+ parts << "│ j/k:nav f:follow /:filter q:quit"
275
+ parts.join(" ")
276
+ end
277
+
278
+ buffer.put_string(x, y, text[0, width] || "", bg: Ratatat::Color::Named::Blue, fg: Ratatat::Color::Named::White)
279
+ end
280
+ end
281
+
282
+ # Main application
283
+ class LogTailer < Ratatat::App
284
+ extend T::Sig
285
+
286
+ BINDINGS = T.let([
287
+ Ratatat::Binding.new("q", "quit", "Quit"),
288
+ Ratatat::Binding.new("f", "toggle_follow", "Toggle follow"),
289
+ Ratatat::Binding.new("/", "start_filter", "Filter"),
290
+ Ratatat::Binding.new("c", "clear_filter", "Clear filter"),
291
+ ], T::Array[Ratatat::Binding])
292
+
293
+ sig { params(source: T.any(IO, StringIO)).void }
294
+ def initialize(source:)
295
+ super()
296
+ @source = source
297
+ @follow_mode = T.let(true, T::Boolean)
298
+ @reader_thread = T.let(nil, T.nilable(Thread))
299
+ end
300
+
301
+ sig { override.returns(T::Array[Ratatat::Widget]) }
302
+ def compose
303
+ [
304
+ Ratatat::Vertical.new(id: "main", ratios: [0.6, 0.39, 0.01]).tap do |v|
305
+ v.mount(
306
+ LogList.new(id: "log"),
307
+ DetailPane.new(id: "detail"),
308
+ StatusBar.new(id: "status")
309
+ )
310
+ end
311
+ ]
312
+ end
313
+
314
+ sig { void }
315
+ def on_mount
316
+ # Start reader thread
317
+ @reader_thread = Thread.new { read_input }
318
+
319
+ # Update timer
320
+ set_interval(0.1) { update_display }
321
+
322
+ # Focus the log list
323
+ query_one("#log")&.focus
324
+ end
325
+
326
+ sig { params(message: LogList::LineSelected).void }
327
+ def on_loglist_lineselected(message)
328
+ detail = T.cast(query_one("#detail"), T.nilable(DetailPane))
329
+ detail&.show_line(message.line, message.index)
330
+
331
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
332
+ status&.cursor_pos = message.index if status
333
+ end
334
+
335
+ sig { void }
336
+ def action_quit
337
+ self.exit
338
+ end
339
+
340
+ sig { void }
341
+ def action_toggle_follow
342
+ @follow_mode = !@follow_mode
343
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
344
+ status&.follow_mode = @follow_mode if status
345
+ end
346
+
347
+ sig { void }
348
+ def action_start_filter
349
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
350
+ return unless status
351
+
352
+ status.mode = :filter
353
+ status.filter_input = ""
354
+ end
355
+
356
+ sig { void }
357
+ def action_clear_filter
358
+ log_list = T.cast(query_one("#log"), T.nilable(LogList))
359
+ log_list&.set_filter(nil)
360
+
361
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
362
+ status&.filter_text = "" if status
363
+ end
364
+
365
+ sig { params(message: Ratatat::Key).void }
366
+ def on_key(message)
367
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
368
+ return unless status&.mode == :filter
369
+
370
+ case message.key
371
+ when "enter"
372
+ apply_filter(status.filter_input)
373
+ status.mode = :normal
374
+ message.stop
375
+ when "escape"
376
+ status.mode = :normal
377
+ message.stop
378
+ when "backspace"
379
+ status.filter_input = status.filter_input[0..-2] || ""
380
+ message.stop
381
+ else
382
+ if message.key.length == 1
383
+ status.filter_input += message.key
384
+ message.stop
385
+ end
386
+ end
387
+ end
388
+
389
+ private
390
+
391
+ sig { void }
392
+ def read_input
393
+ @source.each_line do |line|
394
+ log_list = T.cast(query_one("#log"), T.nilable(LogList))
395
+ log_list&.add_line(line.chomp)
396
+ end
397
+ rescue IOError
398
+ # Stream closed
399
+ end
400
+
401
+ sig { void }
402
+ def update_display
403
+ log_list = T.cast(query_one("#log"), T.nilable(LogList))
404
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
405
+ return unless log_list && status
406
+
407
+ # Follow mode
408
+ if @follow_mode && !log_list.lines.empty?
409
+ log_list.go_to_end
410
+ end
411
+
412
+ # Update status
413
+ status.total_lines = log_list.instance_variable_get(:@all_lines).length
414
+ status.filtered_lines = log_list.lines.length
415
+ status.cursor_pos = log_list.cursor
416
+ end
417
+
418
+ sig { params(pattern: String).void }
419
+ def apply_filter(pattern)
420
+ log_list = T.cast(query_one("#log"), T.nilable(LogList))
421
+ log_list&.set_filter(pattern)
422
+
423
+ status = T.cast(query_one("#status"), T.nilable(StatusBar))
424
+ status&.filter_text = pattern if status
425
+ end
426
+ end
427
+
428
+ # Main entry point
429
+ if __FILE__ == $PROGRAM_NAME
430
+ input_path = ARGV.shift
431
+
432
+ source = if input_path && File.exist?(input_path)
433
+ File.open(input_path, "r")
434
+ elsif !$stdin.tty?
435
+ $stdin
436
+ else
437
+ # Demo mode - generate sample log lines
438
+ require "stringio"
439
+ demo_lines = []
440
+ demo_lines << "[2024-01-15 10:00:00] INFO: Application starting..."
441
+ demo_lines << "[2024-01-15 10:00:01] DEBUG: Loading configuration"
442
+ demo_lines << "[2024-01-15 10:00:02] INFO: Connected to database"
443
+ demo_lines << '[2024-01-15 10:00:03] INFO: Request {"method":"GET","path":"/api/users","status":200}'
444
+ demo_lines << "[2024-01-15 10:00:04] WARN: Slow query detected (2.3s)"
445
+ demo_lines << '[2024-01-15 10:00:05] INFO: Request {"method":"POST","path":"/api/login","status":200}'
446
+ demo_lines << "[2024-01-15 10:00:06] ERROR: Connection timeout to service X"
447
+ demo_lines << "[2024-01-15 10:00:07] INFO: Retrying connection..."
448
+ demo_lines << "[2024-01-15 10:00:08] INFO: Connection restored"
449
+ demo_lines << '[2024-01-15 10:00:09] INFO: Request {"method":"GET","path":"/api/status","status":200}'
450
+ demo_lines << "[2024-01-15 10:00:10] DEBUG: Cache hit ratio: 87%"
451
+ demo_lines << "[2024-01-15 10:00:11] INFO: Background job completed"
452
+ demo_lines << "[2024-01-15 10:00:12] INFO: Metrics exported"
453
+ demo_lines << "[2024-01-15 10:00:13] WARN: High memory usage: 82%"
454
+ demo_lines << "[2024-01-15 10:00:14] INFO: GC completed, freed 256MB"
455
+ StringIO.new(demo_lines.join("\n") + "\n")
456
+ end
457
+
458
+ app = LogTailer.new(source: source)
459
+ app.run
460
+ end