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,131 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use magnus::Error;
5
+ use ratatui::{
6
+ backend::{CrosstermBackend, TestBackend},
7
+ Terminal,
8
+ };
9
+ use std::io;
10
+ use std::sync::Mutex;
11
+
12
+ pub enum TerminalWrapper {
13
+ Crossterm(Terminal<CrosstermBackend<io::Stdout>>),
14
+ Test(Terminal<TestBackend>), // We don't need Mutex inside the enum variant because the global is a Mutex
15
+ }
16
+
17
+ lazy_static::lazy_static! {
18
+ pub static ref TERMINAL: Mutex<Option<TerminalWrapper>> = Mutex::new(None);
19
+ }
20
+
21
+ pub fn init_terminal() -> Result<(), Error> {
22
+ let mut term_lock = TERMINAL.lock().unwrap();
23
+ if term_lock.is_none() {
24
+ crossterm::terminal::enable_raw_mode()
25
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
26
+ let mut stdout = io::stdout();
27
+ crossterm::execute!(
28
+ stdout,
29
+ crossterm::terminal::EnterAlternateScreen,
30
+ crossterm::event::EnableMouseCapture
31
+ )
32
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
33
+ let backend = CrosstermBackend::new(stdout);
34
+ let terminal = Terminal::new(backend)
35
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
36
+ *term_lock = Some(TerminalWrapper::Crossterm(terminal));
37
+ }
38
+ Ok(())
39
+ }
40
+
41
+ pub fn init_test_terminal(width: u16, height: u16) -> Result<(), Error> {
42
+ let mut term_lock = TERMINAL.lock().unwrap();
43
+ if term_lock.is_none() {
44
+ let backend = TestBackend::new(width, height);
45
+ let terminal = Terminal::new(backend)
46
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
47
+ *term_lock = Some(TerminalWrapper::Test(terminal));
48
+ }
49
+ Ok(())
50
+ }
51
+
52
+ pub fn restore_terminal() -> Result<(), Error> {
53
+ let mut term_lock = TERMINAL.lock().unwrap();
54
+ if let Some(TerminalWrapper::Crossterm(mut terminal)) = term_lock.take() {
55
+ let _ = crossterm::terminal::disable_raw_mode();
56
+ let _ = crossterm::execute!(
57
+ terminal.backend_mut(),
58
+ crossterm::terminal::LeaveAlternateScreen,
59
+ crossterm::event::DisableMouseCapture
60
+ );
61
+ }
62
+ Ok(())
63
+ }
64
+
65
+ pub fn get_buffer_content() -> Result<String, Error> {
66
+ let term_lock = TERMINAL.lock().unwrap();
67
+ if let Some(TerminalWrapper::Test(terminal)) = term_lock.as_ref() {
68
+ // We need to access the buffer.
69
+ // Since we are mocking, we can just print the buffer to a string.
70
+ let buffer = terminal.backend().buffer();
71
+ // Simple representation: each cell's symbol.
72
+ // For a more complex representation we could return an array of strings.
73
+ // Let's just return the full string representation for now which is useful for debugging/asserting.
74
+ // Actually, let's reconstruct it line by line.
75
+ let area = buffer.area;
76
+ let mut result = String::new();
77
+ for y in 0..area.height {
78
+ for x in 0..area.width {
79
+ let cell = buffer.get(x, y);
80
+ result.push_str(cell.symbol());
81
+ }
82
+ result.push('\n');
83
+ }
84
+ Ok(result)
85
+ } else {
86
+ Err(Error::new(
87
+ magnus::exception::runtime_error(),
88
+ "Terminal is not initialized as TestBackend",
89
+ ))
90
+ }
91
+ }
92
+
93
+ pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
94
+ let mut term_lock = TERMINAL.lock().unwrap();
95
+ if let Some(TerminalWrapper::Test(terminal)) = term_lock.as_mut() {
96
+ let pos = terminal
97
+ .get_cursor()
98
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?;
99
+ Ok(Some(pos))
100
+ } else {
101
+ Err(Error::new(
102
+ magnus::exception::runtime_error(),
103
+ "Terminal is not initialized as TestBackend",
104
+ ))
105
+ }
106
+ }
107
+
108
+ pub fn resize_terminal(width: u16, height: u16) -> Result<(), Error> {
109
+ let mut term_lock = TERMINAL.lock().unwrap();
110
+ if let Some(wrapper) = term_lock.as_mut() {
111
+ match wrapper {
112
+ TerminalWrapper::Crossterm(_) => {
113
+ // Resize happens automatically for Crossterm via signals usually,
114
+ // but we can't easily force it here without OS interaction.
115
+ // Ignoring for now as it's less critical for unit testing the logic.
116
+ }
117
+ TerminalWrapper::Test(terminal) => {
118
+ terminal.backend_mut().resize(width, height);
119
+ // Also resize the terminal wrapper itself if needed, but TestBackend resize handles the buffer.
120
+ // We might need to call terminal.resize() too if Ratatui caches the size.
121
+ if let Err(e) = terminal.resize(ratatui::layout::Rect::new(0, 0, width, height)) {
122
+ return Err(Error::new(
123
+ magnus::exception::runtime_error(),
124
+ e.to_string(),
125
+ ));
126
+ }
127
+ }
128
+ }
129
+ }
130
+ Ok(())
131
+ }
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use crate::style::{parse_block, parse_style};
5
+ use magnus::{prelude::*, Error, Value};
6
+ use ratatui::{layout::Rect, widgets::BarChart, Frame};
7
+
8
+ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
9
+ let data_val: magnus::RHash = node.funcall("data", ())?;
10
+ let bar_width: u16 = node.funcall("bar_width", ())?;
11
+ let bar_gap: u16 = node.funcall("bar_gap", ())?;
12
+ let max_val: Value = node.funcall("max", ())?;
13
+ let style_val: Value = node.funcall("style", ())?;
14
+ let block_val: Value = node.funcall("block", ())?;
15
+
16
+ let keys: magnus::RArray = data_val.funcall("keys", ())?;
17
+ let mut labels = Vec::new();
18
+ let mut data_vec = Vec::new();
19
+
20
+ for i in 0..keys.len() {
21
+ let key: Value = keys.entry(i as isize)?;
22
+ let val: u64 = data_val.funcall("[]", (key,))?;
23
+ let label: String = key.funcall("to_s", ())?;
24
+ labels.push(label);
25
+ data_vec.push(val);
26
+ }
27
+
28
+ let chart_data: Vec<(&str, u64)> = labels
29
+ .iter()
30
+ .zip(data_vec.iter())
31
+ .map(|(l, v)| (l.as_str(), *v))
32
+ .collect();
33
+
34
+ let mut bar_chart = BarChart::default()
35
+ .data(&chart_data)
36
+ .bar_width(bar_width)
37
+ .bar_gap(bar_gap);
38
+
39
+ if !max_val.is_nil() {
40
+ let max: u64 = u64::try_convert(max_val)?;
41
+ bar_chart = bar_chart.max(max);
42
+ }
43
+
44
+ if !style_val.is_nil() {
45
+ bar_chart = bar_chart.style(parse_style(style_val)?);
46
+ }
47
+
48
+ if !block_val.is_nil() {
49
+ bar_chart = bar_chart.block(parse_block(block_val)?);
50
+ }
51
+
52
+ frame.render_widget(bar_chart, area);
53
+ Ok(())
54
+ }
55
+
56
+ #[cfg(test)]
57
+ mod tests {
58
+ use super::*;
59
+ use ratatui::buffer::Buffer;
60
+ use ratatui::widgets::{BarChart, Widget};
61
+
62
+ #[test]
63
+ fn test_barchart_rendering() {
64
+ let data = [("B1", 10), ("B2", 20)];
65
+ let chart = BarChart::default().data(&data).bar_width(3);
66
+ let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
67
+ chart.render(Rect::new(0, 0, 10, 5), &mut buf);
68
+ // Should have bars rendered (non-space characters)
69
+ assert!(buf.content().iter().any(|c| c.symbol() != " "));
70
+ // Should have labels
71
+ assert!(buf.content().iter().any(|c| c.symbol().contains('B')));
72
+ }
73
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use crate::style::parse_block;
5
+ use magnus::{Error, Value};
6
+ use ratatui::{layout::Rect, widgets::Widget, Frame};
7
+
8
+ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
9
+ let block = parse_block(node)?;
10
+ block.render(area, frame.buffer_mut());
11
+ Ok(())
12
+ }
@@ -0,0 +1,146 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use crate::style::{parse_block, parse_color};
5
+ use magnus::{prelude::*, Error, RArray, Symbol, Value};
6
+ use ratatui::{
7
+ symbols::Marker,
8
+ widgets::canvas::{Canvas, Circle, Line, Map, MapResolution, Rectangle},
9
+ Frame,
10
+ };
11
+
12
+ pub fn render(frame: &mut Frame, area: ratatui::layout::Rect, node: Value) -> Result<(), Error> {
13
+ let shapes_val: RArray = node.funcall("shapes", ())?;
14
+ let x_bounds_val: RArray = node.funcall("x_bounds", ())?;
15
+ let y_bounds_val: RArray = node.funcall("y_bounds", ())?;
16
+ let marker_sym: Symbol = node.funcall("marker", ())?;
17
+ let block_val: Value = node.funcall("block", ())?;
18
+
19
+ let x_bounds: [f64; 2] = [x_bounds_val.entry::<f64>(0)?, x_bounds_val.entry::<f64>(1)?];
20
+ let y_bounds: [f64; 2] = [y_bounds_val.entry::<f64>(0)?, y_bounds_val.entry::<f64>(1)?];
21
+
22
+ let marker = match marker_sym.to_string().as_str() {
23
+ "dot" => Marker::Dot,
24
+ "block" => Marker::Block,
25
+ "bar" => Marker::Bar,
26
+ _ => Marker::Braille,
27
+ };
28
+
29
+ let mut canvas = Canvas::default()
30
+ .x_bounds(x_bounds)
31
+ .y_bounds(y_bounds)
32
+ .marker(marker);
33
+
34
+ if !block_val.is_nil() {
35
+ canvas = canvas.block(parse_block(block_val)?);
36
+ }
37
+
38
+ let canvas = canvas.paint(|ctx| {
39
+ for shape_val in shapes_val.each() {
40
+ let shape_val = shape_val.unwrap();
41
+ let class = shape_val.class();
42
+ let class_name = unsafe { class.name() };
43
+
44
+ match class_name.as_ref() {
45
+ "RatatuiRuby::Line" => {
46
+ let x1: f64 = shape_val.funcall("x1", ()).unwrap_or(0.0);
47
+ let y1: f64 = shape_val.funcall("y1", ()).unwrap_or(0.0);
48
+ let x2: f64 = shape_val.funcall("x2", ()).unwrap_or(0.0);
49
+ let y2: f64 = shape_val.funcall("y2", ()).unwrap_or(0.0);
50
+ let color_val: Value = shape_val.funcall("color", ()).unwrap();
51
+ let color =
52
+ parse_color(&color_val.to_string()).unwrap_or(ratatui::style::Color::Reset);
53
+ ctx.draw(&Line {
54
+ x1,
55
+ y1,
56
+ x2,
57
+ y2,
58
+ color,
59
+ });
60
+ }
61
+ "RatatuiRuby::Rectangle" => {
62
+ let x: f64 = shape_val.funcall("x", ()).unwrap_or(0.0);
63
+ let y: f64 = shape_val.funcall("y", ()).unwrap_or(0.0);
64
+ let width: f64 = shape_val.funcall("width", ()).unwrap_or(0.0);
65
+ let height: f64 = shape_val.funcall("height", ()).unwrap_or(0.0);
66
+ let color_val: Value = shape_val.funcall("color", ()).unwrap();
67
+ let color =
68
+ parse_color(&color_val.to_string()).unwrap_or(ratatui::style::Color::Reset);
69
+ ctx.draw(&Rectangle {
70
+ x,
71
+ y,
72
+ width,
73
+ height,
74
+ color,
75
+ });
76
+ }
77
+ "RatatuiRuby::Circle" => {
78
+ let x: f64 = shape_val.funcall("x", ()).unwrap_or(0.0);
79
+ let y: f64 = shape_val.funcall("y", ()).unwrap_or(0.0);
80
+ let radius: f64 = shape_val.funcall("radius", ()).unwrap_or(0.0);
81
+ let color_val: Value = shape_val.funcall("color", ()).unwrap();
82
+ let color =
83
+ parse_color(&color_val.to_string()).unwrap_or(ratatui::style::Color::Reset);
84
+ ctx.draw(&Circle {
85
+ x,
86
+ y,
87
+ radius,
88
+ color,
89
+ });
90
+ }
91
+ "RatatuiRuby::Map" => {
92
+ let color_val: Value = shape_val.funcall("color", ()).unwrap();
93
+ let color =
94
+ parse_color(&color_val.to_string()).unwrap_or(ratatui::style::Color::Reset);
95
+ let resolution_sym: Symbol = shape_val.funcall("resolution", ()).unwrap();
96
+ let resolution = match resolution_sym.to_string().as_str() {
97
+ "high" => MapResolution::High,
98
+ _ => MapResolution::Low,
99
+ };
100
+ ctx.draw(&Map { color, resolution });
101
+ }
102
+ _ => {}
103
+ }
104
+ }
105
+ });
106
+
107
+ frame.render_widget(canvas, area);
108
+ Ok(())
109
+ }
110
+
111
+ #[cfg(test)]
112
+ mod tests {
113
+ use super::*;
114
+ use ratatui::buffer::Buffer;
115
+ use ratatui::layout::Rect;
116
+ use ratatui::widgets::Widget;
117
+
118
+ #[test]
119
+ fn test_canvas_rendering() {
120
+ let canvas = Canvas::default()
121
+ .x_bounds([0.0, 10.0])
122
+ .y_bounds([0.0, 10.0])
123
+ .marker(Marker::Braille)
124
+ .paint(|ctx| {
125
+ ctx.draw(&Line {
126
+ x1: 0.0,
127
+ y1: 0.0,
128
+ x2: 10.0,
129
+ y2: 10.0,
130
+ color: ratatui::style::Color::Red,
131
+ });
132
+ });
133
+ let mut buf = Buffer::empty(Rect::new(0, 0, 5, 5));
134
+ canvas.render(Rect::new(0, 0, 5, 5), &mut buf);
135
+
136
+ // Verify that some Braille characters are rendered
137
+ let mut found_braille = false;
138
+ for cell in buf.content() {
139
+ if !cell.symbol().trim().is_empty() {
140
+ found_braille = true;
141
+ break;
142
+ }
143
+ }
144
+ assert!(found_braille, "Canvas should render Braille characters");
145
+ }
146
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use crate::rendering::render_node;
5
+ use magnus::{prelude::*, Error, Value};
6
+ use ratatui::{
7
+ layout::{Constraint, Direction, Layout, Rect},
8
+ widgets::Clear,
9
+ Frame,
10
+ };
11
+
12
+ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
13
+ let child: Value = node.funcall("child", ())?;
14
+ let width_percent: u16 = node.funcall("width_percent", ())?;
15
+ let height_percent: u16 = node.funcall("height_percent", ())?;
16
+
17
+ let popup_layout = Layout::default()
18
+ .direction(Direction::Vertical)
19
+ .constraints([
20
+ Constraint::Percentage((100 - height_percent) / 2),
21
+ Constraint::Percentage(height_percent),
22
+ Constraint::Percentage((100 - height_percent) / 2),
23
+ ])
24
+ .split(area);
25
+
26
+ let vertical_center_area = popup_layout[1];
27
+
28
+ let popup_layout_horizontal = Layout::default()
29
+ .direction(Direction::Horizontal)
30
+ .constraints([
31
+ Constraint::Percentage((100 - width_percent) / 2),
32
+ Constraint::Percentage(width_percent),
33
+ Constraint::Percentage((100 - width_percent) / 2),
34
+ ])
35
+ .split(vertical_center_area);
36
+
37
+ let center_area = popup_layout_horizontal[1];
38
+
39
+ frame.render_widget(Clear, center_area);
40
+ render_node(frame, center_area, child)?;
41
+ Ok(())
42
+ }
43
+
44
+ #[cfg(test)]
45
+ mod tests {
46
+
47
+ use ratatui::layout::{Constraint, Direction, Layout, Rect};
48
+
49
+ #[test]
50
+ fn test_center_logic() {
51
+ let area = Rect::new(0, 0, 100, 100);
52
+ let width_percent = 50;
53
+ let height_percent = 50;
54
+
55
+ let popup_layout = Layout::default()
56
+ .direction(Direction::Vertical)
57
+ .constraints([
58
+ Constraint::Percentage((100 - height_percent) / 2),
59
+ Constraint::Percentage(height_percent),
60
+ Constraint::Percentage((100 - height_percent) / 2),
61
+ ])
62
+ .split(area);
63
+ let vertical_center = popup_layout[1];
64
+
65
+ // Vertical check
66
+ assert_eq!(vertical_center.height, 50);
67
+
68
+ let popup_layout_horizontal = Layout::default()
69
+ .direction(Direction::Horizontal)
70
+ .constraints([
71
+ Constraint::Percentage((100 - width_percent) / 2),
72
+ Constraint::Percentage(width_percent),
73
+ Constraint::Percentage((100 - width_percent) / 2),
74
+ ])
75
+ .split(vertical_center);
76
+ let center = popup_layout_horizontal[1];
77
+
78
+ // Horizontal check
79
+ assert_eq!(center.width, 50);
80
+ }
81
+ }
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use magnus::{prelude::*, Error, Value};
5
+ use ratatui::{layout::Rect, Frame};
6
+
7
+ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
8
+ let x: u16 = node.funcall("x", ())?;
9
+ let y: u16 = node.funcall("y", ())?;
10
+ frame.set_cursor(area.x + x, area.y + y);
11
+ Ok(())
12
+ }
13
+
14
+ #[cfg(test)]
15
+ mod tests {
16
+
17
+ use ratatui::layout::Rect;
18
+
19
+ #[test]
20
+ fn test_cursor_math() {
21
+ let area = Rect::new(10, 10, 50, 50);
22
+ let x = 5;
23
+ let y = 5;
24
+ let abs_x = area.x + x;
25
+ let abs_y = area.y + y;
26
+ assert_eq!(abs_x, 15);
27
+ assert_eq!(abs_y, 15);
28
+ }
29
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use crate::style::{parse_block, parse_style};
5
+ use magnus::{prelude::*, Error, Value};
6
+ use ratatui::{layout::Rect, widgets::Gauge, Frame};
7
+
8
+ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
9
+ let ratio: f64 = node.funcall("ratio", ())?;
10
+ let label_val: Value = node.funcall("label", ())?;
11
+ let style_val: Value = node.funcall("style", ())?;
12
+ let block_val: Value = node.funcall("block", ())?;
13
+
14
+ let mut gauge = Gauge::default().ratio(ratio);
15
+
16
+ if !label_val.is_nil() {
17
+ let label_str: String = label_val.funcall("to_s", ())?;
18
+ gauge = gauge.label(label_str);
19
+ }
20
+
21
+ if !style_val.is_nil() {
22
+ gauge = gauge.gauge_style(parse_style(style_val)?);
23
+ }
24
+
25
+ if !block_val.is_nil() {
26
+ gauge = gauge.block(parse_block(block_val)?);
27
+ }
28
+
29
+ frame.render_widget(gauge, area);
30
+ Ok(())
31
+ }
32
+
33
+ #[cfg(test)]
34
+ mod tests {
35
+ use super::*;
36
+ use ratatui::buffer::Buffer;
37
+ use ratatui::widgets::{Gauge, Widget};
38
+
39
+ #[test]
40
+ fn test_gauge_rendering() {
41
+ let gauge = Gauge::default().ratio(0.5).label("50%");
42
+ let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
43
+ gauge.render(Rect::new(0, 0, 10, 1), &mut buf);
44
+ // Gauge renders block characters
45
+ assert!(buf.content().iter().any(|c| c.symbol() != " "));
46
+ // Should contain label
47
+ assert!(buf.content().iter().any(|c| c.symbol() == "5"));
48
+ assert!(buf.content().iter().any(|c| c.symbol() == "%"));
49
+ }
50
+ }
@@ -0,0 +1,82 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use crate::rendering::render_node;
5
+ use magnus::{prelude::*, Error, Symbol, Value};
6
+ use ratatui::{
7
+ layout::{Constraint, Direction, Layout, Rect},
8
+ Frame,
9
+ };
10
+
11
+ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
12
+ let direction_sym: Symbol = node.funcall("direction", ())?;
13
+ let children_val: Value = node.funcall("children", ())?;
14
+ let children_array = magnus::RArray::from_value(children_val)
15
+ .ok_or_else(|| Error::new(magnus::exception::type_error(), "expected array"))?;
16
+
17
+ let constraints_val: Value = node.funcall("constraints", ())?;
18
+ let constraints_array = magnus::RArray::from_value(constraints_val);
19
+
20
+ let direction = if direction_sym.to_string() == "vertical" {
21
+ Direction::Vertical
22
+ } else {
23
+ Direction::Horizontal
24
+ };
25
+
26
+ let len = children_array.len();
27
+ if len > 0 {
28
+ let mut ratatui_constraints = Vec::new();
29
+
30
+ if let Some(arr) = constraints_array {
31
+ for i in 0..arr.len() {
32
+ let constraint_obj: Value = arr.entry(i as isize)?;
33
+ let type_sym: Symbol = constraint_obj.funcall("type", ())?;
34
+ let value: u16 = constraint_obj.funcall("value", ())?;
35
+
36
+ match type_sym.to_string().as_str() {
37
+ "length" => ratatui_constraints.push(Constraint::Length(value)),
38
+ "percentage" => ratatui_constraints.push(Constraint::Percentage(value)),
39
+ "min" => ratatui_constraints.push(Constraint::Min(value)),
40
+ _ => {}
41
+ }
42
+ }
43
+ }
44
+
45
+ // If constraints don't match children, adjust or default
46
+ if ratatui_constraints.len() != len {
47
+ ratatui_constraints = (0..len)
48
+ .map(|_| Constraint::Percentage(100 / (len as u16).max(1)))
49
+ .collect();
50
+ }
51
+
52
+ let chunks = Layout::default()
53
+ .direction(direction)
54
+ .constraints(ratatui_constraints)
55
+ .split(area);
56
+
57
+ for i in 0..len {
58
+ let child: Value = children_array.entry(i as isize)?;
59
+ if let Err(e) = render_node(frame, chunks[i], child) {
60
+ eprintln!("Error rendering child {}: {:?}", i, e);
61
+ }
62
+ }
63
+ }
64
+ Ok(())
65
+ }
66
+
67
+ #[cfg(test)]
68
+ mod tests {
69
+
70
+ use ratatui::layout::{Constraint, Direction, Layout, Rect};
71
+
72
+ #[test]
73
+ fn test_layout_logic() {
74
+ let area = Rect::new(0, 0, 100, 100);
75
+ let chunks = Layout::default()
76
+ .direction(Direction::Vertical)
77
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
78
+ .split(area);
79
+ assert_eq!(chunks.len(), 2);
80
+ assert_eq!(chunks[0].height, 50);
81
+ }
82
+ }