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
@@ -8,62 +8,102 @@ SPDX-License-Identifier: CC-BY-SA-4.0
8
8
  This example demonstrates how to build a **Feature-Rich Interactive Application** using `ratatui_ruby`.
9
9
 
10
10
  It goes beyond simple widgets to show a complete, real-world architecture for handling:
11
- - **Complex State Management** (Input validation, undo/redo prep, clipboard interaction)
11
+ - **Complex State Management** (Input validation, clipboard interaction)
12
12
  - **Mouse Interaction & Hit Testing**
13
13
  - **Dynamic Layouts**
14
14
  - **Modal Dialogs**
15
15
 
16
- ## Architecture: The "Scene-Orchestrated" Pattern
16
+ ## Architecture: The "Proto-Kit" Pattern (Component-Based)
17
17
 
18
- This app uses a pattern we call **"Scene-Orchestrated MVC"**.
18
+ This app uses a **Strict Component-Based Architecture** where every UI element encapsulates its own **Rendering**, **State**, and **Event Handling**.
19
19
 
20
- ### 1. The App (Controller)
21
- The main `App` class (`app.rb`) acts as the Controller. It:
22
- - Holds the source of truth (the State).
23
- - Runs the Event Loop.
24
- - Routes input events to the appropriate handler.
25
- - Initializes the `Scene`.
20
+ ### The Component Contract
26
21
 
27
- ### 2. The Scene (View / Layout Engine)
28
- The `Scene` class (`scene.rb`) acts as the primary View. Unlike simple examples where the render logic is in the `App` class, here the **Scene owns the Layout**.
29
- - **Composition**: It takes purely logical objects (`Palette`, `Input`) and decides how to present them.
30
- - **Hit Testing**: Crucially, the Scene **caches layout rectangles** (like `@export_area_rect`) during the render pass so the Controller knows *where* things are to handle clicks later.
22
+ Every component implements this duck-type interface:
31
23
 
32
- ### 3. The Logical Models
33
- The application logic is broken down into small, testable Plain Old Ruby Objects (POROs) that know nothing about the TUI:
34
- - **`Color`**: Handles hex parsing, contrast calculation, and transformations.
35
- - **`Palette`**: Generates color harmonies.
36
- - **`Input`**: Manages the text buffer and validation state.
37
- - **`Clipboard`**: Wraps system commands.
24
+ ```ruby
25
+ # Renders the component into the given area
26
+ # Caches `area` for hit testing
27
+ def render(tui, frame, area)
28
+ @area = area
29
+ # ... render using frame.render_widget
30
+ end
38
31
 
39
- This separation means your **business logic remains pure Ruby**, while the TUI layer focuses solely on presentation.
32
+ # Processes events; returns a symbolic signal or nil
33
+ def handle_event(event) -> Symbol | nil
34
+ # Returns :consumed, :submitted, :copy_requested, etc.
35
+ end
36
+
37
+ # Optional: time-based updates
38
+ def tick
39
+ end
40
+ ```
41
+
42
+ ### 1. The MainContainer (Orchestrator)
43
+
44
+ The `MainContainer` class (`main_container.rb`) owns all child components and orchestrates the UI:
45
+
46
+ - **Layout Phase:** Calculates `Rect`s using `tui.layout_split`.
47
+ - **Delegation Phase:** Calls `child.render(tui, frame, child_area)` for each component.
48
+ - **Event Routing (Chain of Responsibility):** Delegates events front-to-back. The modal dialog gets priority when active.
49
+ - **Mediator Pattern:** Interprets symbolic signals (`:submitted`, `:copy_requested`) to coordinate cross-component effects.
50
+
51
+ ### 2. Self-Contained Components
52
+
53
+ Each UI element is a self-contained component:
54
+
55
+ - **`Input`**: Text entry with validation. Returns `:submitted` when Enter is pressed.
56
+ - **`Palette`**: Displays color harmonies. Accepts `update_color` from the container.
57
+ - **`ExportPane`**: Shows HEX/RGB/HSL formats. Returns `:copy_requested` when clicked.
58
+ - **`Controls`**: Displays keyboard shortcuts. Has a `tick` lifecycle for clipboard feedback.
59
+ - **`CopyDialog`**: Modal confirmation dialog. Returns `:consumed` when handling events.
60
+
61
+ ### 3. The App (Minimal Runner)
62
+
63
+ The `App` class (`app.rb`) is a thin runner:
64
+ - Creates the `MainContainer`.
65
+ - Runs the main loop: `tick` → `render` → `poll` → `handle_event`.
66
+ - Checks for quit events.
40
67
 
