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,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use magnus::value::ReprValue;
5
- use magnus::Error;
5
+ use magnus::{Error, Module};
6
6
  use ratatui::{
7
7
  backend::{CrosstermBackend, TestBackend},
8
8
  Terminal,
@@ -21,28 +21,32 @@ pub fn init_terminal(focus_events: bool, bracketed_paste: bool) -> Result<(), Er
21
21
  let ruby = magnus::Ruby::get().unwrap();
22
22
  let mut term_lock = TERMINAL.lock().unwrap();
23
23
  if term_lock.is_none() {
24
+ let module = ruby.define_module("RatatuiRuby")?;
25
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
26
+ let error_class = error_base.const_get("Terminal")?;
27
+
24
28
  ratatui::crossterm::terminal::enable_raw_mode()
25
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
29
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
26
30
  let mut stdout = io::stdout();
27
31
  ratatui::crossterm::execute!(
28
32
  stdout,
29
33
  ratatui::crossterm::terminal::EnterAlternateScreen,
30
34
  ratatui::crossterm::event::EnableMouseCapture
31
35
  )
32
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
36
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
33
37
 
34
38
  if focus_events {
35
39
  ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableFocusChange)
36
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
40
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
37
41
  }
38
42
  if bracketed_paste {
39
43
  ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableBracketedPaste)
40
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
44
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
41
45
  }
42
46
 
43
47
  let backend = CrosstermBackend::new(stdout);
44
- let terminal = Terminal::new(backend)
45
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
48
+ let terminal =
49
+ Terminal::new(backend).map_err(|e| Error::new(error_class, e.to_string()))?;
46
50
  *term_lock = Some(TerminalWrapper::Crossterm(terminal));
47
51
  }
48
52
  Ok(())
@@ -52,8 +56,10 @@ pub fn init_test_terminal(width: u16, height: u16) -> Result<(), Error> {
52
56
  let ruby = magnus::Ruby::get().unwrap();
53
57
  let mut term_lock = TERMINAL.lock().unwrap();
54
58
  let backend = TestBackend::new(width, height);
55
- let terminal = Terminal::new(backend)
56
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
59
+ let module = ruby.define_module("RatatuiRuby")?;
60
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
61
+ let error_class = error_base.const_get("Terminal")?;
62
+ let terminal = Terminal::new(backend).map_err(|e| Error::new(error_class, e.to_string()))?;
57
63
  *term_lock = Some(TerminalWrapper::Test(terminal));
58
64
  Ok(())
59
65
  }
@@ -93,8 +99,11 @@ pub fn get_buffer_content() -> Result<String, Error> {
93
99
  }
94
100
  Ok(result)
95
101
  } else {
102
+ let module = ruby.define_module("RatatuiRuby")?;
103
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
104
+ let error_class = error_base.const_get("Terminal")?;
96
105
  Err(Error::new(
97
- ruby.exception_runtime_error(),
106
+ error_class,
98
107
  "Terminal is not initialized as TestBackend",
99
108
  ))
100
109
  }
@@ -104,13 +113,19 @@ pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
104
113
  let ruby = magnus::Ruby::get().unwrap();
105
114
  let mut term_lock = TERMINAL.lock().unwrap();
106
115
  if let Some(TerminalWrapper::Test(terminal)) = term_lock.as_mut() {
116
+ let module = ruby.define_module("RatatuiRuby")?;
117
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
118
+ let error_class = error_base.const_get("Terminal")?;
107
119
  let pos = terminal
108
120
  .get_cursor_position()
109
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
121
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
110
122
  Ok(Some(pos.into()))
111
123
  } else {
124
+ let module = ruby.define_module("RatatuiRuby")?;
125
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
126
+ let error_class = error_base.const_get("Terminal")?;
112
127
  Err(Error::new(
113
- ruby.exception_runtime_error(),
128
+ error_class,
114
129
  "Terminal is not initialized as TestBackend",
115
130
  ))
116
131
  }
