ratatui_ruby 0.6.0 → 0.7.1

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 (177) 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 +4 -4
  7. data/CHANGELOG.md +48 -0
  8. data/README.md +26 -1
  9. data/doc/application_architecture.md +16 -16
  10. data/doc/application_testing.md +1 -1
  11. data/doc/async.md +160 -0
  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 +277 -81
  16. data/doc/contributors/design/rust_backend.md +349 -55
  17. data/doc/contributors/developing_examples.md +5 -5
  18. data/doc/contributors/index.md +7 -5
  19. data/doc/contributors/v1.0.0_blockers.md +1729 -0
  20. data/doc/debugging.md +71 -0
  21. data/doc/index.md +11 -6
  22. data/doc/interactive_design.md +2 -2
  23. data/doc/quickstart.md +66 -97
  24. data/doc/v0.7.0_migration.md +236 -0
  25. data/doc/why.md +93 -0
  26. data/examples/app_all_events/README.md +6 -4
  27. data/examples/app_all_events/app.rb +1 -1
  28. data/examples/app_all_events/model/app_model.rb +1 -1
  29. data/examples/app_all_events/model/msg.rb +1 -1
  30. data/examples/app_all_events/update.rb +1 -1
  31. data/examples/app_all_events/view/app_view.rb +1 -1
  32. data/examples/app_all_events/view/controls_view.rb +1 -1
  33. data/examples/app_all_events/view/counts_view.rb +1 -1
  34. data/examples/app_all_events/view/live_view.rb +1 -1
  35. data/examples/app_all_events/view/log_view.rb +1 -1
  36. data/examples/app_color_picker/README.md +7 -5
  37. data/examples/app_color_picker/app.rb +1 -1
  38. data/examples/app_login_form/README.md +2 -0
  39. data/examples/app_stateful_interaction/README.md +2 -0
  40. data/examples/app_stateful_interaction/app.rb +1 -1
  41. data/examples/verify_quickstart_dsl/README.md +4 -3
  42. data/examples/verify_quickstart_dsl/app.rb +1 -1
  43. data/examples/verify_quickstart_layout/README.md +1 -1
  44. data/examples/verify_quickstart_lifecycle/README.md +3 -3
  45. data/examples/verify_quickstart_lifecycle/app.rb +2 -2
  46. data/examples/verify_readme_usage/README.md +1 -1
  47. data/examples/widget_barchart_demo/README.md +2 -1
  48. data/examples/widget_block_demo/README.md +2 -0
  49. data/examples/widget_box_demo/README.md +3 -3
  50. data/examples/widget_calendar_demo/README.md +3 -3
  51. data/examples/widget_calendar_demo/app.rb +5 -1
  52. data/examples/widget_canvas_demo/README.md +3 -3
  53. data/examples/widget_cell_demo/README.md +3 -3
  54. data/examples/widget_center_demo/README.md +3 -3
  55. data/examples/widget_chart_demo/README.md +3 -3
  56. data/examples/widget_gauge_demo/README.md +3 -3
  57. data/examples/widget_layout_split/README.md +3 -3
  58. data/examples/widget_line_gauge_demo/README.md +3 -3
  59. data/examples/widget_list_demo/README.md +3 -3
  60. data/examples/widget_map_demo/README.md +3 -3
  61. data/examples/widget_map_demo/app.rb +2 -2
  62. data/examples/widget_overlay_demo/README.md +36 -0
  63. data/examples/widget_popup_demo/README.md +3 -3
  64. data/examples/widget_ratatui_logo_demo/README.md +3 -3
  65. data/examples/widget_ratatui_logo_demo/app.rb +1 -1
  66. data/examples/widget_ratatui_mascot_demo/README.md +3 -3
  67. data/examples/widget_rect/README.md +3 -3
  68. data/examples/widget_render/README.md +3 -3
  69. data/examples/widget_render/app.rb +3 -3
  70. data/examples/widget_rich_text/README.md +3 -3
  71. data/examples/widget_scroll_text/README.md +3 -3
  72. data/examples/widget_scrollbar_demo/README.md +3 -3
  73. data/examples/widget_sparkline_demo/README.md +3 -3
  74. data/examples/widget_style_colors/README.md +3 -3
  75. data/examples/widget_table_demo/README.md +3 -3
  76. data/examples/widget_table_demo/app.rb +19 -4
  77. data/examples/widget_tabs_demo/README.md +3 -3
  78. data/examples/widget_text_width/README.md +3 -3
  79. data/examples/widget_text_width/app.rb +8 -1
  80. data/ext/ratatui_ruby/Cargo.lock +1 -1
  81. data/ext/ratatui_ruby/Cargo.toml +1 -1
  82. data/ext/ratatui_ruby/src/frame.rs +6 -5
  83. data/ext/ratatui_ruby/src/lib.rs +3 -2
  84. data/ext/ratatui_ruby/src/rendering.rs +22 -21
  85. data/ext/ratatui_ruby/src/style.rs +25 -9
  86. data/ext/ratatui_ruby/src/text.rs +12 -3
  87. data/ext/ratatui_ruby/src/widgets/canvas.rs +5 -5
  88. data/ext/ratatui_ruby/src/widgets/table.rs +81 -36
  89. data/lib/ratatui_ruby/buffer/cell.rb +168 -0
  90. data/lib/ratatui_ruby/buffer.rb +15 -0
  91. data/lib/ratatui_ruby/frame.rb +8 -8
  92. data/lib/ratatui_ruby/layout/constraint.rb +95 -0
  93. data/lib/ratatui_ruby/layout/layout.rb +106 -0
  94. data/lib/ratatui_ruby/layout/rect.rb +118 -0
  95. data/lib/ratatui_ruby/layout.rb +19 -0
  96. data/lib/ratatui_ruby/list_state.rb +2 -2
  97. data/lib/ratatui_ruby/schema/layout.rb +1 -1
  98. data/lib/ratatui_ruby/schema/row.rb +66 -0
  99. data/lib/ratatui_ruby/schema/table.rb +10 -10
  100. data/lib/ratatui_ruby/schema/text.rb +27 -2
  101. data/lib/ratatui_ruby/style/style.rb +81 -0
  102. data/lib/ratatui_ruby/style.rb +15 -0
  103. data/lib/ratatui_ruby/table_state.rb +1 -1
  104. data/lib/ratatui_ruby/test_helper/snapshot.rb +24 -0
  105. data/lib/ratatui_ruby/test_helper/style_assertions.rb +1 -1
  106. data/lib/ratatui_ruby/tui/buffer_factories.rb +20 -0
  107. data/lib/ratatui_ruby/tui/canvas_factories.rb +44 -0
  108. data/lib/ratatui_ruby/tui/core.rb +38 -0
  109. data/lib/ratatui_ruby/tui/layout_factories.rb +74 -0
  110. data/lib/ratatui_ruby/tui/state_factories.rb +33 -0
  111. data/lib/ratatui_ruby/tui/style_factories.rb +20 -0
  112. data/lib/ratatui_ruby/tui/text_factories.rb +44 -0
  113. data/lib/ratatui_ruby/tui/widget_factories.rb +195 -0
  114. data/lib/ratatui_ruby/tui.rb +75 -0
  115. data/lib/ratatui_ruby/version.rb +1 -1
  116. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +47 -0
  117. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +25 -0
  118. data/lib/ratatui_ruby/widgets/bar_chart.rb +239 -0
  119. data/lib/ratatui_ruby/widgets/block.rb +192 -0
  120. data/lib/ratatui_ruby/widgets/calendar.rb +84 -0
  121. data/lib/ratatui_ruby/widgets/canvas.rb +231 -0
  122. data/lib/ratatui_ruby/widgets/cell.rb +47 -0
  123. data/lib/ratatui_ruby/widgets/center.rb +59 -0
  124. data/lib/ratatui_ruby/widgets/chart.rb +185 -0
  125. data/lib/ratatui_ruby/widgets/clear.rb +54 -0
  126. data/lib/ratatui_ruby/widgets/cursor.rb +42 -0
  127. data/lib/ratatui_ruby/widgets/gauge.rb +72 -0
  128. data/lib/ratatui_ruby/widgets/line_gauge.rb +80 -0
  129. data/lib/ratatui_ruby/widgets/list.rb +127 -0
  130. data/lib/ratatui_ruby/widgets/list_item.rb +43 -0
  131. data/lib/ratatui_ruby/widgets/overlay.rb +43 -0
  132. data/lib/ratatui_ruby/widgets/paragraph.rb +99 -0
  133. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +31 -0
  134. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +36 -0
  135. data/lib/ratatui_ruby/widgets/row.rb +68 -0
  136. data/lib/ratatui_ruby/widgets/scrollbar.rb +143 -0
  137. data/lib/ratatui_ruby/widgets/shape/label.rb +68 -0
  138. data/lib/ratatui_ruby/widgets/sparkline.rb +134 -0
  139. data/lib/ratatui_ruby/widgets/table.rb +141 -0
  140. data/lib/ratatui_ruby/widgets/tabs.rb +85 -0
  141. data/lib/ratatui_ruby/widgets.rb +40 -0
  142. data/lib/ratatui_ruby.rb +23 -39
  143. data/sig/examples/app_all_events/view.rbs +1 -1
  144. data/sig/examples/app_all_events/view_state.rbs +1 -1
  145. data/sig/ratatui_ruby/schema/row.rbs +22 -0
  146. data/sig/ratatui_ruby/schema/table.rbs +1 -1
  147. data/sig/ratatui_ruby/schema/text.rbs +1 -0
  148. data/sig/ratatui_ruby/session.rbs +29 -49
  149. data/sig/ratatui_ruby/tui/buffer_factories.rbs +10 -0
  150. data/sig/ratatui_ruby/tui/canvas_factories.rbs +14 -0
  151. data/sig/ratatui_ruby/tui/core.rbs +14 -0
  152. data/sig/ratatui_ruby/tui/layout_factories.rbs +19 -0
  153. data/sig/ratatui_ruby/tui/state_factories.rbs +12 -0
  154. data/sig/ratatui_ruby/tui/style_factories.rbs +10 -0
  155. data/sig/ratatui_ruby/tui/text_factories.rbs +14 -0
  156. data/sig/ratatui_ruby/tui/widget_factories.rbs +39 -0
  157. data/sig/ratatui_ruby/tui.rbs +19 -0
  158. data/tasks/autodoc.rake +1 -35
  159. data/tasks/bump/changelog.rb +8 -0
  160. data/tasks/bump/ruby_gem.rb +12 -0
  161. data/tasks/bump/unreleased_section.rb +16 -0
  162. data/tasks/sourcehut.rake +4 -1
  163. metadata +64 -15
  164. data/doc/contributors/dwim_dx.md +0 -366
  165. data/doc/contributors/examples_audit/p1_high.md +0 -21
  166. data/doc/contributors/examples_audit/p2_moderate.md +0 -81
  167. data/doc/contributors/examples_audit.md +0 -41
  168. data/doc/images/app_analytics.png +0 -0
  169. data/doc/images/app_custom_widget.png +0 -0
  170. data/doc/images/app_mouse_events.png +0 -0
  171. data/doc/images/widget_table_flex.png +0 -0
  172. data/lib/ratatui_ruby/session/autodoc.rb +0 -482
  173. data/lib/ratatui_ruby/session.rb +0 -178
  174. data/tasks/autodoc/inventory.rb +0 -63
  175. data/tasks/autodoc/notice.rb +0 -26
  176. data/tasks/autodoc/rbs.rb +0 -38
  177. data/tasks/autodoc/rdoc.rb +0 -45