41
68
  ## Key Features Showcased
42
69
 
43
- ### 🖱️ Mouse Support & Hit Testing
44
- See `Scene#export_rect` and `App#handle_main_input`.
45
- The app detects clicks on specific UI elements. This handles the problem: *"How do I know which button the user clicked?"*
46
- - **Solution**: The rendering layer (Scene) exposes the `Rect` of interactive areas. The event loop checks `rect.contains?(mouse_x, mouse_y)`.
70
+ ### 🖱️ Encapsulated Hit Testing
71
+
72
+ Components cache their render area (`@area`) during `render`. In `handle_event`, they check `@area&.contains?(x, y)` to detect clicks. The container never calculates coordinates—hit testing is fully encapsulated.
73
+
74
+ ### 🔲 Modal Dialogs via Chain of Responsibility
75
+
76
+ When `CopyDialog` is active, the `MainContainer` offers it events first. If it returns `:consumed`, event propagation stops. This creates modal behavior without explicit flags in the app.
77
+
78
+ ### 📡 Symbolic Signals (Mediator Pattern)
79
+
80
+ Components return semantic symbols instead of just `:consumed`:
81
+ - `Input` returns `:submitted` when the user presses Enter.
82
+ - `ExportPane` returns `:copy_requested` when clicked.
83
+
84
+ The `MainContainer` interprets these signals to coordinate cross-component communication:
85
+
86
+ ```ruby
87
+ result = @input.handle_event(event)
88
+ case result
89
+ when :submitted
90
+ @palette.update_color(@input.parsed_color)
91
+ return :consumed
92
+ end
93
+ ```
47
94
 
48
- ### 🔲 Modal Dialogs
49
- See `CopyDialog`.
50
- The app implements a modal overlay that intercepts input.
51
- - **Pattern**: The `App` checks `if dialog.active?`. If true, it routes events *only* to the dialog, effectively "blocking" the main UI.
95
+ ### ⏱️ Lifecycle Hooks (`tick`)
52
96
 
53
- ### 🎨 Advanced Styling & Layout
54
- - **Dynamic Constraints**: Layouts that adapt to content.
55
- - **Visual Feedback**:
56
- - Input fields turn red on error.
57
- - Clipboard messages fade out over time (`Clipboard#tick`).
58
- - Text colors automatically adjust for contrast (Black text on light backgrounds, White on dark).
97
+ Components can have time-based updates. `Controls#tick` delegates to `Clipboard#tick` to decrement the feedback timer.
59
98
 
60
- ## Problem Solving: What you can learn
99
+ ## Problem Solving: What You Can Learn
61
100
 
62
101
  Read this example if you are trying to solve:
63
- 1. **"How do I structure a larger app?"** -> Move render logic out of `App` and into a `Scene` or `View` class.
64
- 2. **"How do I handle mouse clicks?"** -> Cache the `Rect` during render.
65
- 3. **"How do I make a popup?"** -> Use a state flag (`active?`) to conditional render on top of everything else (z-ordering) and hijack the input loop.
66
- 4. **"How do I validate input?"** -> Wrap strings in an `Input` object that tracks both keypresses and validation errors.
102
+ 1. **"How do I structure a larger app?"** Use the Component Contract and a Container for orchestration.
103
+ 2. **"How do I handle mouse clicks?"** Cache `@area` during render; check `contains?` in `handle_event`.
104
+ 3. **"How do I make a popup?"** Use Chain of Responsibility: the active modal gets events first.
105
+ 4. **"How do I coordinate between components?"** Use symbolic signals and the Mediator pattern.
106
+ 5. **"How do I validate input?"** → Encapsulate validation inside the `Input` component.
67
107
 
