ratatui_ruby 0.5.0 → 0.6.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +1 -1
  3. data/.builds/ruby-3.3.yml +1 -1
  4. data/.builds/ruby-3.4.yml +1 -1
  5. data/.builds/ruby-4.0.0.yml +1 -1
  6. data/AGENTS.md +6 -0
  7. data/CHANGELOG.md +44 -7
  8. data/README.md +11 -4
  9. data/REUSE.toml +2 -7
  10. data/doc/application_architecture.md +84 -10
  11. data/doc/application_testing.md +75 -29
  12. data/doc/contributors/design/ruby_frontend.md +39 -3
  13. data/doc/contributors/design/rust_backend.md +1 -0
  14. data/doc/contributors/developing_examples.md +129 -44
  15. data/doc/contributors/examples_audit/p1_high.md +21 -0
  16. data/doc/contributors/examples_audit/p2_moderate.md +81 -0
  17. data/doc/contributors/examples_audit.md +41 -0
  18. data/doc/event_handling.md +11 -3
  19. data/doc/images/app_all_events.png +0 -0
  20. data/doc/images/app_color_picker.png +0 -0
  21. data/doc/images/app_login_form.png +0 -0
  22. data/doc/images/app_stateful_interaction.png +0 -0
  23. data/doc/images/verify_quickstart_dsl.png +0 -0
  24. data/doc/images/verify_quickstart_layout.png +0 -0
  25. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  26. data/doc/images/verify_readme_usage.png +0 -0
  27. data/doc/images/widget_barchart_demo.png +0 -0
  28. data/doc/images/widget_block_demo.png +0 -0
  29. data/doc/images/widget_canvas_demo.png +0 -0
  30. data/doc/images/widget_cell_demo.png +0 -0
  31. data/doc/images/widget_center_demo.png +0 -0
  32. data/doc/images/widget_chart_demo.png +0 -0
  33. data/doc/images/widget_list_demo.png +0 -0
  34. data/doc/images/widget_overlay_demo.png +0 -0
  35. data/doc/images/widget_render.png +0 -0
  36. data/doc/images/widget_rich_text.png +0 -0
  37. data/doc/images/widget_scroll_text.png +0 -0
  38. data/doc/images/widget_sparkline_demo.png +0 -0
  39. data/doc/images/widget_table_demo.png +0 -0
  40. data/doc/images/widget_tabs_demo.png +0 -0
  41. data/doc/images/widget_text_width.png +0 -0
  42. data/doc/quickstart.md +69 -76
  43. data/doc/terminal_limitations.md +92 -0
  44. data/examples/app_all_events/README.md +45 -27
  45. data/examples/app_all_events/app.rb +38 -35
  46. data/examples/app_all_events/model/app_model.rb +157 -0
  47. data/examples/app_all_events/model/event_entry.rb +17 -0
  48. data/examples/app_all_events/model/msg.rb +37 -0
  49. data/examples/app_all_events/update.rb +73 -0
  50. data/examples/app_all_events/view/app_view.rb +8 -8
  51. data/examples/app_all_events/view/controls_view.rb +8 -6
  52. data/examples/app_all_events/view/counts_view.rb +12 -8
  53. data/examples/app_all_events/view/live_view.rb +8 -7
  54. data/examples/app_all_events/view/log_view.rb +10 -15
  55. data/examples/app_color_picker/README.md +84 -44
  56. data/examples/app_color_picker/app.rb +24 -62
  57. data/examples/app_color_picker/controls.rb +90 -0
  58. data/examples/app_color_picker/copy_dialog.rb +45 -49
  59. data/examples/app_color_picker/export_pane.rb +126 -0
  60. data/examples/app_color_picker/input.rb +99 -67
  61. data/examples/app_color_picker/main_container.rb +178 -0
  62. data/examples/app_color_picker/palette.rb +55 -26
  63. data/examples/app_login_form/README.md +47 -0
  64. data/examples/app_login_form/app.rb +2 -3
  65. data/examples/app_stateful_interaction/README.md +31 -0
  66. data/examples/app_stateful_interaction/app.rb +272 -0
  67. data/examples/timeout_demo.rb +43 -0
  68. data/examples/verify_quickstart_dsl/README.md +48 -0
  69. data/examples/verify_quickstart_dsl/app.rb +2 -0
  70. data/examples/verify_quickstart_layout/README.md +71 -0
  71. data/examples/verify_quickstart_layout/app.rb +2 -0
  72. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  73. data/examples/verify_quickstart_lifecycle/app.rb +8 -2
  74. data/examples/verify_readme_usage/README.md +43 -0
  75. data/examples/verify_readme_usage/app.rb +8 -2
  76. data/examples/widget_barchart_demo/README.md +49 -0
  77. data/examples/widget_barchart_demo/app.rb +5 -5
  78. data/examples/widget_block_demo/README.md +34 -0
  79. data/examples/widget_block_demo/app.rb +256 -0
  80. data/examples/widget_box_demo/README.md +45 -0
  81. data/examples/widget_calendar_demo/README.md +39 -0
  82. data/examples/widget_canvas_demo/README.md +27 -0
  83. data/examples/widget_canvas_demo/app.rb +123 -0
  84. data/examples/widget_cell_demo/README.md +36 -0
  85. data/examples/widget_cell_demo/app.rb +31 -24
  86. data/examples/widget_center_demo/README.md +29 -0
  87. data/examples/widget_center_demo/app.rb +116 -0
  88. data/examples/widget_chart_demo/README.md +41 -0
  89. data/examples/widget_chart_demo/app.rb +7 -2
  90. data/examples/widget_gauge_demo/README.md +41 -0
  91. data/examples/widget_layout_split/README.md +44 -0
  92. data/examples/widget_line_gauge_demo/README.md +41 -0
  93. data/examples/widget_list_demo/README.md +49 -0
  94. data/examples/widget_list_demo/app.rb +91 -107
  95. data/examples/widget_map_demo/README.md +39 -0
  96. data/examples/{app_map_demo → widget_map_demo}/app.rb +2 -2
  97. data/examples/widget_overlay_demo/app.rb +248 -0
  98. data/examples/widget_popup_demo/README.md +36 -0
  99. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  100. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  101. data/examples/widget_rect/README.md +38 -0
  102. data/examples/widget_render/README.md +37 -0
  103. data/examples/widget_rich_text/README.md +35 -0
  104. data/examples/widget_rich_text/app.rb +62 -33
  105. data/examples/widget_scroll_text/README.md +37 -0
  106. data/examples/widget_scroll_text/app.rb +0 -1
  107. data/examples/widget_scrollbar_demo/README.md +37 -0
  108. data/examples/widget_sparkline_demo/README.md +42 -0
  109. data/examples/widget_sparkline_demo/app.rb +4 -3
  110. data/examples/widget_style_colors/README.md +34 -0
  111. data/examples/widget_table_demo/README.md +48 -0
  112. data/examples/{app_table_select → widget_table_demo}/app.rb +46 -8
  113. data/examples/widget_tabs_demo/README.md +41 -0
  114. data/examples/widget_tabs_demo/app.rb +15 -1
  115. data/examples/widget_text_width/README.md +35 -0
  116. data/examples/widget_text_width/app.rb +106 -0
  117. data/exe/.gitkeep +0 -0
  118. data/ext/ratatui_ruby/Cargo.lock +11 -4
  119. data/ext/ratatui_ruby/Cargo.toml +2 -1
  120. data/ext/ratatui_ruby/src/events.rs +238 -26
  121. data/ext/ratatui_ruby/src/frame.rs +113 -1
  122. data/ext/ratatui_ruby/src/lib.rs +34 -4
  123. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  124. data/ext/ratatui_ruby/src/terminal.rs +39 -15
  125. data/ext/ratatui_ruby/src/text.rs +1 -1
  126. data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
  127. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  128. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  129. data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
  130. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  131. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  132. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
  133. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  134. data/ext/ratatui_ruby/src/widgets/table.rs +113 -1
  135. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  136. data/lib/ratatui_ruby/cell.rb +4 -4
  137. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  138. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  139. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  140. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  141. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  142. data/lib/ratatui_ruby/event/key.rb +111 -51
  143. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  144. data/lib/ratatui_ruby/event/paste.rb +1 -1
  145. data/lib/ratatui_ruby/frame.rb +96 -0
  146. data/lib/ratatui_ruby/list_state.rb +88 -0
  147. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
  148. data/lib/ratatui_ruby/schema/cursor.rb +5 -0
  149. data/lib/ratatui_ruby/schema/gauge.rb +3 -1
  150. data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
  151. data/lib/ratatui_ruby/schema/list.rb +25 -4
  152. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  153. data/lib/ratatui_ruby/schema/rect.rb +43 -0
  154. data/lib/ratatui_ruby/schema/style.rb +24 -4
  155. data/lib/ratatui_ruby/schema/table.rb +21 -3
  156. data/lib/ratatui_ruby/schema/text.rb +69 -1
  157. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  158. data/lib/ratatui_ruby/session/autodoc.rb +65 -0
  159. data/lib/ratatui_ruby/session.rb +22 -7
  160. data/lib/ratatui_ruby/table_state.rb +90 -0
  161. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  162. data/lib/ratatui_ruby/test_helper/snapshot.rb +390 -0
  163. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  164. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  165. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  166. data/lib/ratatui_ruby/test_helper.rb +65 -358
  167. data/lib/ratatui_ruby/version.rb +1 -1
  168. data/lib/ratatui_ruby.rb +42 -19
  169. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  170. data/sig/examples/widget_block_demo/app.rbs +32 -0
  171. data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
  172. data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
  173. data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
  174. data/sig/ratatui_ruby/event.rbs +11 -1
  175. data/sig/ratatui_ruby/frame.rbs +2 -0
  176. data/sig/ratatui_ruby/list_state.rbs +13 -0
  177. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
  178. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  179. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  180. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  181. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  182. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  183. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  184. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  185. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  186. data/sig/ratatui_ruby/schema/text.rbs +8 -6
  187. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  188. data/sig/ratatui_ruby/session.rbs +13 -0
  189. data/sig/ratatui_ruby/table_state.rbs +15 -0
  190. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  191. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  192. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  193. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  194. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  195. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  196. data/tasks/autodoc/examples.rb +79 -0
  197. data/tasks/autodoc/inventory.rb +9 -7
  198. data/tasks/autodoc.rake +11 -5
  199. data/tasks/bump/changelog.rb +3 -3
  200. data/tasks/bump/links.rb +67 -0
  201. data/tasks/sourcehut.rake +61 -21
  202. data/tasks/terminal_preview/app_screenshot.rb +13 -3
  203. data/tasks/terminal_preview/saved_screenshot.rb +4 -3
  204. metadata +111 -37
  205. data/doc/images/app_table_select.png +0 -0
  206. data/doc/images/widget_block_padding.png +0 -0
  207. data/doc/images/widget_block_titles.png +0 -0
  208. data/doc/images/widget_list_styles.png +0 -0
  209. data/examples/app_all_events/model/events.rb +0 -180
  210. data/examples/app_all_events/model/highlight.rb +0 -57
  211. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
  212. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
  213. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
  214. data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
  215. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
  216. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
  217. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
  218. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
  219. data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
  220. data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
  221. data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
  222. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
  223. data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
  224. data/examples/app_all_events/view_state.rb +0 -42
  225. data/examples/app_color_picker/scene.rb +0 -201
  226. data/examples/widget_block_padding/app.rb +0 -67
  227. data/examples/widget_block_titles/app.rb +0 -69
  228. data/examples/widget_list_styles/app.rb +0 -141
  229. data/examples/widget_table_flex/app.rb +0 -95
  230. data/sig/examples/widget_block_padding/app.rbs +0 -11
  231. data/sig/examples/widget_block_titles/app.rbs +0 -11
  232. data/sig/examples/widget_list_styles/app.rbs +0 -11
  233. data/tasks/bump/comparison_links.rb +0 -41
  234. /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