@@ -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_line, parse_span};
5
6
  use crate::widgets::table_state::RubyTableState;
6
7
  use bumpalo::Bump;
7
8
  use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
@@ -22,7 +23,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
22
23
  let widths_val: Value = node.funcall("widths", ())?;
23
24
  let widths_array = magnus::RArray::from_value(widths_val)
24
25
  .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for widths"))?;
25
- let highlight_style_val: Value = node.funcall("highlight_style", ())?;
26
+ let row_highlight_style_val: Value = node.funcall("row_highlight_style", ())?;
26
27
  let column_highlight_style_val: Value = node.funcall("column_highlight_style", ())?;
27
28
  let cell_highlight_style_val: Value = node.funcall("cell_highlight_style", ())?;
28
29
  let highlight_symbol_val: Value = node.funcall("highlight_symbol", ())?;
@@ -73,8 +74,8 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
73
74
  table = table.block(parse_block(block_val, &bump)?);
74
75
  }
75
76
 
76
- if !highlight_style_val.is_nil() {
77
- table = table.row_highlight_style(parse_style(highlight_style_val)?);
77
+ if !row_highlight_style_val.is_nil() {
78
+ table = table.row_highlight_style(parse_style(row_highlight_style_val)?);
78
79
  }
79
80
 
80
81
  if !column_highlight_style_val.is_nil() {
@@ -158,7 +159,7 @@ pub fn render_stateful(
158
159
  // Build table (ignoring selected_row, selected_column, offset — State is truth)
159
160
  let header_val: Value = node.funcall("header", ())?;
160
161
  let footer_val: Value = node.funcall("footer", ())?;
161
- let highlight_style_val: Value = node.funcall("highlight_style", ())?;
162
+ let row_highlight_style_val: Value = node.funcall("row_highlight_style", ())?;
162
163
  let column_highlight_style_val: Value = node.funcall("column_highlight_style", ())?;
163
164
  let cell_highlight_style_val: Value = node.funcall("cell_highlight_style", ())?;
164
165
  let highlight_symbol_val: Value = node.funcall("highlight_symbol", ())?;
@@ -196,8 +197,8 @@ pub fn render_stateful(
196
197
  if !block_val.is_nil() {
197
198
  table = table.block(parse_block(block_val, &bump)?);
198
199
  }
199
- if !highlight_style_val.is_nil() {
200
- table = table.row_highlight_style(parse_style(highlight_style_val)?);
200
+ if !row_highlight_style_val.is_nil() {
201
+ table = table.row_highlight_style(parse_style(row_highlight_style_val)?);
201
202
  }
202
203
  if !column_highlight_style_val.is_nil() {
203
204
  table = table.column_highlight_style(parse_style(column_highlight_style_val)?);
@@ -228,6 +229,53 @@ pub fn render_stateful(
228
229
 
229
230
  fn parse_row(row_val: Value) -> Result<Row<'static>, Error> {
230
231
  let ruby = magnus::Ruby::get().unwrap();
232
+
233
+ // Check if this is a RatatuiRuby::Row object with cells + style + height + margins
234
+ let class = row_val.class();
235
+ // SAFETY: Immediate conversion to owned string avoids GC-unsafe borrowed reference.
236
+ let class_name = unsafe { class.name() }.into_owned();
237
+
238
+ if class_name == "RatatuiRuby::Widgets::Row" {
239
+ let cells_val: Value = row_val.funcall("cells", ())?;
240
+ let style_val: Value = row_val.funcall("style", ())?;
241
+ let height_val: Value = row_val.funcall("height", ())?;
242
+ let top_margin_val: Value = row_val.funcall("top_margin", ())?;
243
+ let bottom_margin_val: Value = row_val.funcall("bottom_margin", ())?;
244
+
245
+ let cells_array = magnus::RArray::from_value(cells_val).ok_or_else(|| {
246
+ Error::new(ruby.exception_type_error(), "expected array for Row.cells")
247
+ })?;
248
+
249
+ let mut cells = Vec::new();
250
+ for i in 0..cells_array.len() {
251
+ let index = isize::try_from(i)
252
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
253
+ let entry_val: Value = cells_array.entry(index)?;
254
+ cells.push(parse_cell(entry_val)?);
255
+ }
256
+
257
+ let mut row = Row::new(cells);
258
+
259
+ if !style_val.is_nil() {
260
+ row = row.style(parse_style(style_val)?);
261
+ }
262
+ if !height_val.is_nil() {
263
+ let h: u16 = height_val.funcall("to_int", ())?;
264
+ row = row.height(h);
265
+ }
266
+ if !top_margin_val.is_nil() {
267
+ let m: u16 = top_margin_val.funcall("to_int", ())?;
268
+ row = row.top_margin(m);
269
+ }
270
+ if !bottom_margin_val.is_nil() {
271
+ let m: u16 = bottom_margin_val.funcall("to_int", ())?;
272
+ row = row.bottom_margin(m);
273
+ }
274
+
275
+ return Ok(row);
276
+ }
277
+
278
+ // Fallback: plain array of cells
231
279
  let row_array = magnus::RArray::from_value(row_val)
232
280
  .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for row"))?;
233
281
 
@@ -246,42 +294,39 @@ fn parse_cell(cell_val: Value) -> Result<Cell<'static>, Error> {
246
294
  // SAFETY: Immediate conversion to owned string avoids GC-unsafe borrowed reference.
247
295
  let class_name = unsafe { class.name() }.into_owned();
248
296
 
249
- if class_name == "RatatuiRuby::Paragraph" {
297
+ // Try Text::Line first (contains multiple spans)
298
+ if class_name.contains("Line") {
299
+ if let Ok(line) = parse_line(cell_val) {
300
+ return Ok(Cell::from(line));
301
+ }
302
+ }
303
+
304
+ // Try Text::Span
305
+ if class_name.contains("Span") {
306
+ if let Ok(span) = parse_span(cell_val) {
307
+ return Ok(Cell::from(ratatui::text::Line::from(vec![span])));
308
+ }
309
+ }
310
+
311
+ if class_name == "RatatuiRuby::Widgets::Paragraph" {
250
312
  let text: String = cell_val.funcall("text", ())?;
251
313
  let style_val: Value = cell_val.funcall("style", ())?;
252
314
  let cell_style = parse_style(style_val)?;
253
315
  Ok(Cell::from(text).style(cell_style))
254
- } else if class_name == "RatatuiRuby::Style" {
316
+ } else if class_name == "RatatuiRuby::Style::Style" {
255
317
  Ok(Cell::from("").style(parse_style(cell_val)?))
256
- } else if class_name == "RatatuiRuby::Cell" {
257
- let symbol: String = cell_val.funcall("char", ())?;
258
- let fg_val: Value = cell_val.funcall("fg", ())?;
259
- let bg_val: Value = cell_val.funcall("bg", ())?;
260
- let modifiers_val: Value = cell_val.funcall("modifiers", ())?;
261
-
262
- let mut style = ratatui::style::Style::default();
263
- if !fg_val.is_nil() {
264
- if let Some(color) = crate::style::parse_color_value(fg_val)? {
265
- style = style.fg(color);
266
- }
267
- }
268
- if !bg_val.is_nil() {
269
- if let Some(color) = crate::style::parse_color_value(bg_val)? {
270
- style = style.bg(color);
271
- }
272
- }
273
- if let Some(mods_array) = magnus::RArray::from_value(modifiers_val) {
274
- let ruby = magnus::Ruby::get().unwrap();
275
- for i in 0..mods_array.len() {
276
- let index = isize::try_from(i)
277
- .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
278
- let mod_str: String = mods_array.entry::<String>(index)?;
279
- if let Some(modifier) = crate::style::parse_modifier_str(&mod_str) {
280
- style = style.add_modifier(modifier);
281
- }
282
- }
318
+ } else if class_name == "RatatuiRuby::Widgets::Cell" {
319
+ // Widgets::Cell has content (String/Span/Line) and optional style
320
+ let content_val: Value = cell_val.funcall("content", ())?;
321
+ let style_val: Value = cell_val.funcall("style", ())?;
322
+
323
+ // Recursively parse the content (could be String, Span, or Line)
324
+ let mut cell = parse_cell(content_val)?;
325
+
326
+ if !style_val.is_nil() {
327
+ cell = cell.style(parse_style(style_val)?);
283
328
  }
284
- Ok(Cell::from(symbol).style(style))
329
+ Ok(cell)
285
330
  } else {
286
331
  let cell_str: String = cell_val.funcall("to_s", ())?;
287
332
  Ok(Cell::from(cell_str))
@@ -0,0 +1,168 @@
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
+ module Buffer
8
+ # Represents a single cell in the terminal buffer.
9
+ #
10
+ # A terminal grid is made of cells. Each cell contains a character (symbol) and styling (colors, modifiers).
11
+ # When testing, you often need to verify that a specific cell renders correctly.
12
+ #
13
+ # This object encapsulates that state. It provides predicate methods for modifiers, making assertions readable.
14
+ #
15
+ # Use it to inspect the visual state of your application in tests.
16
+ #
17
+ # === Examples
18
+ #
19
+ # cell = RatatuiRuby.get_cell_at(0, 0)
20
+ # cell.char # => "H"
21
+ # cell.fg # => :red
22
+ # cell.bold? # => true
23
+ #
24
+ class Cell
25
+ # The character displayed in the cell.
26
+ #
27
+ # Named to match Ratatui's Cell::symbol() method.
28
+ attr_reader :symbol
29
+
30
+ # Alias for Rubyists who prefer a shorter name.
31
+ alias char symbol
32
+
33
+ # The foreground color of the cell (e.g., :red, :blue, "#ff0000").
34
+ attr_reader :fg
35
+
36
+ # The background color of the cell (e.g., :black, nil).
37
+ attr_reader :bg
38
+
39
+ # The list of active modifiers (e.g., ["bold", "italic"]).
40
+ attr_reader :modifiers
41
+
42
+ # Returns an empty cell (space character, no styles).
43
+ #
44
+ # === Example
45
+ #
46
+ # Buffer::Cell.empty # => #<RatatuiRuby::Buffer::Cell char=" ">
47
+ #
48
+ def self.empty
49
+ new(symbol: " ", fg: nil, bg: nil, modifiers: [])
50
+ end
51
+
52
+ # Returns a default cell (alias for empty).
53
+ #
54
+ # === Example
55
+ #
56
+ # Buffer::Cell.default # => #<RatatuiRuby::Buffer::Cell char=" ">
57
+ #
58
+ def self.default
59
+ empty
60
+ end
61
+
62
+ # Returns a cell with a specific character and no styles.
63
+ #
64
+ # [symbol] String (single character).
65
+ #
66
+ # === Example
67
+ #
68
+ # Buffer::Cell.symbol("X") # => #<RatatuiRuby::Buffer::Cell symbol="X">
69
+ #
70
+ def self.symbol(symbol)
71
+ new(symbol:, fg: nil, bg: nil, modifiers: [])
72
+ end
73
+
74
+ # Alias for Rubyists who prefer a shorter name.
75
+ def self.char(char)
76
+ symbol(char)
77
+ end
78
+
79
+ # Creates a new Cell.
80
+ #
81
+ # [symbol] String (single character). Aliased as <tt>char:</tt>.
82
+ # [fg] Symbol or String (nullable).
83
+ # [bg] Symbol or String (nullable).
84
+ # [modifiers] Array of Strings.
85
+ def initialize(symbol: nil, char: nil, fg: nil, bg: nil, modifiers: [])
86
+ @symbol = (symbol || char || " ").freeze
87
+ @fg = fg&.freeze
88
+ @bg = bg&.freeze
89
+ @modifiers = modifiers.map(&:freeze).freeze
90
+ freeze
91
+ end
92
+
93
+ # Returns true if the cell has the bold modifier.
94
+ def bold?
95
+ modifiers.include?("bold")
96
+ end
97
+
98
+ # Returns true if the cell has the dim modifier.
99
+ def dim?
100
+ modifiers.include?("dim")
101
+ end
102
+
103
+ # Returns true if the cell has the italic modifier.
104
+ def italic?
105
+ modifiers.include?("italic")
106
+ end
107
+
108
+ # Returns true if the cell has the underlined modifier.
109
+ def underlined?
110
+ modifiers.include?("underlined")
111
+ end
112
+
113
+ # Returns true if the cell has the slow_blink modifier.
114
+ def slow_blink?
115
+ modifiers.include?("slow_blink")
116
+ end
117
+
118
+ # Returns true if the cell has the rapid_blink modifier.
119
+ def rapid_blink?
120
+ modifiers.include?("rapid_blink")
121
+ end
122
+
123
+ # Returns true if the cell has the reversed modifier.
124
+ def reversed?
125
+ modifiers.include?("reversed")
126
+ end
127
+
128
+ # Returns true if the cell has the hidden modifier.
129
+ def hidden?
130
+ modifiers.include?("hidden")
131
+ end
132
+
133
+ # Returns true if the cell has the crossed_out modifier.
134
+ def crossed_out?
135
+ modifiers.include?("crossed_out")
136
+ end
137
+
138
+ # Checks equality with another Cell.
139
+ def ==(other)
140
+ other.is_a?(Cell) &&
141
+ char == other.char &&
142
+ fg == other.fg &&
143
+ bg == other.bg &&
144
+ modifiers == other.modifiers
145
+ end
146
+
147
+ # Returns a string representation of the cell.
148
+ def inspect
149
+ parts = ["symbol=#{symbol.inspect}"]
150
+ parts << "fg=#{fg.inspect}" if fg
151
+ parts << "bg=#{bg.inspect}" if bg
152
+ parts << "modifiers=#{modifiers.inspect}" unless modifiers.empty?
153
+ "#<#{self.class} #{parts.join(' ')}>"
154
+ end
155
+
156
+ # Returns the cell's character.
157
+ def to_s
158
+ symbol
159
+ end
160
+
161
+ # Support for pattern matching.
162
+ # Supports both <tt>:symbol</tt> and <tt>:char</tt> keys.
163
+ def deconstruct_keys(keys)
164
+ { symbol:, char: symbol, fg:, bg:, modifiers: }
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,15 @@
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
+ # Buffer primitives for terminal cell inspection.
8
+ #
9
+ # This module mirrors +ratatui::buffer+ and contains:
10
+ # - {Cell} — Single terminal cell (for inspection)
11
+ module Buffer
12
+ end
13
+ end
14
+
15
+ require_relative "buffer/cell"
@@ -21,7 +21,7 @@ module RatatuiRuby
21
21
  # Frame is an *I/O handle*, not a data object. It has side effects
22
22
  # (render_widget, set_cursor_position) and is intentionally *not*
23
23
  # Ractor-shareable. Passing it to helper methods during the draw block is
24
- # fine. However, do not include it in immutable TEA Models/Messages or pass
24
+ # fine. However, do not include it in immutable Models/Messages or pass
25
25
  # it to other Ractors. Frame is only valid during the draw block's execution.
26
26
  #
27
27
  # === Examples
@@ -29,7 +29,7 @@ module RatatuiRuby
29
29
  # Basic usage with a single widget:
30
30
  #
31
31
  # RatatuiRuby.draw do |frame|
32
- # paragraph = RatatuiRuby::Paragraph.new(text: "Hello, world!")
32
+ # paragraph = RatatuiRuby::Widgets::Paragraph.new(text: "Hello, world!")
33
33
  # frame.render_widget(paragraph, frame.area)
34
34
  # end
35
35
  #
@@ -40,8 +40,8 @@ module RatatuiRuby
40
40
  # frame.area,
41
41
  # direction: :horizontal,
42
42
  # constraints: [
43
- # RatatuiRuby::Constraint.length(20),
44
- # RatatuiRuby::Constraint.fill(1)
43
+ # RatatuiRuby::Layout::Constraint.length(20),
44
+ # RatatuiRuby::Layout::Constraint.fill(1)
45
45
  # ]
46
46
  # )
47
47
  #
@@ -86,7 +86,7 @@ module RatatuiRuby
86
86
  # === Example
87
87
  #
88
88
  # RatatuiRuby.draw do |frame|
89
- # para = RatatuiRuby::Paragraph.new(text: "Content")
89
+ # para = RatatuiRuby::Widgets::Paragraph.new(text: "Content")
90
90
  # frame.render_widget(para, frame.area)
91
91
  # end
92
92
  #
@@ -122,7 +122,7 @@ module RatatuiRuby
122
122
  # @list_state = RatatuiRuby::ListState.new
123
123
  #
124
124
  # RatatuiRuby.draw do |frame|
125
- # list = RatatuiRuby::List.new(items: ["A", "B"])
125
+ # list = RatatuiRuby::Widgets::List.new(items: ["A", "B"])
126
126
  # frame.render_stateful_widget(list, frame.area, @list_state)
127
127
  # end
128
128
  #
@@ -160,9 +160,9 @@ module RatatuiRuby
160
160
  #
161
161
  # RatatuiRuby.draw do |frame|
162
162
  # # Render the input field
163
- # prompt = RatatuiRuby::Paragraph.new(
163
+ # prompt = RatatuiRuby::Widgets::Paragraph.new(
164
164
  # text: "#{PREFIX}#{username} ]",
165
- # block: RatatuiRuby::Block.new(borders: :all)
165
+ # block: RatatuiRuby::Widgets::Block.new(borders: :all)
166
166
  # )
167
167
  # frame.render_widget(prompt, frame.area)
168
168
  #
@@ -0,0 +1,95 @@
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
+ module Layout
8
+ # Defines the sizing rule for a layout section.
9
+ #
10
+ # Flexible layouts need rules. You can't just place widgets at absolute coordinates; they must adapt to changing terminal sizes.
11
+ #
12
+ # This class defines the rules of engagement. It tells the layout engine exactly how much space a section requires relative to others.
13
+ #
14
+ # Mix and match fixed lengths, percentages, ratios, and minimums. Build layouts that breathe.
15
+ #
16
+ # === Examples
17
+ #
18
+ # Layout::Constraint.length(5) # Exactly 5 cells
19
+ # Layout::Constraint.percentage(50) # Half the available space
20
+ # Layout::Constraint.min(10) # At least 10 cells, maybe more
21
+ # Layout::Constraint.fill(1) # Fill remaining space (weight 1)
22
+ class Constraint < Data.define(:type, :value)
23
+ ##
24
+ # :attr_reader: type
25
+ # The type of constraint.
26
+ #
27
+ # <tt>:length</tt>, <tt>:percentage</tt>, <tt>:min</tt>, <tt>:max</tt>, <tt>:fill</tt>, or <tt>:ratio</tt>.
28
+
29
+ ##
30
+ # :attr_reader: value
31
+ # The numeric value (or array for ratio) associated with the rule.
32
+
33
+ # Requests a fixed size.
34
+ #
35
+ # Layout::Constraint.length(10) # 10 characters wide/high
36
+ #
37
+ # [v] Number of cells (Integer).
38
+ def self.length(v)
39
+ new(type: :length, value: Integer(v))
40
+ end
41
+
42
+ # Requests a percentage of available space.
43
+ #
44
+ # Layout::Constraint.percentage(25) # 25% of the area
45
+ #
46
+ # [v] Percentage 0-100 (Integer).
47
+ def self.percentage(v)
48
+ new(type: :percentage, value: Integer(v))
49
+ end
50
+
51
+ # Enforces a minimum size.
52
+ #
53
+ # Layout::Constraint.min(5) # At least 5 cells
54
+ #
55
+ # This section will grow if space permits, but never shrink below +v+.
56
+ #
57
+ # [v] Minimum cells (Integer).
58
+ def self.min(v)
59
+ new(type: :min, value: Integer(v))
60
+ end
61
+
62
+ # Enforces a maximum size.
63
+ #
64
+ # Layout::Constraint.max(10) # At most 10 cells
65
+ #
66
+ # [v] Maximum cells (Integer).
67
+ def self.max(v)
68
+ new(type: :max, value: Integer(v))
69
+ end
70
+
71
+ # Fills remaining space proportionally.
72
+ #
73
+ # Layout::Constraint.fill(1) # Equal share
74
+ # Layout::Constraint.fill(2) # Double share
75
+ #
76
+ # Fill constraints distribute any space left after satisfying strict rules.
77
+ # They behave like flex-grow. A fill(2) takes twice as much space as a fill(1).
78
+ #
79
+ # [v] Proportional weight (Integer, default: 1).
80
+ def self.fill(v = 1)
81
+ new(type: :fill, value: Integer(v))
82
+ end
83
+
84
+ # Requests a specific ratio of the total space.
85
+ #
86
+ # Layout::Constraint.ratio(1, 3) # 1/3rd of the area
87
+ #
88
+ # [numerator] Top part of fraction (Integer).
89
+ # [denominator] Bottom part of fraction (Integer).
90
+ def self.ratio(numerator, denominator)
91
+ new(type: :ratio, value: [Integer(numerator), Integer(denominator)])
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,106 @@
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
+ module Layout
8
+ # Divides an area into smaller chunks.
9
+ #
10
+ # Terminal screens vary in size. Hardcoded positions break when the window resizes. You need a way to organize space dynamically.
11
+ #
12
+ # This class manages geometry. It splits a given area into multiple sections based on a list of constraints.
13
+ #
14
+ # Use layouts to build responsive grids. Stack sections vertically for a sidebar-main structure. Partition them horizontally for headers and footers. Let the layout engine do the math.
15
+ #
16
+ # {rdoc-image:/doc/images/widget_layout_split.png}[link:/examples/widget_layout_split/app_rb.html]
17
+ #
18
+ # === Example
19
+ #
20
+ # Run the interactive demo from the terminal:
21
+ #
22
+ # ruby examples/widget_layout_split/app.rb
23
+ class Layout < Data.define(:direction, :constraints, :children, :flex)
24
+ ##
25
+ # :attr_reader: direction
26
+ # Direction of the split.
27
+ #
28
+ # Either <tt>:vertical</tt> (top to bottom) or <tt>:horizontal</tt> (left to right).
29
+ #
30
+ # layout.direction # => :vertical
31
+
32
+ ##
33
+ # :attr_reader: constraints
34
+ # Array of rules defining section sizes.
35
+ #
36
+ # See RatatuiRuby::Layout::Constraint.
37
+
38
+ ##
39
+ # :attr_reader: children
40
+ # Widgets to render in each section (optional).
41
+ #
42
+ # If provided, `children[i]` is rendered into the area defined by `constraints[i]`.
43
+
44
+ ##
45
+ # :attr_reader: flex
46
+ # Strategy for distributing extra space.
47
+ #
48
+ # One of <tt>:legacy</tt>, <tt>:start</tt>, <tt>:center</tt>, <tt>:end</tt>, <tt>:space_between</tt>, <tt>:space_around</tt>.
49
+
50
+ # :nodoc:
51
+ FLEX_MODES = %i[legacy start center end space_between space_around space_evenly].freeze
52
+
53
+ # Creates a new Layout.
54
+ #
55
+ # [direction]
56
+ # <tt>:vertical</tt> or <tt>:horizontal</tt> (default: <tt>:vertical</tt>).
57
+ # [constraints]
58
+ # list of Constraint objects.
59
+ # [children]
60
+ # List of widgets to render (optional).
61
+ # [flex]
62
+ # Flex mode for spacing (default: <tt>:legacy</tt>).
63
+ def initialize(direction: :vertical, constraints: [], children: [], flex: :legacy)
64
+ super
65
+ end
66
+
67
+ # Splits an area into multiple rectangles.
68
+ #
69
+ # This is a pure calculation helper for hit testing. It computes where
70
+ # widgets *would* be placed without actually rendering them.
71
+ #
72
+ # rects = Layout::Layout.split(
73
+ # area,
74
+ # direction: :horizontal,
75
+ # constraints: [Layout::Constraint.percentage(50), Layout::Constraint.percentage(50)]
76
+ # )
77
+ # left, right = rects
78
+ #
79
+ # [area]
80
+ # The area to split. Can be a <tt>Rect</tt> or a <tt>Hash</tt> containing <tt>:x</tt>, <tt>:y</tt>, <tt>:width</tt>, and <tt>:height</tt>.
81
+ # [direction]
82
+ # <tt>:vertical</tt> or <tt>:horizontal</tt> (default: <tt>:vertical</tt>).
83
+ # [constraints]
84
+ # Array of <tt>Constraint</tt> objects defining section sizes.
85
+ # [flex]
86
+ # Flex mode for spacing (default: <tt>:legacy</tt>).
87
+ #
88
+ # Returns an Array of <tt>Rect</tt> objects.
89
+ def self.split(area, direction: :vertical, constraints:, flex: :legacy)
90
+ # Duck-typing: If it lacks geometry methods but can be a Hash, convert it.
91
+ if !area.respond_to?(:x) && area.respond_to?(:to_h)
92
+ # Assume it's a Hash-like object with :x, :y, etc.
93
+ hash = area.to_h
94
+ area = Rect.new(
95
+ x: hash.fetch(:x, 0),
96
+ y: hash.fetch(:y, 0),
97
+ width: hash.fetch(:width, 0),
98
+ height: hash.fetch(:height, 0)
99
+ )
100
+ end
101
+ raw_rects = _split(area, direction, constraints, flex)
102
+ raw_rects.map { |r| Rect.new(x: r[:x], y: r[:y], width: r[:width], height: r[:height]) }
103
+ end
104
+ end
105
+ end
106
+ end