68
108
  ## Usage
69
109
 
@@ -79,16 +119,16 @@ ruby examples/app_color_picker/app.rb
79
119
 
80
120
  Complex applications require structured state habits. This Color Picker and the [App All Events](../app_all_events/README.md) example demonstrate two different approaches.
81
121
 
82
- ### The Tool Approach (Color Picker)
122
+ ### The Tool Approach (Color Picker - Proto-Kit)
83
123
 
84
- Tools require interaction. Users click buttons and drag sliders. The Controller needs to know where components exist on screen. MVVM hides this layout data.
124
+ Tools require interaction. Users click buttons and drag sliders. Components need to know where they exist on screen for hit testing. The Container orchestrates cross-component effects.
85
125
 
86
- This example uses a "Scene" pattern. The View exposes layout rectangles. The Controller uses these rectangles to handle mouse clicks.
126
+ This example uses the **Proto-Kit (Component-Based)** pattern. Each component owns its own state, rendering, and event handling. The Container routes events and mediates communication.
87
127
 
88
128
  Use this pattern for forms, editors, and mouse-driven tools.
89
129
 
90
- ### The Dashboard Approach (AppAllEvents)
130
+ ### The Dashboard Approach (AppAllEvents - Proto-TEA)
91
131
 
92
- Dashboards display data. They rarely require complex mouse interaction. Strict MVVM works best there. The View is a pure function. It accepts a `ViewState` and draws it. It ignores input. This simplifies testing.
132
+ Dashboards display data. They rarely require complex mouse interaction. Proto-TEA (Model-View-Update) works best there. State is immutable. Logic is pure. Updates are predictable. This simplifies testing.
93
133
 
94
134
  Use that pattern for logs, monitors, and data viewers.
@@ -7,11 +7,7 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
7
7
  $LOAD_PATH.unshift File.expand_path(__dir__)
8
8
 
9
9
  require "ratatui_ruby"
10
- require_relative "input"
11
- require_relative "palette"
12
- require_relative "clipboard"
13
- require_relative "copy_dialog"
14
- require_relative "scene"
10
+ require_relative "main_container"
15
11
 
16
12
  # A terminal-based color picker application.
17
13
  #
@@ -21,26 +17,27 @@ require_relative "scene"
21
17
  # This application solves the problem by providing an interactive interface. It parses hex strings,
22
18
  # generates palettes, and displays them visually in the terminal.
23
19
  #
24
- # Use it to experiment with color combinations and quickly find the right hex codes.
20
+ # === Architecture
21
+ #
22
+ # This example uses the Proto-Kit (Component-Based) pattern:
23
+ # - **Components**: Self-contained UI elements with `render`, `handle_event`, and optional `tick`
24
+ # - **Container**: Owns layout, delegates to children, routes events via Chain of Responsibility
25
+ # - **Mediator**: Container interprets symbolic signals (`:consumed`, `:submitted`) for cross-component effects
25
26
  #
26
27
  # === Examples
27
28
  #
28
29
  # AppColorPicker.new.run
29
30
  #
30
31
  class AppColorPicker
31
- # Creates a new <tt>AppColorPicker</tt> instance with a default palette and clipboard.
32
+ # Creates a new <tt>AppColorPicker</tt> instance.
32
33
  def initialize
33
- @input = Input.new
34
- @palette = Palette.new(@input.parse)
35
- @clipboard = Clipboard.new
36
- @dialog = CopyDialog.new(@clipboard)
37
- @scene = nil
34
+ @container = nil
38
35
  end
39
36
 
40
37
  # Starts the terminal session and enters the main event loop.
41
38
  #
42
- # This method initializes the terminal, renders the initial scene, and polls for
43
- # input until the user quits.
39
+ # This method initializes the terminal, creates the MainContainer, and runs
40
+ # the event loop until the user quits.
44
41
  #
45
42
  # === Example
46
43
  #
@@ -49,62 +46,27 @@ class AppColorPicker
49
46
  #
50
47
  def run
51
48
  RatatuiRuby.run do |tui|