@@ -2,8 +2,9 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::parse_block;
5
+ use crate::widgets::scrollbar_state::RubyScrollbarState;
5
6
  use bumpalo::Bump;
6
- use magnus::{prelude::*, Error, Symbol, Value};
7
+ use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
7
8
  use ratatui::{
8
9
  layout::Rect,
9
10
  widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
@@ -89,6 +90,97 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
89
90
  Ok(())
90
91
  }
91
92
 
93
+ /// Renders a Scrollbar with an external state object.
94
+ ///
95
+ /// The State object is the single source of truth for position and `content_length`.
96
+ /// Widget properties (`position`, `content_length`) are ignored.
97
+ pub fn render_stateful(
98
+ frame: &mut Frame,
99
+ area: Rect,
100
+ node: Value,
101
+ state_wrapper: Value,
102
+ ) -> Result<(), Error> {
103
+ // Extract the RubyScrollbarState wrapper
104
+ let state: &RubyScrollbarState = TryConvert::try_convert(state_wrapper)?;
105
+
106
+ let orientation_sym: Symbol = node.funcall("orientation", ())?;
107
+ let thumb_symbol_val: Value = node.funcall("thumb_symbol", ())?;
108
+ let thumb_style_val: Value = node.funcall("thumb_style", ())?;
109
+ let track_symbol_val: Value = node.funcall("track_symbol", ())?;
110
+ let track_style_val: Value = node.funcall("track_style", ())?;
111
+ let begin_symbol_val: Value = node.funcall("begin_symbol", ())?;
112
+ let begin_style_val: Value = node.funcall("begin_style", ())?;
113
+ let end_symbol_val: Value = node.funcall("end_symbol", ())?;
114
+ let end_style_val: Value = node.funcall("end_style", ())?;
115
+ let style_val: Value = node.funcall("style", ())?;
116
+ let block_val: Value = node.funcall("block", ())?;
117
+
118
+ let mut scrollbar = Scrollbar::default();
119
+
120
+ scrollbar = match orientation_sym.to_string().as_str() {
121
+ "vertical_left" => scrollbar.orientation(ScrollbarOrientation::VerticalLeft),
122
+ "horizontal_bottom" | "horizontal" => {
123
+ scrollbar.orientation(ScrollbarOrientation::HorizontalBottom)
124
+ }
125
+ "horizontal_top" => scrollbar.orientation(ScrollbarOrientation::HorizontalTop),
126
+ _ => scrollbar.orientation(ScrollbarOrientation::VerticalRight),
127
+ };
128
+
129
+ // Hoisted strings to extend lifetime
130
+ let thumb_str: String;
131
+ let track_str: String;
132
+ let begin_str: String;
133
+ let end_str: String;
134
+
135
+ if !thumb_symbol_val.is_nil() {
136
+ thumb_str = thumb_symbol_val.funcall("to_s", ())?;
137
+ scrollbar = scrollbar.thumb_symbol(&thumb_str);
138
+ }
139
+ if !thumb_style_val.is_nil() {
140
+ scrollbar = scrollbar.thumb_style(crate::style::parse_style(thumb_style_val)?);
141
+ }
142
+ if !track_symbol_val.is_nil() {
143
+ track_str = track_symbol_val.funcall("to_s", ())?;
144
+ scrollbar = scrollbar.track_symbol(Some(&track_str));
145
+ }
146
+ if !track_style_val.is_nil() {
147
+ scrollbar = scrollbar.track_style(crate::style::parse_style(track_style_val)?);
148
+ }
149
+ if !begin_symbol_val.is_nil() {
150
+ begin_str = begin_symbol_val.funcall("to_s", ())?;
151
+ scrollbar = scrollbar.begin_symbol(Some(&begin_str));
152
+ }
153
+ if !begin_style_val.is_nil() {
154
+ scrollbar = scrollbar.begin_style(crate::style::parse_style(begin_style_val)?);
155
+ }
156
+ if !end_symbol_val.is_nil() {
157
+ end_str = end_symbol_val.funcall("to_s", ())?;
158
+ scrollbar = scrollbar.end_symbol(Some(&end_str));
159
+ }
160
+ if !end_style_val.is_nil() {
161
+ scrollbar = scrollbar.end_style(crate::style::parse_style(end_style_val)?);
162
+ }
163
+ if !style_val.is_nil() {
164
+ scrollbar = scrollbar.style(crate::style::parse_style(style_val)?);
165
+ }
166
+
167
+ // Borrow the inner ScrollbarState, render, and release the borrow immediately
168
+ {
169
+ let mut inner_state = state.borrow_mut();
170
+ if block_val.is_nil() {
171
+ frame.render_stateful_widget(scrollbar, area, &mut inner_state);
172
+ } else {
173
+ let bump = Bump::new();
174
+ let block = parse_block(block_val, &bump)?;
175
+ let inner_area = block.inner(area);
176
+ frame.render_widget(block, area);
177
+ frame.render_stateful_widget(scrollbar, inner_area, &mut inner_state);
178
+ }
179
+ }
180
+
181
+ Ok(())
182
+ }
183
+
92
184
  #[cfg(test)]
