ratatui_ruby 0.1.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/.build.yml +34 -0
- data/.pre-commit-config.yaml +9 -0
- data/.rubocop.yml +8 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +119 -0
- data/CHANGELOG.md +15 -0
- data/CODE_OF_CONDUCT.md +30 -0
- data/CONTRIBUTING.md +40 -0
- data/LICENSE +15 -0
- data/LICENSES/AGPL-3.0-or-later.txt +661 -0
- data/LICENSES/BSD-2-Clause.txt +9 -0
- data/LICENSES/CC-BY-SA-4.0.txt +427 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/MIT.txt +21 -0
- data/README.md +86 -0
- data/REUSE.toml +17 -0
- data/Rakefile +108 -0
- data/docs/application_testing.md +96 -0
- data/docs/contributors/design/ruby_frontend.md +100 -0
- data/docs/contributors/design/rust_backend.md +61 -0
- data/docs/contributors/design.md +11 -0
- data/docs/contributors/index.md +16 -0
- data/docs/images/examples-analytics.rb.png +0 -0
- data/docs/images/examples-box_demo.rb.png +0 -0
- data/docs/images/examples-dashboard.rb.png +0 -0
- data/docs/images/examples-login_form.rb.png +0 -0
- data/docs/images/examples-map_demo.rb.png +0 -0
- data/docs/images/examples-mouse_events.rb.png +0 -0
- data/docs/images/examples-scrollbar_demo.rb.png +0 -0
- data/docs/images/examples-stock_ticker.rb.png +0 -0
- data/docs/images/examples-system_monitor.rb.png +0 -0
- data/docs/index.md +18 -0
- data/docs/quickstart.md +126 -0
- data/examples/analytics.rb +87 -0
- data/examples/box_demo.rb +71 -0
- data/examples/dashboard.rb +72 -0
- data/examples/login_form.rb +114 -0
- data/examples/map_demo.rb +58 -0
- data/examples/mouse_events.rb +95 -0
- data/examples/scrollbar_demo.rb +75 -0
- data/examples/stock_ticker.rb +85 -0
- data/examples/system_monitor.rb +93 -0
- data/examples/test_analytics.rb +65 -0
- data/examples/test_box_demo.rb +38 -0
- data/examples/test_dashboard.rb +38 -0
- data/examples/test_login_form.rb +63 -0
- data/examples/test_map_demo.rb +100 -0
- data/examples/test_stock_ticker.rb +39 -0
- data/examples/test_system_monitor.rb +40 -0
- data/ext/ratatui_ruby/.cargo/config.toml +8 -0
- data/ext/ratatui_ruby/.gitignore +4 -0
- data/ext/ratatui_ruby/Cargo.lock +698 -0
- data/ext/ratatui_ruby/Cargo.toml +16 -0
- data/ext/ratatui_ruby/extconf.rb +12 -0
- data/ext/ratatui_ruby/src/events.rs +279 -0
- data/ext/ratatui_ruby/src/lib.rs +105 -0
- data/ext/ratatui_ruby/src/rendering.rs +31 -0
- data/ext/ratatui_ruby/src/style.rs +149 -0
- data/ext/ratatui_ruby/src/terminal.rs +131 -0
- data/ext/ratatui_ruby/src/widgets/barchart.rs +73 -0
- data/ext/ratatui_ruby/src/widgets/block.rs +12 -0
- data/ext/ratatui_ruby/src/widgets/canvas.rs +146 -0
- data/ext/ratatui_ruby/src/widgets/center.rs +81 -0
- data/ext/ratatui_ruby/src/widgets/cursor.rs +29 -0
- data/ext/ratatui_ruby/src/widgets/gauge.rs +50 -0
- data/ext/ratatui_ruby/src/widgets/layout.rs +82 -0
- data/ext/ratatui_ruby/src/widgets/linechart.rs +154 -0
- data/ext/ratatui_ruby/src/widgets/list.rs +62 -0
- data/ext/ratatui_ruby/src/widgets/mod.rs +18 -0
- data/ext/ratatui_ruby/src/widgets/overlay.rs +20 -0
- data/ext/ratatui_ruby/src/widgets/paragraph.rs +56 -0
- data/ext/ratatui_ruby/src/widgets/scrollbar.rs +68 -0
- data/ext/ratatui_ruby/src/widgets/sparkline.rs +59 -0
- data/ext/ratatui_ruby/src/widgets/table.rs +117 -0
- data/ext/ratatui_ruby/src/widgets/tabs.rs +51 -0
- data/lib/ratatui_ruby/output.rb +7 -0
- data/lib/ratatui_ruby/schema/bar_chart.rb +28 -0
- data/lib/ratatui_ruby/schema/block.rb +23 -0
- data/lib/ratatui_ruby/schema/canvas.rb +62 -0
- data/lib/ratatui_ruby/schema/center.rb +19 -0
- data/lib/ratatui_ruby/schema/constraint.rb +33 -0
- data/lib/ratatui_ruby/schema/cursor.rb +17 -0
- data/lib/ratatui_ruby/schema/gauge.rb +24 -0
- data/lib/ratatui_ruby/schema/layout.rb +22 -0
- data/lib/ratatui_ruby/schema/line_chart.rb +41 -0
- data/lib/ratatui_ruby/schema/list.rb +22 -0
- data/lib/ratatui_ruby/schema/overlay.rb +15 -0
- data/lib/ratatui_ruby/schema/paragraph.rb +37 -0
- data/lib/ratatui_ruby/schema/scrollbar.rb +33 -0
- data/lib/ratatui_ruby/schema/sparkline.rb +24 -0
- data/lib/ratatui_ruby/schema/style.rb +31 -0
- data/lib/ratatui_ruby/schema/table.rb +24 -0
- data/lib/ratatui_ruby/schema/tabs.rb +22 -0
- data/lib/ratatui_ruby/test_helper.rb +75 -0
- data/lib/ratatui_ruby/version.rb +10 -0
- data/lib/ratatui_ruby.rb +87 -0
- data/sig/ratatui_ruby/ratatui_ruby.rbs +16 -0
- data/sig/ratatui_ruby/schema/bar_chart.rbs +14 -0
- data/sig/ratatui_ruby/schema/block.rbs +11 -0
- data/sig/ratatui_ruby/schema/canvas.rbs +62 -0
- data/sig/ratatui_ruby/schema/center.rbs +11 -0
- data/sig/ratatui_ruby/schema/constraint.rbs +13 -0
- data/sig/ratatui_ruby/schema/cursor.rbs +10 -0
- data/sig/ratatui_ruby/schema/gauge.rbs +13 -0
- data/sig/ratatui_ruby/schema/layout.rbs +11 -0
- data/sig/ratatui_ruby/schema/line_chart.rbs +20 -0
- data/sig/ratatui_ruby/schema/list.rbs +11 -0
- data/sig/ratatui_ruby/schema/overlay.rbs +9 -0
- data/sig/ratatui_ruby/schema/paragraph.rbs +11 -0
- data/sig/ratatui_ruby/schema/scrollbar.rbs +20 -0
- data/sig/ratatui_ruby/schema/sparkline.rbs +12 -0
- data/sig/ratatui_ruby/schema/style.rbs +13 -0
- data/sig/ratatui_ruby/schema/table.rbs +13 -0
- data/sig/ratatui_ruby/schema/tabs.rbs +11 -0
- data/sig/ratatui_ruby/test_helper.rbs +11 -0
- data/sig/ratatui_ruby/version.rbs +6 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +196 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
|
|
6
|
+
require "mkmf"
|
|
7
|
+
require "rb_sys/mkmf"
|
|
8
|
+
|
|
9
|
+
create_rust_makefile("ratatui_ruby/ratatui_ruby") do |r|
|
|
10
|
+
# Optional: Force release profile if needed, but defaults are usually good
|
|
11
|
+
# r.profile = ENV.fetch("RB_SYS_CARGO_PROFILE", :release).to_sym
|
|
12
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
use magnus::{Error, IntoValue, Symbol, TryConvert, Value};
|
|
5
|
+
use std::sync::Mutex;
|
|
6
|
+
|
|
7
|
+
lazy_static::lazy_static! {
|
|
8
|
+
static ref EVENT_QUEUE: Mutex<Vec<crossterm::event::Event>> = Mutex::new(Vec::new());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(), Error> {
|
|
12
|
+
let event = match event_type.as_str() {
|
|
13
|
+
"key" => {
|
|
14
|
+
let code_val: Value = data.get(Symbol::new("code")).ok_or_else(|| {
|
|
15
|
+
Error::new(
|
|
16
|
+
magnus::exception::arg_error(),
|
|
17
|
+
"Missing 'code' in key event",
|
|
18
|
+
)
|
|
19
|
+
})?;
|
|
20
|
+
let code_str: String = String::try_convert(code_val)?;
|
|
21
|
+
let code = match code_str.as_str() {
|
|
22
|
+
"up" => crossterm::event::KeyCode::Up,
|
|
23
|
+
"down" => crossterm::event::KeyCode::Down,
|
|
24
|
+
"left" => crossterm::event::KeyCode::Left,
|
|
25
|
+
"right" => crossterm::event::KeyCode::Right,
|
|
26
|
+
"enter" => crossterm::event::KeyCode::Enter,
|
|
27
|
+
"esc" => crossterm::event::KeyCode::Esc,
|
|
28
|
+
"backspace" => crossterm::event::KeyCode::Backspace,
|
|
29
|
+
"tab" => crossterm::event::KeyCode::Tab,
|
|
30
|
+
c if c.len() == 1 => crossterm::event::KeyCode::Char(c.chars().next().unwrap()),
|
|
31
|
+
_ => crossterm::event::KeyCode::Null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let mut modifiers = crossterm::event::KeyModifiers::empty();
|
|
35
|
+
if let Some(mods_val) = data.get(Symbol::new("modifiers")) {
|
|
36
|
+
let mods: Vec<String> = Vec::try_convert(mods_val)?;
|
|
37
|
+
for m in mods {
|
|
38
|
+
match m.as_str() {
|
|
39
|
+
"ctrl" => modifiers |= crossterm::event::KeyModifiers::CONTROL,
|
|
40
|
+
"alt" => modifiers |= crossterm::event::KeyModifiers::ALT,
|
|
41
|
+
"shift" => modifiers |= crossterm::event::KeyModifiers::SHIFT,
|
|
42
|
+
_ => {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
crossterm::event::Event::Key(crossterm::event::KeyEvent::new(code, modifiers))
|
|
48
|
+
}
|
|
49
|
+
"mouse" => {
|
|
50
|
+
let kind_val: Value = data.get(Symbol::new("kind")).ok_or_else(|| {
|
|
51
|
+
Error::new(
|
|
52
|
+
magnus::exception::arg_error(),
|
|
53
|
+
"Missing 'kind' in mouse event",
|
|
54
|
+
)
|
|
55
|
+
})?;
|
|
56
|
+
let kind_str: String = String::try_convert(kind_val)?;
|
|
57
|
+
|
|
58
|
+
let button = if let Some(btn_val) = data.get(Symbol::new("button")) {
|
|
59
|
+
let button_str: String = String::try_convert(btn_val)?;
|
|
60
|
+
match button_str.as_str() {
|
|
61
|
+
"right" => crossterm::event::MouseButton::Right,
|
|
62
|
+
"middle" => crossterm::event::MouseButton::Middle,
|
|
63
|
+
_ => crossterm::event::MouseButton::Left,
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
crossterm::event::MouseButton::Left
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
let x_val: Value = data.get(Symbol::new("x")).ok_or_else(|| {
|
|
70
|
+
Error::new(magnus::exception::arg_error(), "Missing 'x' in mouse event")
|
|
71
|
+
})?;
|
|
72
|
+
let x: u16 = u16::try_convert(x_val)?;
|
|
73
|
+
|
|
74
|
+
let y_val: Value = data.get(Symbol::new("y")).ok_or_else(|| {
|
|
75
|
+
Error::new(magnus::exception::arg_error(), "Missing 'y' in mouse event")
|
|
76
|
+
})?;
|
|
77
|
+
let y: u16 = u16::try_convert(y_val)?;
|
|
78
|
+
|
|
79
|
+
let kind = match kind_str.as_str() {
|
|
80
|
+
"down" => crossterm::event::MouseEventKind::Down(button),
|
|
81
|
+
"up" => crossterm::event::MouseEventKind::Up(button),
|
|
82
|
+
"drag" => crossterm::event::MouseEventKind::Drag(button),
|
|
83
|
+
"moved" => crossterm::event::MouseEventKind::Moved,
|
|
84
|
+
"scroll_down" => crossterm::event::MouseEventKind::ScrollDown,
|
|
85
|
+
"scroll_up" => crossterm::event::MouseEventKind::ScrollUp,
|
|
86
|
+
"scroll_left" => crossterm::event::MouseEventKind::ScrollLeft,
|
|
87
|
+
"scroll_right" => crossterm::event::MouseEventKind::ScrollRight,
|
|
88
|
+
_ => {
|
|
89
|
+
return Err(Error::new(
|
|
90
|
+
magnus::exception::arg_error(),
|
|
91
|
+
format!("Unknown mouse kind: {}", kind_str),
|
|
92
|
+
))
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let mut modifiers = crossterm::event::KeyModifiers::empty();
|
|
97
|
+
if let Some(mods_val) = data.get(Symbol::new("modifiers")) {
|
|
98
|
+
let mods: Vec<String> = Vec::try_convert(mods_val)?;
|
|
99
|
+
for m in mods {
|
|
100
|
+
match m.as_str() {
|
|
101
|
+
"ctrl" => modifiers |= crossterm::event::KeyModifiers::CONTROL,
|
|
102
|
+
"alt" => modifiers |= crossterm::event::KeyModifiers::ALT,
|
|
103
|
+
"shift" => modifiers |= crossterm::event::KeyModifiers::SHIFT,
|
|
104
|
+
_ => {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
crossterm::event::Event::Mouse(crossterm::event::MouseEvent {
|
|
110
|
+
kind,
|
|
111
|
+
column: x,
|
|
112
|
+
row: y,
|
|
113
|
+
modifiers,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
_ => {
|
|
117
|
+
return Err(Error::new(
|
|
118
|
+
magnus::exception::arg_error(),
|
|
119
|
+
format!("Unknown event type: {}", event_type),
|
|
120
|
+
))
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
EVENT_QUEUE.lock().unwrap().push(event);
|
|
125
|
+
Ok(())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pub fn poll_event() -> Result<Value, Error> {
|
|
129
|
+
let event = {
|
|
130
|
+
let mut queue = EVENT_QUEUE.lock().unwrap();
|
|
131
|
+
if !queue.is_empty() {
|
|
132
|
+
Some(queue.remove(0))
|
|
133
|
+
} else {
|
|
134
|
+
None
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if let Some(e) = event {
|
|
139
|
+
return handle_event(e);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check if we are in test mode. If so, don't poll crossterm.
|
|
143
|
+
let is_test_mode = {
|
|
144
|
+
let term_lock = crate::terminal::TERMINAL.lock().unwrap();
|
|
145
|
+
matches!(term_lock.as_ref(), Some(crate::terminal::TerminalWrapper::Test(_)))
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if is_test_mode {
|
|
149
|
+
return Ok(magnus::value::qnil().into_value());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if crossterm::event::poll(std::time::Duration::from_millis(16))
|
|
153
|
+
.map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?
|
|
154
|
+
{
|
|
155
|
+
let event = crossterm::event::read()
|
|
156
|
+
.map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
|
|
157
|
+
handle_event(event)
|
|
158
|
+
} else {
|
|
159
|
+
Ok(magnus::value::qnil().into_value())
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn handle_event(event: crossterm::event::Event) -> Result<Value, Error> {
|
|
164
|
+
match event {
|
|
165
|
+
crossterm::event::Event::Key(key) => {
|
|
166
|
+
if key.kind == crossterm::event::KeyEventKind::Press {
|
|
167
|
+
let hash = magnus::RHash::new();
|
|
168
|
+
hash.aset(Symbol::new("type"), Symbol::new("key"))?;
|
|
169
|
+
|
|
170
|
+
let code = match key.code {
|
|
171
|
+
crossterm::event::KeyCode::Char(c) => c.to_string(),
|
|
172
|
+
crossterm::event::KeyCode::Up => "up".to_string(),
|
|
173
|
+
crossterm::event::KeyCode::Down => "down".to_string(),
|
|
174
|
+
crossterm::event::KeyCode::Left => "left".to_string(),
|
|
175
|
+
crossterm::event::KeyCode::Right => "right".to_string(),
|
|
176
|
+
crossterm::event::KeyCode::Enter => "enter".to_string(),
|
|
177
|
+
crossterm::event::KeyCode::Esc => "esc".to_string(),
|
|
178
|
+
crossterm::event::KeyCode::Backspace => "backspace".to_string(),
|
|
179
|
+
crossterm::event::KeyCode::Tab => "tab".to_string(),
|
|
180
|
+
_ => "unknown".to_string(),
|
|
181
|
+
};
|
|
182
|
+
hash.aset(Symbol::new("code"), code)?;
|
|
183
|
+
|
|
184
|
+
let mut modifiers = Vec::new();
|
|
185
|
+
if key
|
|
186
|
+
.modifiers
|
|
187
|
+
.contains(crossterm::event::KeyModifiers::CONTROL)
|
|
188
|
+
{
|
|
189
|
+
modifiers.push("ctrl");
|
|
190
|
+
}
|
|
191
|
+
if key.modifiers.contains(crossterm::event::KeyModifiers::ALT) {
|
|
192
|
+
modifiers.push("alt");
|
|
193
|
+
}
|
|
194
|
+
if key
|
|
195
|
+
.modifiers
|
|
196
|
+
.contains(crossterm::event::KeyModifiers::SHIFT)
|
|
197
|
+
{
|
|
198
|
+
modifiers.push("shift");
|
|
199
|
+
}
|
|
200
|
+
if !modifiers.is_empty() {
|
|
201
|
+
hash.aset(Symbol::new("modifiers"), modifiers)?;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return Ok(hash.into_value());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
crossterm::event::Event::Mouse(event) => {
|
|
208
|
+
let hash = magnus::RHash::new();
|
|
209
|
+
hash.aset(Symbol::new("type"), Symbol::new("mouse"))?;
|
|
210
|
+
|
|
211
|
+
let (kind, button) = match event.kind {
|
|
212
|
+
crossterm::event::MouseEventKind::Down(btn) => ("down", btn),
|
|
213
|
+
crossterm::event::MouseEventKind::Up(btn) => ("up", btn),
|
|
214
|
+
crossterm::event::MouseEventKind::Drag(btn) => ("drag", btn),
|
|
215
|
+
crossterm::event::MouseEventKind::Moved => {
|
|
216
|
+
("moved", crossterm::event::MouseButton::Left)
|
|
217
|
+
} // button is ignored for moved
|
|
218
|
+
crossterm::event::MouseEventKind::ScrollDown => {
|
|
219
|
+
("scroll_down", crossterm::event::MouseButton::Left)
|
|
220
|
+
} // button is ignored for scroll
|
|
221
|
+
crossterm::event::MouseEventKind::ScrollUp => {
|
|
222
|
+
("scroll_up", crossterm::event::MouseButton::Left)
|
|
223
|
+
} // button is ignored for scroll
|
|
224
|
+
crossterm::event::MouseEventKind::ScrollLeft => {
|
|
225
|
+
("scroll_left", crossterm::event::MouseButton::Left)
|
|
226
|
+
} // button is ignored for scroll
|
|
227
|
+
crossterm::event::MouseEventKind::ScrollRight => {
|
|
228
|
+
("scroll_right", crossterm::event::MouseButton::Left)
|
|
229
|
+
} // button is ignored for scroll
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
hash.aset(Symbol::new("kind"), Symbol::new(kind))?;
|
|
233
|
+
|
|
234
|
+
if matches!(
|
|
235
|
+
event.kind,
|
|
236
|
+
crossterm::event::MouseEventKind::Down(_)
|
|
237
|
+
| crossterm::event::MouseEventKind::Up(_)
|
|
238
|
+
| crossterm::event::MouseEventKind::Drag(_)
|
|
239
|
+
) {
|
|
240
|
+
let btn_sym = match button {
|
|
241
|
+
crossterm::event::MouseButton::Left => "left",
|
|
242
|
+
crossterm::event::MouseButton::Right => "right",
|
|
243
|
+
crossterm::event::MouseButton::Middle => "middle",
|
|
244
|
+
};
|
|
245
|
+
hash.aset(Symbol::new("button"), Symbol::new(btn_sym))?;
|
|
246
|
+
} else {
|
|
247
|
+
hash.aset(Symbol::new("button"), Symbol::new("none"))?;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
hash.aset(Symbol::new("x"), event.column)?;
|
|
251
|
+
hash.aset(Symbol::new("y"), event.row)?;
|
|
252
|
+
|
|
253
|
+
let mut modifiers = Vec::new();
|
|
254
|
+
if event
|
|
255
|
+
.modifiers
|
|
256
|
+
.contains(crossterm::event::KeyModifiers::CONTROL)
|
|
257
|
+
{
|
|
258
|
+
modifiers.push("ctrl");
|
|
259
|
+
}
|
|
260
|
+
if event
|
|
261
|
+
.modifiers
|
|
262
|
+
.contains(crossterm::event::KeyModifiers::ALT)
|
|
263
|
+
{
|
|
264
|
+
modifiers.push("alt");
|
|
265
|
+
}
|
|
266
|
+
if event
|
|
267
|
+
.modifiers
|
|
268
|
+
.contains(crossterm::event::KeyModifiers::SHIFT)
|
|
269
|
+
{
|
|
270
|
+
modifiers.push("shift");
|
|
271
|
+
}
|
|
272
|
+
hash.aset(Symbol::new("modifiers"), modifiers)?;
|
|
273
|
+
|
|
274
|
+
return Ok(hash.into_value());
|
|
275
|
+
}
|
|
276
|
+
_ => {}
|
|
277
|
+
}
|
|
278
|
+
Ok(magnus::value::qnil().into_value())
|
|
279
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
mod events;
|
|
5
|
+
mod rendering;
|
|
6
|
+
mod style;
|
|
7
|
+
mod terminal;
|
|
8
|
+
mod widgets;
|
|
9
|
+
|
|
10
|
+
use magnus::{define_module, function, Error, Value};
|
|
11
|
+
use terminal::{init_terminal, restore_terminal, TERMINAL};
|
|
12
|
+
|
|
13
|
+
fn draw(tree: Value) -> Result<(), Error> {
|
|
14
|
+
let mut term_lock = TERMINAL.lock().unwrap();
|
|
15
|
+
if let Some(wrapper) = term_lock.as_mut() {
|
|
16
|
+
match wrapper {
|
|
17
|
+
terminal::TerminalWrapper::Crossterm(term) => {
|
|
18
|
+
term.draw(|f| {
|
|
19
|
+
if let Err(e) = rendering::render_node(f, f.size(), tree) {
|
|
20
|
+
eprintln!("Render error: {:?}", e);
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
.map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
|
|
24
|
+
}
|
|
25
|
+
terminal::TerminalWrapper::Test(term) => {
|
|
26
|
+
term.draw(|f| {
|
|
27
|
+
if let Err(e) = rendering::render_node(f, f.size(), tree) {
|
|
28
|
+
eprintln!("Render error: {:?}", e);
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
.map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
eprintln!("Terminal is None!");
|
|
36
|
+
}
|
|
37
|
+
Ok(())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[magnus::init]
|
|
41
|
+
fn init() -> Result<(), Error> {
|
|
42
|
+
let m = define_module("RatatuiRuby")?;
|
|
43
|
+
m.define_module_function("init_terminal", function!(init_terminal, 0))?;
|
|
44
|
+
m.define_module_function("restore_terminal", function!(restore_terminal, 0))?;
|
|
45
|
+
m.define_module_function("draw", function!(draw, 1))?;
|
|
46
|
+
m.define_module_function("poll_event", function!(events::poll_event, 0))?;
|
|
47
|
+
m.define_module_function("inject_test_event", function!(events::inject_test_event, 2))?;
|
|
48
|
+
|
|
49
|
+
// Test backend helpers
|
|
50
|
+
m.define_module_function(
|
|
51
|
+
"init_test_terminal",
|
|
52
|
+
function!(terminal::init_test_terminal, 2),
|
|
53
|
+
)?;
|
|
54
|
+
m.define_module_function(
|
|
55
|
+
"get_buffer_content",
|
|
56
|
+
function!(terminal::get_buffer_content, 0),
|
|
57
|
+
)?;
|
|
58
|
+
m.define_module_function(
|
|
59
|
+
"get_cursor_position",
|
|
60
|
+
function!(terminal::get_cursor_position, 0),
|
|
61
|
+
)?;
|
|
62
|
+
m.define_module_function("resize_terminal", function!(terminal::resize_terminal, 2))?;
|
|
63
|
+
|
|
64
|
+
Ok(())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[cfg(test)]
|
|
68
|
+
mod tests {
|
|
69
|
+
use ratatui::layout::Rect;
|
|
70
|
+
use ratatui::style::Color;
|
|
71
|
+
use ratatui::widgets::Widget;
|
|
72
|
+
use ratatui::widgets::{Chart, Dataset, Sparkline};
|
|
73
|
+
|
|
74
|
+
#[test]
|
|
75
|
+
fn test_parse_color() {
|
|
76
|
+
// We can test this through the style module directly now
|
|
77
|
+
use crate::style::parse_color;
|
|
78
|
+
assert_eq!(parse_color("red"), Some(Color::Red));
|
|
79
|
+
assert_eq!(parse_color("blue"), Some(Color::Blue));
|
|
80
|
+
assert_eq!(parse_color("#ffffff"), Some(Color::Rgb(255, 255, 255)));
|
|
81
|
+
assert_eq!(parse_color("#000000"), Some(Color::Rgb(0, 0, 0)));
|
|
82
|
+
assert_eq!(parse_color("invalid"), None);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#[test]
|
|
86
|
+
fn test_sparkline_render() {
|
|
87
|
+
let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 10, 1));
|
|
88
|
+
let data = vec![1, 2, 3];
|
|
89
|
+
let sparkline = Sparkline::default().data(&data);
|
|
90
|
+
sparkline.render(Rect::new(0, 0, 10, 1), &mut buf);
|
|
91
|
+
assert!(buf.content().iter().any(|c| c.symbol() != " "));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[test]
|
|
95
|
+
fn test_line_chart_render() {
|
|
96
|
+
let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 20, 10));
|
|
97
|
+
let data = vec![(0.0, 0.0), (1.0, 1.0)];
|
|
98
|
+
let datasets = vec![Dataset::default().data(&data)];
|
|
99
|
+
let chart = Chart::new(datasets)
|
|
100
|
+
.x_axis(ratatui::widgets::Axis::default().bounds([0.0, 1.0]))
|
|
101
|
+
.y_axis(ratatui::widgets::Axis::default().bounds([0.0, 1.0]));
|
|
102
|
+
chart.render(Rect::new(0, 0, 20, 10), &mut buf);
|
|
103
|
+
assert!(buf.content().iter().any(|c| c.symbol() != " "));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
use crate::widgets;
|
|
5
|
+
use magnus::{prelude::*, Error, Value};
|
|
6
|
+
use ratatui::{layout::Rect, Frame};
|
|
7
|
+
|
|
8
|
+
pub fn render_node(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
|
|
9
|
+
let class = node.class();
|
|
10
|
+
let class_name = unsafe { class.name() };
|
|
11
|
+
|
|
12
|
+
match class_name.as_ref() {
|
|
13
|
+
"RatatuiRuby::Paragraph" => widgets::paragraph::render(frame, area, node)?,
|
|
14
|
+
"RatatuiRuby::Cursor" => widgets::cursor::render(frame, area, node)?,
|
|
15
|
+
"RatatuiRuby::Overlay" => widgets::overlay::render(frame, area, node)?,
|
|
16
|
+
"RatatuiRuby::Center" => widgets::center::render(frame, area, node)?,
|
|
17
|
+
"RatatuiRuby::Layout" => widgets::layout::render(frame, area, node)?,
|
|
18
|
+
"RatatuiRuby::List" => widgets::list::render(frame, area, node)?,
|
|
19
|
+
"RatatuiRuby::Gauge" => widgets::gauge::render(frame, area, node)?,
|
|
20
|
+
"RatatuiRuby::Table" => widgets::table::render(frame, area, node)?,
|
|
21
|
+
"RatatuiRuby::Block" => widgets::block::render(frame, area, node)?,
|
|
22
|
+
"RatatuiRuby::Tabs" => widgets::tabs::render(frame, area, node)?,
|
|
23
|
+
"RatatuiRuby::Scrollbar" => widgets::scrollbar::render(frame, area, node)?,
|
|
24
|
+
"RatatuiRuby::BarChart" => widgets::barchart::render(frame, area, node)?,
|
|
25
|
+
"RatatuiRuby::Canvas" => widgets::canvas::render(frame, area, node)?,
|
|
26
|
+
"RatatuiRuby::Sparkline" => widgets::sparkline::render(frame, area, node)?,
|
|
27
|
+
"RatatuiRuby::LineChart" => widgets::linechart::render(frame, area, node)?,
|
|
28
|
+
_ => {}
|
|
29
|
+
}
|
|
30
|
+
Ok(())
|
|
31
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
use magnus::{prelude::*, Error, Symbol, Value};
|
|
5
|
+
use ratatui::{
|
|
6
|
+
style::{Color, Modifier, Style},
|
|
7
|
+
widgets::{Block, Borders},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
pub fn parse_color(color_str: &str) -> Option<Color> {
|
|
11
|
+
color_str.parse::<Color>().ok()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub fn parse_style(style_val: Value) -> Result<Style, Error> {
|
|
15
|
+
if style_val.is_nil() {
|
|
16
|
+
return Ok(Style::default());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let mut style = Style::default();
|
|
20
|
+
|
|
21
|
+
let fg: Value = style_val.funcall("fg", ())?;
|
|
22
|
+
if !fg.is_nil() {
|
|
23
|
+
let fg_str: String = fg.funcall("to_s", ())?;
|
|
24
|
+
if let Some(color) = parse_color(&fg_str) {
|
|
25
|
+
style = style.fg(color);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let bg: Value = style_val.funcall("bg", ())?;
|
|
30
|
+
if !bg.is_nil() {
|
|
31
|
+
let bg_str: String = bg.funcall("to_s", ())?;
|
|
32
|
+
if let Some(color) = parse_color(&bg_str) {
|
|
33
|
+
style = style.bg(color);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let modifiers_val: Value = style_val.funcall("modifiers", ())?;
|
|
38
|
+
if !modifiers_val.is_nil() {
|
|
39
|
+
let modifiers_array = magnus::RArray::from_value(modifiers_val).ok_or_else(|| {
|
|
40
|
+
Error::new(
|
|
41
|
+
magnus::exception::type_error(),
|
|
42
|
+
"expected array for modifiers",
|
|
43
|
+
)
|
|
44
|
+
})?;
|
|
45
|
+
|
|
46
|
+
for i in 0..modifiers_array.len() {
|
|
47
|
+
let sym: Symbol = modifiers_array.entry(i as isize)?;
|
|
48
|
+
match sym.to_string().as_str() {
|
|
49
|
+
"bold" => style = style.add_modifier(Modifier::BOLD),
|
|
50
|
+
"italic" => style = style.add_modifier(Modifier::ITALIC),
|
|
51
|
+
"dim" => style = style.add_modifier(Modifier::DIM),
|
|
52
|
+
"reversed" => style = style.add_modifier(Modifier::REVERSED),
|
|
53
|
+
"underlined" => style = style.add_modifier(Modifier::UNDERLINED),
|
|
54
|
+
"slow_blink" => style = style.add_modifier(Modifier::SLOW_BLINK),
|
|
55
|
+
"rapid_blink" => style = style.add_modifier(Modifier::RAPID_BLINK),
|
|
56
|
+
"crossed_out" => style = style.add_modifier(Modifier::CROSSED_OUT),
|
|
57
|
+
"hidden" => style = style.add_modifier(Modifier::HIDDEN),
|
|
58
|
+
_ => {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Ok(style)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pub fn parse_block(block_val: Value) -> Result<Block<'static>, Error> {
|
|
67
|
+
if block_val.is_nil() {
|
|
68
|
+
return Ok(Block::default());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let title: Value = block_val.funcall("title", ())?;
|
|
72
|
+
let borders_val: Value = block_val.funcall("borders", ())?;
|
|
73
|
+
let border_color: Value = block_val.funcall("border_color", ())?;
|
|
74
|
+
|
|
75
|
+
let mut block = Block::default();
|
|
76
|
+
|
|
77
|
+
if !title.is_nil() {
|
|
78
|
+
let title_str: String = title.funcall("to_s", ())?;
|
|
79
|
+
block = block.title(title_str);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if !borders_val.is_nil() {
|
|
83
|
+
let mut ratatui_borders = Borders::NONE;
|
|
84
|
+
if let Some(sym) = Symbol::from_value(borders_val) {
|
|
85
|
+
match sym.to_string().as_str() {
|
|
86
|
+
"all" => ratatui_borders = Borders::ALL,
|
|
87
|
+
"top" => ratatui_borders = Borders::TOP,
|
|
88
|
+
"bottom" => ratatui_borders = Borders::BOTTOM,
|
|
89
|
+
"left" => ratatui_borders = Borders::LEFT,
|
|
90
|
+
"right" => ratatui_borders = Borders::RIGHT,
|
|
91
|
+
_ => {}
|
|
92
|
+
}
|
|
93
|
+
} else if let Some(borders_array) = magnus::RArray::from_value(borders_val) {
|
|
94
|
+
for i in 0..borders_array.len() {
|
|
95
|
+
let sym: Symbol = borders_array.entry(i as isize)?;
|
|
96
|
+
match sym.to_string().as_str() {
|
|
97
|
+
"all" => ratatui_borders |= Borders::ALL,
|
|
98
|
+
"top" => ratatui_borders |= Borders::TOP,
|
|
99
|
+
"bottom" => ratatui_borders |= Borders::BOTTOM,
|
|
100
|
+
"left" => ratatui_borders |= Borders::LEFT,
|
|
101
|
+
"right" => ratatui_borders |= Borders::RIGHT,
|
|
102
|
+
_ => {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
block = block.borders(ratatui_borders);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if !border_color.is_nil() {
|
|
110
|
+
let color_str: String = border_color.funcall("to_s", ())?;
|
|
111
|
+
if let Some(color) = parse_color(&color_str) {
|
|
112
|
+
block = block.border_style(Style::default().fg(color));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
Ok(block)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[cfg(test)]
|
|
120
|
+
mod tests {
|
|
121
|
+
use super::*;
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn test_parse_color() {
|
|
125
|
+
assert_eq!(parse_color("red"), Some(Color::Red));
|
|
126
|
+
assert_eq!(parse_color("blue"), Some(Color::Blue));
|
|
127
|
+
assert_eq!(parse_color("black"), Some(Color::Black));
|
|
128
|
+
assert_eq!(parse_color("white"), Some(Color::White));
|
|
129
|
+
assert_eq!(parse_color("green"), Some(Color::Green));
|
|
130
|
+
assert_eq!(parse_color("yellow"), Some(Color::Yellow));
|
|
131
|
+
assert_eq!(parse_color("magenta"), Some(Color::Magenta));
|
|
132
|
+
assert_eq!(parse_color("cyan"), Some(Color::Cyan));
|
|
133
|
+
assert_eq!(parse_color("gray"), Some(Color::Gray));
|
|
134
|
+
assert_eq!(parse_color("dark_gray"), Some(Color::DarkGray));
|
|
135
|
+
assert_eq!(parse_color("light_red"), Some(Color::LightRed));
|
|
136
|
+
assert_eq!(parse_color("light_green"), Some(Color::LightGreen));
|
|
137
|
+
assert_eq!(parse_color("light_yellow"), Some(Color::LightYellow));
|
|
138
|
+
assert_eq!(parse_color("light_blue"), Some(Color::LightBlue));
|
|
139
|
+
assert_eq!(parse_color("light_magenta"), Some(Color::LightMagenta));
|
|
140
|
+
assert_eq!(parse_color("light_cyan"), Some(Color::LightCyan));
|
|
141
|
+
|
|
142
|
+
assert_eq!(parse_color("#ffffff"), Some(Color::Rgb(255, 255, 255)));
|
|
143
|
+
assert_eq!(parse_color("#000000"), Some(Color::Rgb(0, 0, 0)));
|
|
144
|
+
assert_eq!(parse_color("#FF0000"), Some(Color::Rgb(255, 0, 0)));
|
|
145
|
+
|
|
146
|
+
assert_eq!(parse_color("invalid"), None);
|
|
147
|
+
assert_eq!(parse_color(""), None);
|
|
148
|
+
}
|
|
149
|
+
}
|