52
- @scene = Scene.new(tui)
53
- loop do
54
- render(tui)
55
- result = handle_input(tui)
56
- break if result == :quit
57
- end
58
- end
59
- end
60
-
61
- private def render(tui)
62
- @clipboard.tick
63
- tui.draw do |frame|
64
- @scene.render(frame, input: @input, palette: @palette, clipboard: @clipboard, dialog: @dialog)
65
- end
66
- end
49
+ @container = MainContainer.new(tui)
67
50
 
68
- private def handle_input(tui)
69
- event = tui.poll_event
70
- @input.clear_error unless @dialog.active?
51
+ loop do
52
+ @container.tick
53
+ tui.draw { |frame| @container.render(tui, frame, frame.area) }
71
54
 
72
- if @dialog.active?
73
- handle_dialog_input(event)
74
- else
75
- handle_main_input(event)
76
- end
77
- end
55
+ event = tui.poll_event
56
+ break if quit_event?(event)
78
57
 
79
- private def handle_dialog_input(event)
80
- result = @dialog.handle_input(event)
81
- case event
82
- in { type: :key, code: "q" } | { type: :key, code: "esc" } | { type: :key, code: "c", modifiers: ["ctrl"] }
83
- :quit
84
- else
85
- result
58
+ @container.handle_event(event)
59
+ end
86
60
  end
87
61
  end
88
62
 
89
- private def handle_main_input(event)
63
+ private def quit_event?(event)
90
64
  case event
91
- in { type: :key, code: "q" } | { type: :key, code: "esc" } | { type: :key, code: "c", modifiers: ["ctrl"] }
92
- :quit
93
- in { type: :key, code: "enter" }
94
- @palette = Palette.new(@input.parse)
95
- in { type: :key, code: "backspace" }
96
- @input.delete_char
97
- in { type: :paste, content: }
98
- @input.set(content)
99
- @palette = Palette.new(@input.parse)
100
- in { type: :key, code: code }
101
- @input.append_char(code)
102
- in { type: :mouse, kind: "down", button: "left", x:, y: }
103
- if @scene && @scene.export_rect&.contains?(x, y) && @palette.main
104
- @dialog.open(@palette.main.hex)
105
- end
65
+ in { type: :key, code: "q" } | { type: :key, code: "esc" } |
66
+ { type: :key, code: "c", modifiers: [/ctrl/] }
67
+ true
106
68
  else
107
- nil
69
+ false
108
70
  end
109
71
  end
110
72
  end
@@ -0,0 +1,90 @@
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
+ # A display-only component showing keyboard shortcuts and clipboard feedback.
7
+ #
8
+ # Users need to know what keys are available. They also need feedback when
9
+ # they copy a color. This component renders the controls section.
10
+ #
11
+ # === Component Contract
12
+ #
13
+ # - `render(tui, frame, area, clipboard:)`: Draws the controls; stores `area`
14
+ # - `handle_event(event) -> nil`: Display-only, always returns nil
15
+ # - `tick`: Delegates to clipboard for time-based feedback updates
16
+ #
17
+ # === Example
18
+ #
19
+ # controls = Controls.new
20
+ # controls.render(tui, frame, area, clipboard: clipboard)
21
+ # controls.tick(clipboard)
22
+ class Controls
23
+ def initialize
24
+ @area = nil
25
+ @hotkey_style = nil
26
+ end
27
+
28
+ # The cached render area.
29
+ attr_reader :area
30
+
31
+ # Renders the controls section into the given area.
32
+ #
33
+ # Shows keyboard shortcuts and clipboard feedback message if one is active.
34
+ #
35
+ # [tui] Session or TUI factory object
36
+ # [frame] Frame object from RatatuiRuby.draw block
37
+ # [area] Rect area to draw into
38
+ # [clipboard] Clipboard object for feedback message
39
+ #
40
+ # === Example
41
+ #
42
+ # controls.render(tui, frame, control_area, clipboard: clipboard)
43
+ def render(tui, frame, area, clipboard:)
44
+ @area = area
45
+ @hotkey_style ||= tui.style(modifiers: [:bold, :underlined])
46
+ widget = build_widget(tui, clipboard)
47
+ frame.render_widget(widget, area)
48
+ end
49
+
50
+ # Display-only component; always returns nil.
51
+ def handle_event(_event)
52
+ nil
53
+ end
54
+
55
+ # Delegates tick to the clipboard for time-based updates.
56
+ #
57
+ # [clipboard] Clipboard object to tick
58
+ def tick(clipboard)
59
+ clipboard.tick
60
+ end
61
+
62
+ private def build_widget(tui, clipboard)
63
+ control_lines = [
64
+ tui.text_line(spans: [
65
+ tui.text_span(content: "a-z/0-9", style: @hotkey_style),
66
+ tui.text_span(content: ": Type "),
67
+ tui.text_span(content: "enter", style: @hotkey_style),
68
+ tui.text_span(content: ": Parse "),
69
+ tui.text_span(content: "bksp", style: @hotkey_style),
70
+ tui.text_span(content: ": Erase "),
71
+ tui.text_span(content: "esc", style: @hotkey_style),
72
+ tui.text_span(content: ": Quit"),
73
+ ]),
74
+ ]
75
+
76
+ unless clipboard.message.empty?
77
+ control_lines << tui.text_line(spans: [
78
+ tui.text_span(content: clipboard.message, style: tui.style(fg: :green, modifiers: [:bold])),
79
+ ])
80
+ end
81
+
82
+ tui.block(
83
+ title: "Controls",
84
+ borders: [:all],
85
+ children: [
86
+ tui.paragraph(text: control_lines),
87
+ ]
88
+ )
89
+ end
90
+ end
@@ -5,39 +5,41 @@
5
5
 