93
185
  mod tests {
94
186
  use super::*;
@@ -0,0 +1,169 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! `ScrollbarState` wrapper for exposing Ratatui's `ScrollbarState` to Ruby.
5
+ //!
6
+ //! This module provides `RubyScrollbarState`, a Magnus-wrapped struct that holds
7
+ //! a `RefCell<ScrollbarState>` for interior mutability during stateful rendering.
8
+
9
+ use magnus::{function, method, prelude::*, Error, Module, Ruby};
10
+ use ratatui::widgets::ScrollbarState;
11
+ use std::cell::RefCell;
12
+
13
+ /// A wrapper around Ratatui's `ScrollbarState` exposed to Ruby.
14
+ ///
15
+ /// Ratatui's `ScrollbarState` doesn't expose getters for `position`, `content_length`,
16
+ /// or `viewport_content_length`. We track these values internally.
17
+ #[magnus::wrap(class = "RatatuiRuby::ScrollbarState")]
18
+ pub struct RubyScrollbarState {
19
+ inner: RefCell<ScrollbarState>,
20
+ /// We store these values ourselves since Ratatui's `ScrollbarState`
21
+ /// doesn't expose getters for them.
22
+ position_val: RefCell<usize>,
23
+ content_len: RefCell<usize>,
24
+ viewport_len: RefCell<usize>,
25
+ }
26
+
27
+ impl RubyScrollbarState {
28
+ /// Creates a new `RubyScrollbarState` with the given content length.
29
+ pub fn new(content_length: usize) -> Self {
30
+ Self {
31
+ inner: RefCell::new(ScrollbarState::new(content_length)),
32
+ position_val: RefCell::new(0),
33
+ content_len: RefCell::new(content_length),
34
+ viewport_len: RefCell::new(0),
35
+ }
36
+ }
37
+
38
+ /// Returns the current scroll position.
39
+ pub fn position(&self) -> usize {
40
+ *self.position_val.borrow()
41
+ }
42
+
43
+ /// Sets the current scroll position.
44
+ pub fn set_position(&self, position: usize) {
45
+ *self.position_val.borrow_mut() = position;
46
+ let mut state = self.inner.borrow_mut();
47
+ *state = state.position(position);
48
+ }
49
+
50
+ /// Returns the total content length.
51
+ pub fn content_length(&self) -> usize {
52
+ *self.content_len.borrow()
53
+ }
54
+
55
+ /// Sets the total content length.
56
+ pub fn set_content_length(&self, length: usize) {
57
+ *self.content_len.borrow_mut() = length;
58
+ let mut state = self.inner.borrow_mut();
59
+ *state = state.content_length(length);
60
+ }
61
+
62
+ /// Returns the viewport content length.
63
+ pub fn viewport_content_length(&self) -> usize {
64
+ *self.viewport_len.borrow()
65
+ }
66
+
67
+ /// Sets the viewport content length.
68
+ pub fn set_viewport_content_length(&self, length: usize) {
69
+ *self.viewport_len.borrow_mut() = length;
70
+ let mut state = self.inner.borrow_mut();
71
+ *state = state.viewport_content_length(length);
72
+ }
73
+
74
+ /// Scrolls to the first position.
75
+ pub fn first(&self) {
76
+ *self.position_val.borrow_mut() = 0;
77
+ self.inner.borrow_mut().first();
78
+ }
79
+
80
+ /// Scrolls to the last position.
81
+ pub fn last(&self) {
82
+ let content_len = *self.content_len.borrow();
83
+ let new_pos = content_len.saturating_sub(1);
84
+ *self.position_val.borrow_mut() = new_pos;
85
+ self.inner.borrow_mut().last();
86
+ }
87
+
88
+ /// Scrolls to the next position.
89
+ pub fn next(&self) {
90
+ let content_len = *self.content_len.borrow();
91
+ let current = *self.position_val.borrow();
92
+ let new_pos = (current + 1).min(content_len.saturating_sub(1));
93
+ *self.position_val.borrow_mut() = new_pos;
94
+ self.inner.borrow_mut().next();
95
+ }
96
+
97
+ /// Scrolls to the previous position.
98
+ pub fn prev(&self) {
99
+ let current = *self.position_val.borrow();
100
+ let new_pos = current.saturating_sub(1);
101
+ *self.position_val.borrow_mut() = new_pos;
102
+ self.inner.borrow_mut().prev();
103
+ }
104
+
105
+ /// Borrows the inner `ScrollbarState` mutably for rendering.
106
+ pub fn borrow_mut(&self) -> std::cell::RefMut<'_, ScrollbarState> {
107
+ self.inner.borrow_mut()
108
+ }
109
+ }
110
+
111
+ /// Registers the `ScrollbarState` class with Ruby.
112
+ pub fn register(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
113
+ let class = module.define_class("ScrollbarState", ruby.class_object())?;
114
+ class.define_singleton_method("new", function!(RubyScrollbarState::new, 1))?;
115
+ class.define_method("position", method!(RubyScrollbarState::position, 0))?;
116
+ class.define_method("position=", method!(RubyScrollbarState::set_position, 1))?;
117
+ class.define_method(
118
+ "content_length",
119
+ method!(RubyScrollbarState::content_length, 0),
120
+ )?;
121
+ class.define_method(
122
+ "content_length=",
123
+ method!(RubyScrollbarState::set_content_length, 1),
124
+ )?;
125
+ class.define_method(
126
+ "viewport_content_length",
127
+ method!(RubyScrollbarState::viewport_content_length, 0),
128
+ )?;
129
+ class.define_method(
130
+ "viewport_content_length=",
131
+ method!(RubyScrollbarState::set_viewport_content_length, 1),
132
+ )?;
133
+ class.define_method("first", method!(RubyScrollbarState::first, 0))?;
134
+ class.define_method("last", method!(RubyScrollbarState::last, 0))?;
135
+ class.define_method("next", method!(RubyScrollbarState::next, 0))?;
136
+ class.define_method("prev", method!(RubyScrollbarState::prev, 0))?;
137
+ Ok(())
138
+ }
139
+
140
+ #[cfg(test)]
141
+ mod tests {
142
+ use super::*;
143
+
144
+ #[test]
145
+ fn test_new_with_content_length() {
146
+ let state = RubyScrollbarState::new(100);
147
+ assert_eq!(state.content_length(), 100);
148
+ assert_eq!(state.position(), 0);
149
+ }
150
+
151
+ #[test]
152
+ fn test_position_navigation() {
153
+ let state = RubyScrollbarState::new(10);
154
+ state.next();
155
+ assert_eq!(state.position(), 1);
156
+ state.prev();
157
+ assert_eq!(state.position(), 0);
158
+ }
159
+
160
+ #[test]
161
+ fn test_first_and_last() {
162
+ let state = RubyScrollbarState::new(10);
163
+ state.set_position(5);
164
+ state.first();
165
+ assert_eq!(state.position(), 0);
166
+ state.last();
167
+ assert_eq!(state.position(), 9);
168
+ }
169
+ }
@@ -2,8 +2,9 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::widgets::table_state::RubyTableState;
5
6
  use bumpalo::Bump;
