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.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/.build.yml +34 -0
  3. data/.pre-commit-config.yaml +9 -0
  4. data/.rubocop.yml +8 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +119 -0
  7. data/CHANGELOG.md +15 -0
  8. data/CODE_OF_CONDUCT.md +30 -0
  9. data/CONTRIBUTING.md +40 -0
  10. data/LICENSE +15 -0
  11. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  12. data/LICENSES/BSD-2-Clause.txt +9 -0
  13. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  14. data/LICENSES/CC0-1.0.txt +121 -0
  15. data/LICENSES/MIT.txt +21 -0
  16. data/README.md +86 -0
  17. data/REUSE.toml +17 -0
  18. data/Rakefile +108 -0
  19. data/docs/application_testing.md +96 -0
  20. data/docs/contributors/design/ruby_frontend.md +100 -0
  21. data/docs/contributors/design/rust_backend.md +61 -0
  22. data/docs/contributors/design.md +11 -0
  23. data/docs/contributors/index.md +16 -0
  24. data/docs/images/examples-analytics.rb.png +0 -0
  25. data/docs/images/examples-box_demo.rb.png +0 -0
  26. data/docs/images/examples-dashboard.rb.png +0 -0
  27. data/docs/images/examples-login_form.rb.png +0 -0
  28. data/docs/images/examples-map_demo.rb.png +0 -0
  29. data/docs/images/examples-mouse_events.rb.png +0 -0
  30. data/docs/images/examples-scrollbar_demo.rb.png +0 -0
  31. data/docs/images/examples-stock_ticker.rb.png +0 -0
  32. data/docs/images/examples-system_monitor.rb.png +0 -0
  33. data/docs/index.md +18 -0
  34. data/docs/quickstart.md +126 -0
  35. data/examples/analytics.rb +87 -0
  36. data/examples/box_demo.rb +71 -0
  37. data/examples/dashboard.rb +72 -0
  38. data/examples/login_form.rb +114 -0
  39. data/examples/map_demo.rb +58 -0
  40. data/examples/mouse_events.rb +95 -0
  41. data/examples/scrollbar_demo.rb +75 -0
  42. data/examples/stock_ticker.rb +85 -0
  43. data/examples/system_monitor.rb +93 -0
  44. data/examples/test_analytics.rb +65 -0
  45. data/examples/test_box_demo.rb +38 -0
  46. data/examples/test_dashboard.rb +38 -0
  47. data/examples/test_login_form.rb +63 -0
  48. data/examples/test_map_demo.rb +100 -0
  49. data/examples/test_stock_ticker.rb +39 -0
  50. data/examples/test_system_monitor.rb +40 -0
  51. data/ext/ratatui_ruby/.cargo/config.toml +8 -0
  52. data/ext/ratatui_ruby/.gitignore +4 -0
  53. data/ext/ratatui_ruby/Cargo.lock +698 -0
  54. data/ext/ratatui_ruby/Cargo.toml +16 -0
  55. data/ext/ratatui_ruby/extconf.rb +12 -0
  56. data/ext/ratatui_ruby/src/events.rs +279 -0
  57. data/ext/ratatui_ruby/src/lib.rs +105 -0
  58. data/ext/ratatui_ruby/src/rendering.rs +31 -0
  59. data/ext/ratatui_ruby/src/style.rs +149 -0
  60. data/ext/ratatui_ruby/src/terminal.rs +131 -0
  61. data/ext/ratatui_ruby/src/widgets/barchart.rs +73 -0
  62. data/ext/ratatui_ruby/src/widgets/block.rs +12 -0
  63. data/ext/ratatui_ruby/src/widgets/canvas.rs +146 -0
  64. data/ext/ratatui_ruby/src/widgets/center.rs +81 -0
  65. data/ext/ratatui_ruby/src/widgets/cursor.rs +29 -0
  66. data/ext/ratatui_ruby/src/widgets/gauge.rs +50 -0
  67. data/ext/ratatui_ruby/src/widgets/layout.rs +82 -0
  68. data/ext/ratatui_ruby/src/widgets/linechart.rs +154 -0
  69. data/ext/ratatui_ruby/src/widgets/list.rs +62 -0
  70. data/ext/ratatui_ruby/src/widgets/mod.rs +18 -0
  71. data/ext/ratatui_ruby/src/widgets/overlay.rs +20 -0
  72. data/ext/ratatui_ruby/src/widgets/paragraph.rs +56 -0
  73. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +68 -0
  74. data/ext/ratatui_ruby/src/widgets/sparkline.rs +59 -0
  75. data/ext/ratatui_ruby/src/widgets/table.rs +117 -0
  76. data/ext/ratatui_ruby/src/widgets/tabs.rs +51 -0
  77. data/lib/ratatui_ruby/output.rb +7 -0
  78. data/lib/ratatui_ruby/schema/bar_chart.rb +28 -0
  79. data/lib/ratatui_ruby/schema/block.rb +23 -0
  80. data/lib/ratatui_ruby/schema/canvas.rb +62 -0
  81. data/lib/ratatui_ruby/schema/center.rb +19 -0
  82. data/lib/ratatui_ruby/schema/constraint.rb +33 -0
  83. data/lib/ratatui_ruby/schema/cursor.rb +17 -0
  84. data/lib/ratatui_ruby/schema/gauge.rb +24 -0
  85. data/lib/ratatui_ruby/schema/layout.rb +22 -0
  86. data/lib/ratatui_ruby/schema/line_chart.rb +41 -0
  87. data/lib/ratatui_ruby/schema/list.rb +22 -0
  88. data/lib/ratatui_ruby/schema/overlay.rb +15 -0
  89. data/lib/ratatui_ruby/schema/paragraph.rb +37 -0
  90. data/lib/ratatui_ruby/schema/scrollbar.rb +33 -0
  91. data/lib/ratatui_ruby/schema/sparkline.rb +24 -0
  92. data/lib/ratatui_ruby/schema/style.rb +31 -0
  93. data/lib/ratatui_ruby/schema/table.rb +24 -0
  94. data/lib/ratatui_ruby/schema/tabs.rb +22 -0
  95. data/lib/ratatui_ruby/test_helper.rb +75 -0
  96. data/lib/ratatui_ruby/version.rb +10 -0
  97. data/lib/ratatui_ruby.rb +87 -0
  98. data/sig/ratatui_ruby/ratatui_ruby.rbs +16 -0
  99. data/sig/ratatui_ruby/schema/bar_chart.rbs +14 -0
  100. data/sig/ratatui_ruby/schema/block.rbs +11 -0
  101. data/sig/ratatui_ruby/schema/canvas.rbs +62 -0
  102. data/sig/ratatui_ruby/schema/center.rbs +11 -0
  103. data/sig/ratatui_ruby/schema/constraint.rbs +13 -0
  104. data/sig/ratatui_ruby/schema/cursor.rbs +10 -0
  105. data/sig/ratatui_ruby/schema/gauge.rbs +13 -0
  106. data/sig/ratatui_ruby/schema/layout.rbs +11 -0
  107. data/sig/ratatui_ruby/schema/line_chart.rbs +20 -0
  108. data/sig/ratatui_ruby/schema/list.rbs +11 -0
  109. data/sig/ratatui_ruby/schema/overlay.rbs +9 -0
  110. data/sig/ratatui_ruby/schema/paragraph.rbs +11 -0
  111. data/sig/ratatui_ruby/schema/scrollbar.rbs +20 -0
  112. data/sig/ratatui_ruby/schema/sparkline.rbs +12 -0
  113. data/sig/ratatui_ruby/schema/style.rbs +13 -0
  114. data/sig/ratatui_ruby/schema/table.rbs +13 -0
  115. data/sig/ratatui_ruby/schema/tabs.rbs +11 -0
  116. data/sig/ratatui_ruby/test_helper.rbs +11 -0
  117. data/sig/ratatui_ruby/version.rbs +6 -0
  118. data/vendor/goodcop/base.yml +1047 -0
  119. 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
+ }