6
6
  require_relative "clipboard"
7
7
 
8
- # A confirmation dialog for copying text to the clipboard.
8
+ # A self-contained modal dialog component for copying text to the clipboard.
9
9
  #
10
10
  # Users click on content they want to copy. The app needs to confirm: "Are you
11
- # sure?" Managing dialog state (visible, selection, active), rendering the
12
- # dialog, and dispatching keyboard events manually is tedious.
11
+ # sure?" This component owns dialog state, renders itself, and handles keyboard
12
+ # input.
13
13
  #
14
- # This object owns dialog state and lifecycle. It renders itself. It responds
15
- # to keyboard input. It delegates clipboard operations to a Clipboard.
14
+ # === Component Contract
16
15
  #
17
- # Use it to build copy-on-click interactions with user confirmation.
16
+ # - `render(tui, frame, area)`: Draws the dialog; stores `area`
17
+ # - `handle_event(event) -> Symbol | nil`: Returns `:consumed` when handled
18
+ # - `open(text)`: Opens the dialog with the text to copy
19
+ # - `close`: Closes the dialog
20
+ # - `active?`: True if the dialog is visible
18
21
  #
19
22
  # === Example
20
23
  #
21
- # clipboard = Clipboard.new
22
24
  # dialog = CopyDialog.new(clipboard)
23
- #
24
- # # Open the dialog
25
25
  # dialog.open("#FF0000")
26
- # dialog.active? # => true
27
26
  #
28
- # # Handle input
29
- # result = dialog.handle_input(event) # Routes to :copied or :cancelled
27
+ # result = dialog.handle_event(event)
28
+ # # result == :consumed when dialog handled the event
30
29
  #
31
- # # Render
32
- # widget = dialog.render(tui, area)
30
+ # dialog.render(tui, frame, center_area)
33
31
  class CopyDialog
34
32
  def initialize(clipboard)
35
33
  @clipboard = clipboard
36
34
  @text = ""
37
35
  @selected = :yes
38
36
  @active = false
37
+ @area = nil
39
38
  end
40
39
 
40
+ # The cached render area.
41
+ attr_reader :area
42
+
41
43
  # Opens the dialog with text to copy.
42
44
  #
43
45
  # Initializes selection to <tt>:yes</tt> and sets active to true.
@@ -48,7 +50,6 @@ class CopyDialog
48
50
  #
49
51
  # dialog.open("#FF0000")
50
52
  # dialog.active? # => true