6
- use magnus::{prelude::*, Error, Symbol, Value};
7
+ use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
7
8
  use ratatui::{
8
9
  layout::{Constraint, Flex, Rect},
9
10
  widgets::{Cell, HighlightSpacing, Row, Table, TableState},
@@ -110,10 +111,121 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
110
111
  state.select_column(Some(index));
111
112
  }
112
113
 
114
+ let offset_val: Value = node.funcall("offset", ())?;
115
+ if !offset_val.is_nil() {
116
+ let offset: usize = offset_val.funcall("to_int", ())?;
117
+ *state.offset_mut() = offset;
118
+ }
119
+
113
120
  frame.render_stateful_widget(table, area, &mut state);
114
121
  Ok(())
115
122
  }
116
123
 
124
+ /// Renders a Table with an external state object.
125
+ ///
126
+ /// This function ignores `selected_row`, `selected_column`, and `offset` from the widget.
127
+ /// The State object is the single source of truth for selection and scroll position.
128
+ pub fn render_stateful(
129
+ frame: &mut Frame,
130
+ area: Rect,
131
+ node: Value,
132
+ state_wrapper: Value,
133
+ ) -> Result<(), Error> {
134
+ let bump = Bump::new();
135
+ let ruby = magnus::Ruby::get().unwrap();
136
+
137
+ // Extract the RubyTableState wrapper
138
+ let state: &RubyTableState = TryConvert::try_convert(state_wrapper)?;
139
+
140
+ // Parse rows
141
+ let rows_value: Value = node.funcall("rows", ())?;
142
+ let rows_array = magnus::RArray::from_value(rows_value)
143
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for rows"))?;
144
+ let widths_val: Value = node.funcall("widths", ())?;
145
+ let widths_array = magnus::RArray::from_value(widths_val)
146
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for widths"))?;
147
+
148
+ let mut rows = Vec::new();
149
+ for i in 0..rows_array.len() {
150
+ let index = isize::try_from(i)
151
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
152
+ let row_val: Value = rows_array.entry(index)?;
153
+ rows.push(parse_row(row_val)?);
154
+ }
155
+
156
+ let constraints = parse_constraints(widths_array)?;
157
+
158
+ // Build table (ignoring selected_row, selected_column, offset — State is truth)
159
+ let header_val: Value = node.funcall("header", ())?;
160
+ let footer_val: Value = node.funcall("footer", ())?;
161
+ let highlight_style_val: Value = node.funcall("highlight_style", ())?;
162
+ let column_highlight_style_val: Value = node.funcall("column_highlight_style", ())?;
163
+ let cell_highlight_style_val: Value = node.funcall("cell_highlight_style", ())?;
164
+ let highlight_symbol_val: Value = node.funcall("highlight_symbol", ())?;
165
+ let block_val: Value = node.funcall("block", ())?;
166
+ let flex_sym: Symbol = node.funcall("flex", ())?;
167
+ let highlight_spacing_sym: Symbol = node.funcall("highlight_spacing", ())?;
168
+ let style_val: Value = node.funcall("style", ())?;
169
+ let column_spacing_val: Value = node.funcall("column_spacing", ())?;
170
+
171
+ let flex = match flex_sym.to_string().as_str() {
172
+ "start" => Flex::Start,
173
+ "center" => Flex::Center,
174
+ "end" => Flex::End,
175
+ "space_between" => Flex::SpaceBetween,
176
+ "space_around" => Flex::SpaceAround,
177
+ "space_evenly" => Flex::SpaceEvenly,
178
+ _ => Flex::Legacy,
179
+ };
180
+
181
+ let mut table = Table::new(rows, constraints).flex(flex);
182
+
183
+ let highlight_spacing = match highlight_spacing_sym.to_string().as_str() {
184
+ "always" => HighlightSpacing::Always,
185
+ "never" => HighlightSpacing::Never,
186
+ _ => HighlightSpacing::WhenSelected,
187
+ };
188
+ table = table.highlight_spacing(highlight_spacing);
189
+
190
+ if !header_val.is_nil() {
191
+ table = table.header(parse_row(header_val)?);
192
+ }
193
+ if !footer_val.is_nil() {
194
+ table = table.footer(parse_row(footer_val)?);
195
+ }
196
+ if !block_val.is_nil() {
197
+ table = table.block(parse_block(block_val, &bump)?);
198
+ }
199
+ if !highlight_style_val.is_nil() {
200
+ table = table.row_highlight_style(parse_style(highlight_style_val)?);
201
+ }
202
+ if !column_highlight_style_val.is_nil() {
203
+ table = table.column_highlight_style(parse_style(column_highlight_style_val)?);
204
+ }
205
+ if !cell_highlight_style_val.is_nil() {
206
+ table = table.cell_highlight_style(parse_style(cell_highlight_style_val)?);
207
+ }
208
+ if !highlight_symbol_val.is_nil() {
209
+ let symbol: String = highlight_symbol_val.funcall("to_s", ())?;
210
+ table = table.highlight_symbol(symbol);
211
+ }
212
+ if !style_val.is_nil() {
213
+ table = table.style(parse_style(style_val)?);
214
+ }
215
+ if !column_spacing_val.is_nil() {
216
+ let spacing: u16 = column_spacing_val.funcall("to_int", ())?;
217
+ table = table.column_spacing(spacing);
218
+ }
219
+
220
+ // Borrow the inner TableState, render, and release the borrow immediately
221
+ {
222
+ let mut inner_state = state.borrow_mut();
223
+ frame.render_stateful_widget(table, area, &mut inner_state);
224
+ }
225
+
226
+ Ok(())
227
+ }
228
+
117
229
  fn parse_row(row_val: Value) -> Result<Row<'static>, Error> {