@@ -125,7 +140,10 @@ pub fn resize_terminal(width: u16, height: u16) -> Result<(), Error> {
125
140
  TerminalWrapper::Test(terminal) => {
126
141
  terminal.backend_mut().resize(width, height);
127
142
  if let Err(e) = terminal.resize(ratatui::layout::Rect::new(0, 0, width, height)) {
128
- return Err(Error::new(ruby.exception_runtime_error(), e.to_string()));
143
+ let module = ruby.define_module("RatatuiRuby")?;
144
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
145
+ let error_class = error_base.const_get("Terminal")?;
146
+ return Err(Error::new(error_class, e.to_string()));
129
147
  }
130
148
  }
131
149
  }
@@ -148,14 +166,20 @@ pub fn get_cell_at(x: u16, y: u16) -> Result<magnus::RHash, Error> {
148
166
  hash.aset("modifiers", modifiers_to_value(cell.modifier))?;
149
167
  Ok(hash)
150
168
  } else {
169
+ let module = ruby.define_module("RatatuiRuby")?;
170
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
171
+ let error_class = error_base.const_get("Terminal")?;
151
172
  Err(Error::new(
152
- ruby.exception_runtime_error(),
173
+ error_class,
153
174
  format!("Coordinates ({x}, {y}) out of bounds"),
154
175
  ))
155
176
  }
156
177
  } else {
178
+ let module = ruby.define_module("RatatuiRuby")?;
179
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
180
+ let error_class = error_base.const_get("Terminal")?;
157
181
  Err(Error::new(
158
- ruby.exception_runtime_error(),
182
+ error_class,
159
183
  "Terminal is not initialized as TestBackend",
160
184
  ))
161
185
  }
@@ -90,7 +90,7 @@ pub fn parse_text(value: Value) -> Result<Vec<Line<'static>>, Error> {
90
90
  }
91
91
 
92
92
  /// Parses a Ruby Span object into a ratatui Span.
93
- fn parse_span(value: Value) -> Result<Span<'static>, Error> {
93
+ pub fn parse_span(value: Value) -> Result<Span<'static>, Error> {
94
94
  let ruby = magnus::Ruby::get().unwrap();
95
95
 
96
96
  // Get class name
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_bar_set, parse_block, parse_style};
5
+ use crate::text::{parse_line, parse_span};
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, RArray, Symbol, Value};
7
8
  use ratatui::{
@@ -11,6 +12,7 @@ use ratatui::{
11
12
  Frame,
12
13
  };
13
14
 
15
+ #[allow(clippy::too_many_lines)]
14
16
  pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
15
17
  let bump = Bump::new();
16
18
  let data_val: Value = node.funcall("data", ())?;
@@ -66,16 +68,32 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
66
68
 
67
69
  let label_val: Value = bar_obj.funcall("label", ())?;
68
70
  if !label_val.is_nil() {
69
- let s: String = label_val.funcall("to_s", ())?;
70
- let s_ref = bump.alloc_str(&s) as &str;
71
- bar = bar.label(Line::from(s_ref));
71
+ // Try to parse as Line (rich text)
72
+ if let Ok(line) = parse_line(label_val) {
73
+ bar = bar.label(line);
74
+ } else if let Ok(span) = parse_span(label_val) {
75
+ bar = bar.label(Line::from(vec![span]));
76
+ } else {
77
+ // Fallback to string
78
+ let s: String = label_val.funcall("to_s", ())?;
79
+ let s_ref = bump.alloc_str(&s) as &str;
80
+ bar = bar.label(Line::from(s_ref));
81
+ }
72
82
  }
73
83
 
74
84
  let text_val: Value = bar_obj.funcall("text_value", ())?;
75
85
  if !text_val.is_nil() {
76
- let s: String = text_val.funcall("to_s", ())?;
77
- let s_ref = bump.alloc_str(&s) as &str;
78
- bar = bar.text_value(s_ref);
86
+ // Try to parse as Line (rich text)
87
+ if let Ok(line) = parse_line(text_val) {
88
+ bar = bar.text_value(line);
89
+ } else if let Ok(span) = parse_span(text_val) {
90
+ bar = bar.text_value(Line::from(vec![span]));
91
+ } else {
92
+ // Fallback to string
93
+ let s: String = text_val.funcall("to_s", ())?;
94
+ let s_ref = bump.alloc_str(&s) as &str;
95
+ bar = bar.text_value(s_ref);
96
+ }
79
97
  }
80
98
 
81
99
  let style_val: Value = bar_obj.funcall("style", ())?;
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::text::parse_span;
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, Value};
7
8
  use ratatui::{layout::Rect, widgets::Gauge, Frame};
