ratatui_ruby 0.5.0 → 0.7.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 (311) 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 +10 -4
  7. data/CHANGELOG.md +79 -7
  8. data/README.md +37 -5
  9. data/REUSE.toml +2 -7
  10. data/doc/application_architecture.md +96 -22
  11. data/doc/application_testing.md +76 -30
  12. data/doc/contributors/architectural_overhaul/chat_conversations.md +4952 -0
  13. data/doc/contributors/architectural_overhaul/implementation_plan.md +60 -0
  14. data/doc/contributors/architectural_overhaul/task.md +37 -0
  15. data/doc/contributors/design/ruby_frontend.md +288 -56
  16. data/doc/contributors/design/rust_backend.md +349 -54
  17. data/doc/contributors/developing_examples.md +134 -49
  18. data/doc/contributors/index.md +7 -5
  19. data/doc/contributors/v1.0.0_blockers.md +1729 -0
  20. data/doc/event_handling.md +11 -3
  21. data/doc/images/app_all_events.png +0 -0
  22. data/doc/images/app_color_picker.png +0 -0
  23. data/doc/images/app_login_form.png +0 -0
  24. data/doc/images/app_stateful_interaction.png +0 -0
  25. data/doc/images/verify_quickstart_dsl.png +0 -0
  26. data/doc/images/verify_quickstart_layout.png +0 -0
  27. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  28. data/doc/images/verify_readme_usage.png +0 -0
  29. data/doc/images/widget_barchart_demo.png +0 -0
  30. data/doc/images/widget_block_demo.png +0 -0
  31. data/doc/images/widget_canvas_demo.png +0 -0
  32. data/doc/images/widget_cell_demo.png +0 -0
  33. data/doc/images/widget_center_demo.png +0 -0
  34. data/doc/images/widget_chart_demo.png +0 -0
  35. data/doc/images/widget_list_demo.png +0 -0
  36. data/doc/images/widget_overlay_demo.png +0 -0
  37. data/doc/images/widget_render.png +0 -0
  38. data/doc/images/widget_rich_text.png +0 -0
  39. data/doc/images/widget_scroll_text.png +0 -0
  40. data/doc/images/widget_sparkline_demo.png +0 -0
  41. data/doc/images/widget_table_demo.png +0 -0
  42. data/doc/images/widget_tabs_demo.png +0 -0
  43. data/doc/images/widget_text_width.png +0 -0
  44. data/doc/index.md +11 -6
  45. data/doc/interactive_design.md +2 -2
  46. data/doc/quickstart.md +127 -165
  47. data/doc/terminal_limitations.md +92 -0
  48. data/doc/v0.7.0_migration.md +236 -0
  49. data/doc/why.md +93 -0
  50. data/examples/app_all_events/README.md +47 -27
  51. data/examples/app_all_events/app.rb +38 -35
  52. data/examples/app_all_events/model/app_model.rb +157 -0
  53. data/examples/app_all_events/model/event_entry.rb +17 -0
  54. data/examples/app_all_events/model/msg.rb +37 -0
  55. data/examples/app_all_events/update.rb +73 -0
  56. data/examples/app_all_events/view/app_view.rb +9 -9
  57. data/examples/app_all_events/view/controls_view.rb +9 -7
  58. data/examples/app_all_events/view/counts_view.rb +13 -9
  59. data/examples/app_all_events/view/live_view.rb +9 -8
  60. data/examples/app_all_events/view/log_view.rb +11 -16
  61. data/examples/app_color_picker/README.md +84 -42
  62. data/examples/app_color_picker/app.rb +24 -62
  63. data/examples/app_color_picker/controls.rb +90 -0
  64. data/examples/app_color_picker/copy_dialog.rb +45 -49
  65. data/examples/app_color_picker/export_pane.rb +126 -0
  66. data/examples/app_color_picker/input.rb +99 -67
  67. data/examples/app_color_picker/main_container.rb +178 -0
  68. data/examples/app_color_picker/palette.rb +55 -26
  69. data/examples/app_login_form/README.md +49 -0
  70. data/examples/app_login_form/app.rb +2 -3
  71. data/examples/app_stateful_interaction/README.md +33 -0
  72. data/examples/app_stateful_interaction/app.rb +272 -0
  73. data/examples/timeout_demo.rb +43 -0
  74. data/examples/verify_quickstart_dsl/README.md +49 -0
  75. data/examples/verify_quickstart_dsl/app.rb +2 -0
  76. data/examples/verify_quickstart_layout/README.md +71 -0
  77. data/examples/verify_quickstart_layout/app.rb +2 -0
  78. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  79. data/examples/verify_quickstart_lifecycle/app.rb +10 -4
  80. data/examples/verify_readme_usage/README.md +43 -0
  81. data/examples/verify_readme_usage/app.rb +8 -2
  82. data/examples/widget_barchart_demo/README.md +50 -0
  83. data/examples/widget_barchart_demo/app.rb +5 -5
  84. data/examples/widget_block_demo/README.md +36 -0
  85. data/examples/widget_block_demo/app.rb +256 -0
  86. data/examples/widget_box_demo/README.md +45 -0
  87. data/examples/widget_calendar_demo/README.md +39 -0
  88. data/examples/widget_calendar_demo/app.rb +5 -1
  89. data/examples/widget_canvas_demo/README.md +27 -0
  90. data/examples/widget_canvas_demo/app.rb +123 -0
  91. data/examples/widget_cell_demo/README.md +36 -0
  92. data/examples/widget_cell_demo/app.rb +31 -24
  93. data/examples/widget_center_demo/README.md +29 -0
  94. data/examples/widget_center_demo/app.rb +116 -0
  95. data/examples/widget_chart_demo/README.md +41 -0
  96. data/examples/widget_chart_demo/app.rb +7 -2
  97. data/examples/widget_gauge_demo/README.md +41 -0
  98. data/examples/widget_layout_split/README.md +44 -0
  99. data/examples/widget_line_gauge_demo/README.md +41 -0
  100. data/examples/widget_list_demo/README.md +49 -0
  101. data/examples/widget_list_demo/app.rb +91 -107
  102. data/examples/widget_map_demo/README.md +39 -0
  103. data/examples/{app_map_demo → widget_map_demo}/app.rb +4 -4
  104. data/examples/widget_overlay_demo/README.md +36 -0
  105. data/examples/widget_overlay_demo/app.rb +248 -0
  106. data/examples/widget_popup_demo/README.md +36 -0
  107. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  108. data/examples/widget_ratatui_logo_demo/app.rb +1 -1
  109. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  110. data/examples/widget_rect/README.md +38 -0
  111. data/examples/widget_render/README.md +37 -0
  112. data/examples/widget_render/app.rb +3 -3
  113. data/examples/widget_rich_text/README.md +35 -0
  114. data/examples/widget_rich_text/app.rb +62 -33
  115. data/examples/widget_scroll_text/README.md +37 -0
  116. data/examples/widget_scroll_text/app.rb +0 -1
  117. data/examples/widget_scrollbar_demo/README.md +37 -0
  118. data/examples/widget_sparkline_demo/README.md +42 -0
  119. data/examples/widget_sparkline_demo/app.rb +4 -3
  120. data/examples/widget_style_colors/README.md +34 -0
  121. data/examples/widget_table_demo/README.md +48 -0
  122. data/examples/{app_table_select → widget_table_demo}/app.rb +65 -12
  123. data/examples/widget_tabs_demo/README.md +41 -0
  124. data/examples/widget_tabs_demo/app.rb +15 -1
  125. data/examples/widget_text_width/README.md +35 -0
  126. data/examples/widget_text_width/app.rb +113 -0
  127. data/exe/.gitkeep +0 -0
  128. data/ext/ratatui_ruby/Cargo.lock +11 -4
  129. data/ext/ratatui_ruby/Cargo.toml +2 -1
  130. data/ext/ratatui_ruby/src/events.rs +238 -26
  131. data/ext/ratatui_ruby/src/frame.rs +116 -3
  132. data/ext/ratatui_ruby/src/lib.rs +37 -6
  133. data/ext/ratatui_ruby/src/rendering.rs +22 -21
  134. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  135. data/ext/ratatui_ruby/src/terminal.rs +39 -15
  136. data/ext/ratatui_ruby/src/text.rs +13 -4
  137. data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
  138. data/ext/ratatui_ruby/src/widgets/canvas.rs +5 -5
  139. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  140. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  141. data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
  142. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  143. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  144. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
  145. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  146. data/ext/ratatui_ruby/src/widgets/table.rs +191 -34
  147. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  148. data/lib/ratatui_ruby/buffer/cell.rb +168 -0
  149. data/lib/ratatui_ruby/buffer.rb +15 -0
  150. data/lib/ratatui_ruby/cell.rb +4 -4
  151. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  152. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  153. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  154. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  155. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  156. data/lib/ratatui_ruby/event/key.rb +111 -51
  157. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  158. data/lib/ratatui_ruby/event/paste.rb +1 -1
  159. data/lib/ratatui_ruby/frame.rb +100 -4
  160. data/lib/ratatui_ruby/layout/constraint.rb +95 -0
  161. data/lib/ratatui_ruby/layout/layout.rb +106 -0
  162. data/lib/ratatui_ruby/layout/rect.rb +118 -0
  163. data/lib/ratatui_ruby/layout.rb +19 -0
  164. data/lib/ratatui_ruby/list_state.rb +88 -0
  165. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
  166. data/lib/ratatui_ruby/schema/cursor.rb +5 -0
  167. data/lib/ratatui_ruby/schema/gauge.rb +3 -1
  168. data/lib/ratatui_ruby/schema/layout.rb +1 -1
  169. data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
  170. data/lib/ratatui_ruby/schema/list.rb +25 -4
  171. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  172. data/lib/ratatui_ruby/schema/rect.rb +43 -0
  173. data/lib/ratatui_ruby/schema/row.rb +66 -0
  174. data/lib/ratatui_ruby/schema/style.rb +24 -4
  175. data/lib/ratatui_ruby/schema/table.rb +29 -11
  176. data/lib/ratatui_ruby/schema/text.rb +96 -3
  177. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  178. data/lib/ratatui_ruby/style/style.rb +81 -0
  179. data/lib/ratatui_ruby/style.rb +15 -0
  180. data/lib/ratatui_ruby/table_state.rb +90 -0
  181. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  182. data/lib/ratatui_ruby/test_helper/snapshot.rb +414 -0
  183. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  184. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  185. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  186. data/lib/ratatui_ruby/test_helper.rb +65 -358
  187. data/lib/ratatui_ruby/tui/buffer_factories.rb +20 -0
  188. data/lib/ratatui_ruby/tui/canvas_factories.rb +44 -0
  189. data/lib/ratatui_ruby/tui/core.rb +38 -0
  190. data/lib/ratatui_ruby/tui/layout_factories.rb +74 -0
  191. data/lib/ratatui_ruby/tui/state_factories.rb +33 -0
  192. data/lib/ratatui_ruby/tui/style_factories.rb +20 -0
  193. data/lib/ratatui_ruby/tui/text_factories.rb +44 -0
  194. data/lib/ratatui_ruby/tui/widget_factories.rb +195 -0
  195. data/lib/ratatui_ruby/tui.rb +75 -0
  196. data/lib/ratatui_ruby/version.rb +1 -1
  197. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +47 -0
  198. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +25 -0
  199. data/lib/ratatui_ruby/widgets/bar_chart.rb +239 -0
  200. data/lib/ratatui_ruby/widgets/block.rb +192 -0
  201. data/lib/ratatui_ruby/widgets/calendar.rb +84 -0
  202. data/lib/ratatui_ruby/widgets/canvas.rb +231 -0
  203. data/lib/ratatui_ruby/widgets/cell.rb +47 -0
  204. data/lib/ratatui_ruby/widgets/center.rb +59 -0
  205. data/lib/ratatui_ruby/widgets/chart.rb +185 -0
  206. data/lib/ratatui_ruby/widgets/clear.rb +54 -0
  207. data/lib/ratatui_ruby/widgets/cursor.rb +42 -0
  208. data/lib/ratatui_ruby/widgets/gauge.rb +72 -0
  209. data/lib/ratatui_ruby/widgets/line_gauge.rb +80 -0
  210. data/lib/ratatui_ruby/widgets/list.rb +127 -0
  211. data/lib/ratatui_ruby/widgets/list_item.rb +43 -0
  212. data/lib/ratatui_ruby/widgets/overlay.rb +43 -0
  213. data/lib/ratatui_ruby/widgets/paragraph.rb +99 -0
  214. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +31 -0
  215. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +36 -0
  216. data/lib/ratatui_ruby/widgets/row.rb +68 -0
  217. data/lib/ratatui_ruby/widgets/scrollbar.rb +143 -0
  218. data/lib/ratatui_ruby/widgets/shape/label.rb +68 -0
  219. data/lib/ratatui_ruby/widgets/sparkline.rb +134 -0
  220. data/lib/ratatui_ruby/widgets/table.rb +141 -0
  221. data/lib/ratatui_ruby/widgets/tabs.rb +85 -0
  222. data/lib/ratatui_ruby/widgets.rb +40 -0
  223. data/lib/ratatui_ruby.rb +64 -57
  224. data/sig/examples/app_all_events/view.rbs +1 -1
  225. data/sig/examples/app_all_events/view_state.rbs +1 -1
  226. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  227. data/sig/examples/widget_block_demo/app.rbs +32 -0
  228. data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
  229. data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
  230. data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
  231. data/sig/ratatui_ruby/event.rbs +11 -1
  232. data/sig/ratatui_ruby/frame.rbs +2 -0
  233. data/sig/ratatui_ruby/list_state.rbs +13 -0
  234. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
  235. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  236. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  237. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  238. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  239. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  240. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  241. data/sig/ratatui_ruby/schema/row.rbs +22 -0
  242. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  243. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  244. data/sig/ratatui_ruby/schema/text.rbs +9 -6
  245. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  246. data/sig/ratatui_ruby/session.rbs +41 -48
  247. data/sig/ratatui_ruby/table_state.rbs +15 -0
  248. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  249. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  250. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  251. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  252. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  253. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  254. data/sig/ratatui_ruby/tui/buffer_factories.rbs +10 -0
  255. data/sig/ratatui_ruby/tui/canvas_factories.rbs +14 -0
  256. data/sig/ratatui_ruby/tui/core.rbs +14 -0
  257. data/sig/ratatui_ruby/tui/layout_factories.rbs +19 -0
  258. data/sig/ratatui_ruby/tui/state_factories.rbs +12 -0
  259. data/sig/ratatui_ruby/tui/style_factories.rbs +10 -0
  260. data/sig/ratatui_ruby/tui/text_factories.rbs +14 -0
  261. data/sig/ratatui_ruby/tui/widget_factories.rbs +39 -0
  262. data/sig/ratatui_ruby/tui.rbs +19 -0
  263. data/tasks/autodoc/examples.rb +79 -0
  264. data/tasks/autodoc.rake +7 -35
  265. data/tasks/bump/changelog.rb +3 -3
  266. data/tasks/bump/links.rb +67 -0
  267. data/tasks/sourcehut.rake +64 -21
  268. data/tasks/terminal_preview/app_screenshot.rb +13 -3
  269. data/tasks/terminal_preview/saved_screenshot.rb +4 -3
  270. metadata +169 -48
  271. data/doc/contributors/dwim_dx.md +0 -366
  272. data/doc/images/app_analytics.png +0 -0
  273. data/doc/images/app_custom_widget.png +0 -0
  274. data/doc/images/app_mouse_events.png +0 -0
  275. data/doc/images/app_table_select.png +0 -0
  276. data/doc/images/widget_block_padding.png +0 -0
  277. data/doc/images/widget_block_titles.png +0 -0
  278. data/doc/images/widget_list_styles.png +0 -0
  279. data/doc/images/widget_table_flex.png +0 -0
  280. data/examples/app_all_events/model/events.rb +0 -180
  281. data/examples/app_all_events/model/highlight.rb +0 -57
  282. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
  283. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
  284. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
  285. data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
  286. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
  287. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
  288. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
  289. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
  290. data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
  291. data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
  292. data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
  293. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
  294. data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
  295. data/examples/app_all_events/view_state.rb +0 -42
  296. data/examples/app_color_picker/scene.rb +0 -201
  297. data/examples/widget_block_padding/app.rb +0 -67
  298. data/examples/widget_block_titles/app.rb +0 -69
  299. data/examples/widget_list_styles/app.rb +0 -141
  300. data/examples/widget_table_flex/app.rb +0 -95
  301. data/lib/ratatui_ruby/session/autodoc.rb +0 -417
  302. data/lib/ratatui_ruby/session.rb +0 -163
  303. data/sig/examples/widget_block_padding/app.rbs +0 -11
  304. data/sig/examples/widget_block_titles/app.rbs +0 -11
  305. data/sig/examples/widget_list_styles/app.rbs +0 -11
  306. data/tasks/autodoc/inventory.rb +0 -61
  307. data/tasks/autodoc/notice.rb +0 -26
  308. data/tasks/autodoc/rbs.rb +0 -38
  309. data/tasks/autodoc/rdoc.rb +0 -45
  310. data/tasks/bump/comparison_links.rb +0 -41
  311. /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
@@ -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;
@@ -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
+ }