118
230
  let ruby = magnus::Ruby::get().unwrap();
119
231
  let row_array = magnus::RArray::from_value(row_val)
@@ -0,0 +1,121 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! `TableState` wrapper for exposing Ratatui's `TableState` to Ruby.
5
+ //!
6
+ //! This module provides `RubyTableState`, a Magnus-wrapped struct that holds
7
+ //! a `RefCell<TableState>` for interior mutability during stateful rendering.
8
+ //!
9
+ //! # Design
10
+ //!
11
+ //! When using `render_stateful_widget`, the State object is the single source
12
+ //! of truth for selection and offset. Widget properties (`selected_row`,
13
+ //! `selected_column`, `offset`) are ignored in stateful mode.
14
+
15
+ use magnus::{function, method, prelude::*, Error, Module, Ruby};
16
+ use ratatui::widgets::TableState;
17
+ use std::cell::RefCell;
18
+
19
+ /// A wrapper around Ratatui's `TableState` exposed to Ruby.
20
+ #[magnus::wrap(class = "RatatuiRuby::TableState")]
21
+ pub struct RubyTableState {
22
+ inner: RefCell<TableState>,
23
+ }
24
+
25
+ impl RubyTableState {
26
+ /// Creates a new `RubyTableState` with optional initial selection.
27
+ pub fn new(selected: Option<usize>) -> Self {
28
+ let mut state = TableState::default();
29
+ if let Some(idx) = selected {
30
+ state.select(Some(idx));
31
+ }
32
+ Self {
33
+ inner: RefCell::new(state),
34
+ }
35
+ }
36
+
37
+ /// Sets the selected row index.
38
+ pub fn select(&self, index: Option<usize>) {
39
+ self.inner.borrow_mut().select(index);
40
+ }
41
+
42
+ /// Returns the currently selected row index.
43
+ pub fn selected(&self) -> Option<usize> {
44
+ self.inner.borrow().selected()
45
+ }
46
+
47
+ /// Sets the selected column index.
48
+ pub fn select_column(&self, index: Option<usize>) {
49
+ self.inner.borrow_mut().select_column(index);
50
+ }
51
+
52
+ /// Returns the currently selected column index.
53
+ pub fn selected_column(&self) -> Option<usize> {
54
+ self.inner.borrow().selected_column()
55
+ }
56
+
57
+ /// Returns the current scroll offset.
58
+ pub fn offset(&self) -> usize {
59
+ self.inner.borrow().offset()
60
+ }
61
+
62
+ /// Scrolls down by the given number of rows.
63
+ pub fn scroll_down_by(&self, amount: u16) {
64
+ self.inner.borrow_mut().scroll_down_by(amount);
65
+ }
66
+
67
+ /// Scrolls up by the given number of rows.
68
+ pub fn scroll_up_by(&self, amount: u16) {
69
+ self.inner.borrow_mut().scroll_up_by(amount);
70
+ }
71
+
72
+ /// Borrows the inner `TableState` mutably for rendering.
73
+ pub fn borrow_mut(&self) -> std::cell::RefMut<'_, TableState> {
74
+ self.inner.borrow_mut()
75
+ }
76
+ }
77
+
78
+ /// Registers the `TableState` class with Ruby.
79
+ pub fn register(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
80
+ let class = module.define_class("TableState", ruby.class_object())?;
81
+ class.define_singleton_method("new", function!(RubyTableState::new, 1))?;
82
+ class.define_method("select", method!(RubyTableState::select, 1))?;
83
+ class.define_method("selected", method!(RubyTableState::selected, 0))?;
84
+ class.define_method("select_column", method!(RubyTableState::select_column, 1))?;
85
+ class.define_method(
86
+ "selected_column",
87
+ method!(RubyTableState::selected_column, 0),
88
+ )?;
89
+ class.define_method("offset", method!(RubyTableState::offset, 0))?;
90
+ class.define_method("scroll_down_by", method!(RubyTableState::scroll_down_by, 1))?;
91
+ class.define_method("scroll_up_by", method!(RubyTableState::scroll_up_by, 1))?;
92
+ Ok(())
93
+ }
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use super::*;
98
+
99
+ #[test]
100
+ fn test_new_with_no_selection() {
101
+ let state = RubyTableState::new(None);
102
+ assert_eq!(state.selected(), None);
103
+ assert_eq!(state.selected_column(), None);
104
+ assert_eq!(state.offset(), 0);
105
+ }
106
+
107
+ #[test]
108
+ fn test_new_with_selection() {
109
+ let state = RubyTableState::new(Some(3));
110
+ assert_eq!(state.selected(), Some(3));
111
+ }
112
+
113
+ #[test]
114
+ fn test_column_selection() {
115
+ let state = RubyTableState::new(None);
116
+ state.select_column(Some(2));
117
+ assert_eq!(state.selected_column(), Some(2));
118
+ state.select_column(None);
119
+ assert_eq!(state.selected_column(), None);
120
+ }
121
+ }
@@ -82,10 +82,10 @@ module RatatuiRuby
82
82
  # [bg] Symbol or String (nullable).