@@ -17,8 +18,14 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
17
18
  let mut gauge = Gauge::default().ratio(ratio).use_unicode(use_unicode);
18
19
 
19
20
  if !label_val.is_nil() {
20
- let label_str: String = label_val.funcall("to_s", ())?;
21
- gauge = gauge.label(label_str);
21
+ // Try to parse as a Span (rich text)
22
+ if let Ok(span) = parse_span(label_val) {
23
+ gauge = gauge.label(span);
24
+ } else {
25
+ // Fallback to string
26
+ let label_str: String = label_val.funcall("to_s", ())?;
27
+ gauge = gauge.label(label_str);
28
+ }
22
29
  }
23
30
 
24
31
  if !style_val.is_nil() {
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::text::parse_span;
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, Value};
7
8
  use ratatui::{layout::Rect, widgets::LineGauge, Frame};
@@ -23,8 +24,14 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
23
24
  .unfilled_symbol(&unfilled_symbol_val);
24
25
 
25
26
  if !label_val.is_nil() {
26
- let label_str: String = label_val.funcall("to_s", ())?;
27
- gauge = gauge.label(label_str);
27
+ // Try to parse as a Span (rich text)
28
+ if let Ok(span) = parse_span(label_val) {
29
+ gauge = gauge.label(span);
30
+ } else {
31
+ // Fallback to string
32
+ let label_str: String = label_val.funcall("to_s", ())?;
33
+ gauge = gauge.label(label_str);
34
+ }
28
35
  }
29
36
 
30
37
  if !style_val.is_nil() {
@@ -2,12 +2,14 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::text::{parse_line, parse_span};
6
+ use crate::widgets::list_state::RubyListState;
5
7
  use bumpalo::Bump;
6
8
  use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
7
9
  use ratatui::{
8
10
  layout::Rect,
9
11
  text::Line,
10
- widgets::{HighlightSpacing, List, ListState},
12
+ widgets::{HighlightSpacing, List, ListItem, ListState},
11
13
  Frame,
12
14
  };
13
15
 
@@ -27,11 +29,12 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
27
29
  let scroll_padding_val: Value = node.funcall("scroll_padding", ())?;
28
30
  let block_val: Value = node.funcall("block", ())?;
29
31
 
30
- let mut items: Vec<String> = Vec::new();
32
+ let mut items: Vec<ListItem> = Vec::new();
31
33
  for i in 0..items_array.len() {
32
34
  let index = isize::try_from(i)
33
35
  .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
34
- let item: String = items_array.entry(index)?;
36
+ let item_val: Value = items_array.entry(index)?;
37
+ let item = parse_list_item(item_val)?;
35
38
  items.push(item);
36
39
  }
37
40
 
@@ -47,6 +50,12 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
47
50
  state.select(Some(index));
48
51
  }
49
52
 
53
+ let offset_val: Value = node.funcall("offset", ())?;
54
+ if !offset_val.is_nil() {
55
+ let offset: usize = offset_val.funcall("to_int", ())?;
56
+ *state.offset_mut() = offset;
57
+ }
58
+
50
59
  let mut list = List::new(items);
51
60
 
52
61
  let highlight_spacing = match highlight_spacing_sym.to_string().as_str() {
@@ -101,6 +110,173 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
101
110
  Ok(())
102
111
  }
103
112
 
