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.
- checksums.yaml +7 -0
- data/README.md +201 -0
- data/examples/log_tailer.rb +460 -0
- data/lib/ratatat/ansi_backend.rb +175 -0
- data/lib/ratatat/app.rb +342 -0
- data/lib/ratatat/binding.rb +74 -0
- data/lib/ratatat/buffer.rb +238 -0
- data/lib/ratatat/cell.rb +166 -0
- data/lib/ratatat/color.rb +191 -0
- data/lib/ratatat/css_parser.rb +192 -0
- data/lib/ratatat/dom_query.rb +124 -0
- data/lib/ratatat/driver.rb +200 -0
- data/lib/ratatat/input.rb +208 -0
- data/lib/ratatat/message.rb +147 -0
- data/lib/ratatat/reactive.rb +79 -0
- data/lib/ratatat/styles.rb +293 -0
- data/lib/ratatat/terminal.rb +168 -0
- data/lib/ratatat/version.rb +3 -0
- data/lib/ratatat/widget.rb +337 -0
- data/lib/ratatat/widgets/button.rb +43 -0
- data/lib/ratatat/widgets/checkbox.rb +68 -0
- data/lib/ratatat/widgets/container.rb +50 -0
- data/lib/ratatat/widgets/data_table.rb +123 -0
- data/lib/ratatat/widgets/grid.rb +40 -0
- data/lib/ratatat/widgets/horizontal.rb +52 -0
- data/lib/ratatat/widgets/log.rb +97 -0
- data/lib/ratatat/widgets/modal.rb +161 -0
- data/lib/ratatat/widgets/progress_bar.rb +80 -0
- data/lib/ratatat/widgets/radio_set.rb +91 -0
- data/lib/ratatat/widgets/scrollable_container.rb +88 -0
- data/lib/ratatat/widgets/select.rb +100 -0
- data/lib/ratatat/widgets/sparkline.rb +61 -0
- data/lib/ratatat/widgets/spinner.rb +93 -0
- data/lib/ratatat/widgets/static.rb +23 -0
- data/lib/ratatat/widgets/tabbed_content.rb +114 -0
- data/lib/ratatat/widgets/text_area.rb +183 -0
- data/lib/ratatat/widgets/text_input.rb +143 -0
- data/lib/ratatat/widgets/toast.rb +55 -0
- data/lib/ratatat/widgets/tooltip.rb +66 -0
- data/lib/ratatat/widgets/tree.rb +172 -0
- data/lib/ratatat/widgets/vertical.rb +52 -0
- data/lib/ratatat.rb +51 -0
- 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
|