83
83
  # [modifiers] Array of Strings.
84
84
  def initialize(symbol: nil, char: nil, fg: nil, bg: nil, modifiers: [])
85
- @symbol = symbol || char || " "
86
- @fg = fg
87
- @bg = bg
88
- @modifiers = modifiers.freeze
85
+ @symbol = (symbol || char || " ").freeze
86
+ @fg = fg&.freeze
87
+ @bg = bg&.freeze
88
+ @modifiers = modifiers.map(&:freeze).freeze
89
89
  freeze
90
90
  end
91
91
 
@@ -0,0 +1,35 @@
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
+ module RatatuiRuby
7
+ class Event
8
+ class Key < Event
9
+ # Methods for handling printable characters.
10
+ module Character
11
+ # Returns true if the key represents a single printable character.
12
+ #
13
+ # RatatuiRuby::Event::Key.new(code: "a").text? # => true
14
+ # RatatuiRuby::Event::Key.new(code: "enter").text? # => false
15
+ # RatatuiRuby::Event::Key.new(code: "space").text? # => false ("space" is not 1 char, " " is)
16
+ def text?
17
+ @code.length == 1
18
+ end
19
+
20
+ # Returns the key as a printable character (if applicable).
21
+ #
22
+ # [Printable Characters]
23
+ # Returns the character itself (e.g., <tt>"a"</tt>, <tt>"1"</tt>, <tt>" "</tt>).
24
+ # [Special Keys]
25
+ # Returns <tt>nil</tt> (e.g., <tt>"enter"</tt>, <tt>"up"</tt>, <tt>"f1"</tt>).
26
+ #
27
+ # RatatuiRuby::Event::Key.new(code: "a").char # => "a"
28
+ # RatatuiRuby::Event::Key.new(code: "enter").char # => nil
29
+ def char
30
+ text? ? @code : nil
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,44 @@
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
+ module RatatuiRuby
7
+ class Event
8
+ class Key < Event
9
+ # Methods and logic for media keys.
10
+ module Media
11
+ # Returns true if this is a media key.
12
+ #
13
+ # Media keys include: play, pause, stop, track controls, volume controls.
14
+ # These are only available in terminals supporting the Kitty keyboard protocol.
15
+ #
16
+ # event.media? # => true for media_play, media_pause, etc.
17
+ def media?
18
+ @kind == :media
19
+ end
20
+
21
+ # Handles media-specific DWIM logic for method_missing.
22
+ private def match_media_dwim?(key_name)
23
+ return false unless @kind == :media
24
+
25
+ # Allow unprefixed predicate
26
+ # e.g., pause? returns true for media_pause
27
+ if @code.start_with?("media_")
28
+ base_code = @code.delete_prefix("media_")
29
+ return true if key_name == base_code
30
+ end
31
+
32
+ # Bidirectional media overlaps
33
+ # e.g., play? and pause? both match media_play_pause
34
+ return true if @code == "media_play_pause" && (key_name == "play" || key_name == "pause")
35
+
36
+ # e.g., play_pause? matches media_play or media_pause
37
+ return true if key_name == "play_pause" && (@code == "media_play" || @code == "media_pause")
38
+
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end