113
+ /// Renders a List with an external state object.
114
+ ///
115
+ /// This function ignores `selected_index` and `offset` from the widget.
116
+ /// The State object is the single source of truth for selection and scroll position.
117
+ pub fn render_stateful(
118
+ frame: &mut Frame,
119
+ area: Rect,
120
+ node: Value,
121
+ state_wrapper: Value,
122
+ ) -> Result<(), Error> {
123
+ let bump = Bump::new();
124
+ let ruby = magnus::Ruby::get().unwrap();
125
+
126
+ // Extract the RubyListState wrapper
127
+ let state: &RubyListState = TryConvert::try_convert(state_wrapper)?;
128
+
129
+ // Build items
130
+ let items_val: Value = node.funcall("items", ())?;
131
+ let items_array = magnus::RArray::from_value(items_val)
132
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array"))?;
133
+
134
+ let mut items: Vec<ListItem> = Vec::new();
135
+ for i in 0..items_array.len() {
136
+ let index = isize::try_from(i)
137
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
138
+ let item_val: Value = items_array.entry(index)?;
139
+ let item = parse_list_item(item_val)?;
140
+ items.push(item);
141
+ }
142
+
143
+ // Build widget (ignoring selected_index and offset — State is truth)
144
+ let style_val: Value = node.funcall("style", ())?;
145
+ let highlight_style_val: Value = node.funcall("highlight_style", ())?;
146
+ let highlight_symbol_val: Value = node.funcall("highlight_symbol", ())?;
147
+ let repeat_highlight_symbol_val: Value = node.funcall("repeat_highlight_symbol", ())?;
148
+ let highlight_spacing_sym: Symbol = node.funcall("highlight_spacing", ())?;
149
+ let direction_val: Value = node.funcall("direction", ())?;
150
+ let scroll_padding_val: Value = node.funcall("scroll_padding", ())?;
151
+ let block_val: Value = node.funcall("block", ())?;
152
+
153
+ let symbol: String = if highlight_symbol_val.is_nil() {
154
+ String::new()
155
+ } else {
156
+ String::try_convert(highlight_symbol_val)?
157
+ };
158
+
159
+ let mut list = List::new(items);
160
+
161
+ let highlight_spacing = match highlight_spacing_sym.to_string().as_str() {
162
+ "always" => HighlightSpacing::Always,
163
+ "never" => HighlightSpacing::Never,
164
+ _ => HighlightSpacing::WhenSelected,
165
+ };
166
+ list = list.highlight_spacing(highlight_spacing);
167
+
168
+ if !highlight_symbol_val.is_nil() {
169
+ list = list.highlight_symbol(Line::from(symbol));
170
+ }
171
+
172
+ if !repeat_highlight_symbol_val.is_nil() {
173
+ let repeat: bool = TryConvert::try_convert(repeat_highlight_symbol_val)?;
174
+ list = list.repeat_highlight_symbol(repeat);
175
+ }
176
+
177
+ if !direction_val.is_nil() {
178
+ let direction_sym: magnus::Symbol = TryConvert::try_convert(direction_val)?;
179
+ let direction_str = direction_sym.name().unwrap();
180
+ match direction_str.as_ref() {
181
+ "top_to_bottom" => list = list.direction(ratatui::widgets::ListDirection::TopToBottom),
182
+ "bottom_to_top" => list = list.direction(ratatui::widgets::ListDirection::BottomToTop),
183
+ _ => {
184
+ return Err(Error::new(
185
+ ruby.exception_arg_error(),
186
+ "direction must be :top_to_bottom or :bottom_to_top",
187
+ ))
188
+ }
189
+ }
190
+ }
191
+
192
+ if !scroll_padding_val.is_nil() {
193
+ let padding: usize = TryConvert::try_convert(scroll_padding_val)?;
194
+ list = list.scroll_padding(padding);
195
+ }
196
+
197
+ if !style_val.is_nil() {
198
+ list = list.style(parse_style(style_val)?);
199
+ }
200
+
201
+ if !highlight_style_val.is_nil() {
202
+ list = list.highlight_style(parse_style(highlight_style_val)?);
203
+ }
204
+
205
+ if !block_val.is_nil() {
206
+ list = list.block(parse_block(block_val, &bump)?);
207
+ }
208
+
209
+ // Borrow the inner ListState, render, and release the borrow immediately
210
+ {
211
+ let mut inner_state = state.borrow_mut();
212
+ frame.render_stateful_widget(list, area, &mut inner_state);
213
+ }
214
+ // Borrow is now released
215
+
216
+ Ok(())
217
+ }
218
+
219
+ /// Parses a Ruby list item into a ratatui `ListItem`.
220
+ ///
221
+ /// Accepts:
222
+ /// - `String`: Plain text item
223
+ /// - `Text::Span`: A single styled fragment
224
+ /// - `Text::Line`: A line composed of multiple spans
225
+ /// - `RatatuiRuby::ListItem`: A `ListItem` object with content and optional style
226
+ fn parse_list_item(value: Value) -> Result<ListItem<'static>, Error> {
227
+ let ruby = magnus::Ruby::get().unwrap();
228
+
229
+ // Check if it's a RatatuiRuby::ListItem
230
+ if let Ok(class_obj) = value.funcall::<_, _, Value>("class", ()) {
231
+ if let Ok(class_name) = class_obj.funcall::<_, _, String>("name", ()) {
232
+ if class_name.contains("ListItem") {
233
+ // Extract content and style from the ListItem
234
+ let content_val: Value = value.funcall("content", ())?;
235
+ let style_val: Value = value.funcall("style", ())?;
236
+
237
+ // Parse content as a Line
238
+ let line = if let Ok(s) = String::try_convert(content_val) {
239
+ Line::from(s)
240
+ } else if let Ok(line) = parse_line(content_val) {
241
+ line
242
+ } else if let Ok(span) = parse_span(content_val) {
243
+ Line::from(vec![span])
244
+ } else {
245
+ Line::from("")
246
+ };
247
+
248
+ // Parse and apply style if present
249
+ let mut item = ListItem::new(line);
250
+ if !style_val.is_nil() {
251
+ item = item.style(parse_style(style_val)?);
252
+ }
253
+ return Ok(item);
254
+ }
255
+ }
256
+ }
257
+
258
+ // Try as String
259
+ if let Ok(s) = String::try_convert(value) {
260
+ return Ok(ListItem::new(Line::from(s)));
261
+ }
262
+
263
+ // Try as Line
264
+ if let Ok(line) = parse_line(value) {
265
+ return Ok(ListItem::new(line));
266
+ }
267
+
268
+ // Try as Span
269
+ if let Ok(span) = parse_span(value) {
270
+ return Ok(ListItem::new(Line::from(vec![span])));
271
+ }
272
+
273
+ // Fallback
274
+ Err(Error::new(
275
+ ruby.exception_type_error(),
276
+ "expected String, Text::Span, Text::Line, or ListItem",
277
+ ))
278
+ }
279
+
104
280
  #[cfg(test)]