51
- # dialog.text # => "#FF0000"
52
53
  def open(text)
53
54
  @text = text
54
55
  @selected = :yes
@@ -61,73 +62,68 @@ class CopyDialog
61
62
  end
62
63
 
63
64
  # True if the dialog is currently open and visible.
65
+ def active?
66
+ @active
67
+ end
68
+
69
+ # Renders the dialog into the given area.
70
+ #
71
+ # Shows the text to copy, Yes/No buttons with current selection highlighted,
72
+ # and keyboard instructions.
73
+ #
74
+ # [tui] Session or TUI factory object
75
+ # [frame] Frame object from RatatuiRuby.draw block
76
+ # [area] Rect area to draw into
64
77
  #
65
78
  # === Example
66
79
  #
67
- # dialog.open("text")
68
- # dialog.active? # => true
69
- # dialog.close
70
- # dialog.active? # => false
71
- def active?
72
- @active
80
+ # dialog.render(tui, frame, center_area)
81
+ def render(tui, frame, area)
82
+ @area = area
83
+ widget = build_widget(tui)
84
+ frame.render_widget(widget, area)
73
85
  end
74
86
 
75
87
  # Processes a keyboard event and updates selection or closes the dialog.
76
88
  #
77
- # Left/h moves selection to :yes. Right/l moves to :no. Enter confirms.
78
- # Y/N hotkeys also work (Y copies immediately, N cancels). Returns nil for
79
- # all handled events; does nothing if the dialog is inactive.
89
+ # Returns:
90
+ # - `:consumed` when the event was handled
91
+ # - `nil` when the event was ignored or dialog is inactive
80
92
  #
81
- # [event] Hash event from RatatuiRuby.poll_event
93
+ # [event] Event from RatatuiRuby.poll_event
82
94
  #
83
95
  # === Example
84
96
  #
85
- # dialog.open("text")
86
- # dialog.handle_input({ type: :key, code: "left" })
87
- # dialog.handle_input({ type: :key, code: "enter" })
88
- # dialog.active? # => false
89
- def handle_input(event)
97
+ # result = dialog.handle_event(event)
98
+ def handle_event(event)
90
99
  return nil unless @active
91
100
 
92
101
  case event
93
102
  in { type: :key, code: "left" } | { type: :key, code: "h" }
94
103
  @selected = :yes
95
- nil
104
+ :consumed
96
105
  in { type: :key, code: "right" } | { type: :key, code: "l" }
97
106
  @selected = :no
98
- nil
107
+ :consumed
99
108
  in { type: :key, code: "enter" }
100
109
  if @selected == :yes
101
110
  @clipboard.copy(@text)
102
111
  end
103
112
  @active = false
104
- nil
113
+ :consumed
105
114
  in { type: :key, code: "y" }
106
115
  @clipboard.copy(@text)
107
116
  @active = false
108
- nil
117
+ :consumed
109
118
  in { type: :key, code: "n" }
110
119
  @active = false
111
- nil
120
+ :consumed
112
121
  else
113
122
  nil
114
123
  end
115
124
  end
116
125
 
117
- # Renders the dialog widget for display in a TUI frame.
118
- #
119
- # Shows the text to copy, Yes/No buttons with current selection highlighted,
120
- # and keyboard instructions. Renders only when active.
121
- #
122
- # [tui] Session or TUI factory object
123
- # [area] Rect area for the dialog
124
- #
125
- # === Example
126
- #
127
- # dialog.open("#FF0000")
128
- # widget = dialog.render(tui, center_area)
129
- # frame.render_widget(widget, center_area)
130
- def render(tui, area)
126
+ private def build_widget(tui)
131
127
  yes_style = if @selected == :yes
132
128
  tui.style(bg: :cyan, fg: :black, modifiers: [:bold])
133
129
  else