105
281
  mod tests {
106
282
  use super::*;
@@ -0,0 +1,137 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! `ListState` wrapper for exposing Ratatui's `ListState` to Ruby.
5
+ //!
6
+ //! This module provides `RubyListState`, a Magnus-wrapped struct that holds
7
+ //! a `RefCell<ListState>` 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_index`, `offset`)
13
+ //! are ignored in stateful mode.
14
+ //!
15
+ //! # Safety
16
+ //!
17
+ //! The `RefCell` is borrowed only during the `render_stateful_widget` call.
18
+ //! The borrow is released immediately after to avoid double-borrow panics
19
+ //! if a user inspects state inside a custom widget's render method.
20
+
21
+ use magnus::{function, method, prelude::*, Error, Module, Ruby};
22
+ use ratatui::widgets::ListState;
23
+ use std::cell::RefCell;
24
+
25
+ /// A wrapper around Ratatui's `ListState` exposed to Ruby.
26
+ ///
27
+ /// This struct uses `RefCell` for interior mutability, allowing the state
28
+ /// to be updated during rendering while remaining accessible from Ruby.
29
+ #[magnus::wrap(class = "RatatuiRuby::ListState")]
30
+ pub struct RubyListState {
31
+ inner: RefCell<ListState>,
32
+ }
33
+
34
+ impl RubyListState {
35
+ /// Creates a new `RubyListState` with optional initial selection.
36
+ ///
37
+ /// # Arguments
38
+ ///
39
+ /// * `selected` - Optional initial selection index
40
+ pub fn new(selected: Option<usize>) -> Self {
41
+ let mut state = ListState::default();
42
+ if let Some(idx) = selected {
43
+ state.select(Some(idx));
44
+ }
45
+ Self {
46
+ inner: RefCell::new(state),
47
+ }
48
+ }
49
+
50
+ /// Sets the selected index.
51
+ ///
52
+ /// Pass `nil` to deselect.
53
+ pub fn select(&self, index: Option<usize>) {
54
+ self.inner.borrow_mut().select(index);
55
+ }
56
+
57
+ /// Returns the currently selected index, or `nil` if nothing is selected.
58
+ pub fn selected(&self) -> Option<usize> {
59
+ self.inner.borrow().selected()
60
+ }
61
+
62
+ /// Returns the current scroll offset.
63
+ ///
64
+ /// This is the critical read-back method. After `render_stateful_widget`,
65
+ /// this returns the scroll position calculated by Ratatui to keep the
66
+ /// selection visible.
67
+ pub fn offset(&self) -> usize {
68
+ self.inner.borrow().offset()
69
+ }
70
+
71
+ /// Scrolls down by the given number of items.
72
+ pub fn scroll_down_by(&self, amount: u16) {
73
+ self.inner.borrow_mut().scroll_down_by(amount);
74
+ }
75
+
76
+ /// Scrolls up by the given number of items.
77
+ pub fn scroll_up_by(&self, amount: u16) {
78
+ self.inner.borrow_mut().scroll_up_by(amount);
79
+ }
80
+
81
+ /// Borrows the inner `ListState` mutably for rendering.
82
+ ///
83
+ /// # Safety
84
+ ///
85
+ /// The caller must ensure the borrow is released before returning
86
+ /// control to Ruby to avoid double-borrow panics.
87
+ pub fn borrow_mut(&self) -> std::cell::RefMut<'_, ListState> {
88
+ self.inner.borrow_mut()
89
+ }
90
+ }
91
+
92
+ /// Registers the `ListState` class with Ruby.
93
+ pub fn register(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
94
+ let class = module.define_class("ListState", ruby.class_object())?;
95
+ class.define_singleton_method("new", function!(RubyListState::new, 1))?;
96
+ class.define_method("select", method!(RubyListState::select, 1))?;
97
+ class.define_method("selected", method!(RubyListState::selected, 0))?;
98
+ class.define_method("offset", method!(RubyListState::offset, 0))?;
99
+ class.define_method("scroll_down_by", method!(RubyListState::scroll_down_by, 1))?;
100
+ class.define_method("scroll_up_by", method!(RubyListState::scroll_up_by, 1))?;
101
+ Ok(())
102
+ }
103
+
104
+ #[cfg(test)]
105
+ mod tests {
106
+ use super::*;
107
+
108
+ #[test]
109
+ fn test_new_with_no_selection() {
110
+ let state = RubyListState::new(None);
111
+ assert_eq!(state.selected(), None);
112
+ assert_eq!(state.offset(), 0);
113
+ }
114
+
115
+ #[test]
116
+ fn test_new_with_selection() {
117
+ let state = RubyListState::new(Some(5));
118
+ assert_eq!(state.selected(), Some(5));
119
+ }
120
+
121
+ #[test]
122
+ fn test_select_and_deselect() {
123
+ let state = RubyListState::new(None);
124
+ state.select(Some(3));
125
+ assert_eq!(state.selected(), Some(3));
126
+ state.select(None);
127
+ assert_eq!(state.selected(), None);
128
+ }
129
+
130
+ #[test]
131
+ fn test_scroll_operations() {
132
+ let state = RubyListState::new(None);
133
+ state.scroll_down_by(5);
134
+ // Note: scroll operations affect offset, but the exact behavior
135
+ // depends on the list size which is determined during rendering
136
+ }
137
+ }
@@ -13,11 +13,14 @@ pub mod gauge;
13
13
  pub mod layout;
14
14
  pub mod line_gauge;
15
15
  pub mod list;
16
+ pub mod list_state;
16
17
  pub mod overlay;
17
18
  pub mod paragraph;
18
19
  pub mod ratatui_logo;
19
20
  pub mod ratatui_mascot;
20
21
  pub mod scrollbar;
22
+ pub mod scrollbar_state;
21
23
  pub mod sparkline;
22
24
  pub mod table;
25
+ pub mod table_state;
23
26
  pub mod tabs;