@@ -0,0 +1,126 @@
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
+ # A self-contained component displaying export formats for a color.
7
+ #
8
+ # Users need to copy color values in different formats (HEX, RGB, HSL).
9
+ # This component renders the export section and detects clicks on itself.
10
+ #
11
+ # === Component Contract
12
+ #
13
+ # - `render(tui, frame, area, palette:)`: Draws the export formats; stores `area` for hit testing
14
+ # - `handle_event(event) -> Symbol | nil`: Returns `:copy_requested` when clicked
15
+ #
16
+ # === Example
17
+ #
18
+ # export_pane = ExportPane.new
19
+ # export_pane.render(tui, frame, area, palette: palette)
20
+ #
21
+ # result = export_pane.handle_event(event)
22
+ # if result == :copy_requested && palette.main
23
+ # dialog.open(palette.main.hex)
24
+ # end
25
+ class ExportPane
26
+ def initialize
27
+ @area = nil
28
+ end
29
+
30
+ # The cached render area, for hit testing.
31
+ attr_reader :area
32
+
33
+ # Renders the export formats section into the given area.
34
+ #
35
+ # Shows HEX, RGB, and HSL values for the current color. If no color is set,
36
+ # displays a placeholder message.
37
+ #
38
+ # [tui] Session or TUI factory object
39
+ # [frame] Frame object from RatatuiRuby.draw block
40
+ # [area] Rect area to draw into
41
+ # [palette] Palette object containing the color to display
42
+ #
43
+ # === Example
44
+ #
45
+ # export_pane.render(tui, frame, export_area, palette: palette)
46
+ def render(tui, frame, area, palette:)
47
+ @area = area
48
+ widget = build_widget(tui, palette)
49
+ frame.render_widget(widget, area)
50
+ end
51
+
52
+ # Processes a mouse event and returns a signal if clicked.
53
+ #
54
+ # Returns:
55
+ # - `:copy_requested` when the pane is clicked (caller should open copy dialog)
56
+ # - `nil` when the event was ignored or outside the area
57
+ #
58
+ # [event] Event from RatatuiRuby.poll_event
59
+ #
60
+ # === Example
61
+ #
62
+ # result = export_pane.handle_event(event)
63
+ # if result == :copy_requested
64
+ # dialog.open(palette.main.hex)
65
+ # end
66
+ def handle_event(event)
67
+ case event
68
+ in { type: :mouse, kind: "down", button: "left", x:, y: }
69
+ if @area&.contains?(x, y)
70
+ :copy_requested
71
+ end
72
+ else
73
+ nil
74
+ end
75
+ end
76
+
77
+ private def build_widget(tui, palette)
78
+ if palette.main.nil?
79
+ tui.block(
80
+ title: "Export Formats",
81
+ borders: [:all],
82
+ children: [
83
+ tui.paragraph(
84
+ text: tui.text_line(spans: [
85
+ tui.text_span(content: "Enter a color to see formats"),
86
+ ])
87
+ ),
88
+ ]
89
+ )
90
+ else
91
+ build_color_widget(tui, palette.main)
92
+ end
93
+ end
94
+
95
+ private def build_color_widget(tui, color)
96
+ hex = color.hex
97
+ rgb = color.rgb
98
+ hsl = color.hsl_string
99
+ text_color = color.contrasting_text_color
100
+ bg_style = tui.style(bg: hex, fg: text_color)
101
+
102
+ tui.block(
103
+ title: "Export Formats",
104
+ borders: [:all],
105
+ style: bg_style,
106
+ children: [
107
+ tui.paragraph(
108
+ text: [
109
+ tui.text_line(spans: [
110
+ tui.text_span(content: "HEX: ", style: bg_style),
111
+ tui.text_span(content: hex, style: tui.style(bg: hex, fg: text_color, modifiers: [:underlined])),
112
+ ]),
113
+ tui.text_line(spans: [
114
+ tui.text_span(content: "RGB: ", style: bg_style),
115
+ tui.text_span(content: rgb, style: tui.style(bg: hex, fg: text_color, modifiers: [:underlined])),
116
+ ]),
117
+ tui.text_line(spans: [
118
+ tui.text_span(content: "HSL: ", style: bg_style),
119
+ tui.text_span(content: hsl, style: tui.style(bg: hex, fg: text_color, modifiers: [:underlined])),
120
+ ]),
121
+ ]
122
+ ),
123
+ ]
124
+ )
125
+ end
126
+ end