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
@@ -5,27 +5,30 @@
5
5
 
6
6
  require_relative "color"
7
7
 
8
- # Manages text input and color parsing with error feedback.
8
+ # A self-contained text input component for color entry.
9
9
  #
10
10
  # Users type color values. They make mistakes—typos, invalid formats. The app
11
- # needs to validate their input and show helpful error messages. Manually
12
- # tracking input state, validation, and error messages across renders is
13
- # cumbersome and error-prone.
11
+ # needs to validate their input and show helpful error messages.
14
12
  #
15
- # This object holds the current input string. It validates by parsing. It stores
16
- # errors and clears them when appropriate. It provides methods to manipulate
17
- # the input (append, delete).
13
+ # This component encapsulates rendering, state, and event handling. It draws
14
+ # itself into the provided area, caches that area for hit testing, and handles
15
+ # keyboard events internally.
18
16
  #
19
- # Use it to build text input forms where validation feedback matters.
17
+ # === Component Contract
18
+ #
19
+ # - `render(tui, frame, area)`: Draws the input field; stores `area` for hit testing
20
+ # - `handle_event(event) -> Symbol | nil`: Returns `:consumed`, `:submitted`, or `nil`
20
21
  #
21
22
  # === Example
22
23
  #
23
24
  # input = Input.new
24
- # input.append_char("#")
25
- # input.append_char("f")
26
- # input.append_char("f")
27
- # color = input.parse # => Color or nil
28
- # puts input.error # => error message if parse failed
25
+ # input.render(tui, frame, area)
26
+ #
27
+ # result = input.handle_event(event)
28
+ # case result
29
+ # when :submitted
30
+ # palette.update_color(input.parsed_color)
31
+ # end
29
32
  class Input
30
33
  PRINTABLE_PATTERN = /[\w#,().\s%]/
31
34
 
@@ -35,93 +38,112 @@ class Input
35
38
  def initialize(initial_value = "#F96302")
36
39
  @value = initial_value
37
40
  @error = ""
41
+ @parsed_color = nil
42
+ @area = nil
38
43
  end
39
44
 
40
45
  # Current input string.
41
- #
42
- # === Example
43
- #
44
- # input = Input.new
45
- # input.value # => "#F96302"
46
- def value
47
- @value
48
- end
46
+ attr_reader :value
49
47
 
50
48
  # Error message from the last failed parse, or empty string.
51
- #
52
- # === Example
53
- #
54
- # input.parse # => nil (invalid)
55
- # input.error # => "Invalid color format. Try: #ff0000, rgb(255,0,0), red"
56
- def error
57
- @error
58
- end
49
+ attr_reader :error
50
+
51
+ # The last successfully parsed Color, or nil.
52
+ attr_reader :parsed_color
53
+
54
+ # The cached render area, for hit testing.
55
+ attr_reader :area
59
56
 
60
57
  # Clears the current error message.
61
58
  def clear_error
62
59
  @error = ""
63
60
  end
64
61
 
65
- # Appends a character to the input if it matches the printable pattern.
62
+ # Renders the input widget into the given area.
63
+ #
64
+ # Caches `area` for hit testing. Shows the current input value and positions
65
+ # the terminal's blinking cursor at the end of the text using
66
+ # `frame.set_cursor_position`. Displays the error message in red if set.
67
+ #
68
+ # [tui] Session or TUI factory object
69
+ # [frame] Frame object from RatatuiRuby.draw block
70
+ # [area] Rect area to draw into
71
+ #
72
+ # === Example
73
+ #
74
+ # input.render(tui, frame, input_area)
75
+ def render(tui, frame, area)
76
+ @area = area
77
+ widget = build_widget(tui)
78
+ frame.render_widget(widget, area)
79
+
80
+ # Position real blinking cursor at end of input text
81
+ cursor_x, cursor_y = cursor_position_in(area)
82
+ frame.set_cursor_position(cursor_x, cursor_y)
83
+ end
84
+
85
+ # Processes a keyboard event and updates internal state.
86
+ #
87
+ # Returns:
88
+ # - `:submitted` when Enter is pressed (caller should read `parsed_color`)
89
+ # - `:consumed` when the event was handled (typing, backspace)
90
+ # - `nil` when the event was ignored
66
91
  #
67
- # Silently ignores non-printable characters. Valid characters include
68
- # letters, digits, hash, comma, parentheses, dot, space, and percent.
92
+ # [event] Event from RatatuiRuby.poll_event
69
93
  #
70
- # [char] String single character
71
- def append_char(char)
94
+ # === Example
95
+ #
96
+ # result = input.handle_event(event)
97
+ # if result == :submitted
98
+ # palette.update_color(input.parsed_color)
99
+ # end
100
+ def handle_event(event)
101
+ case event
102
+ in { type: :key, code: "enter" }
103
+ parse
104
+ :submitted
105
+ in { type: :key, code: "backspace" }
106
+ delete_char
107
+ :consumed
108
+ in { type: :paste, content: }
109
+ set(content)
110
+ parse
111
+ :submitted
112
+ in { type: :key, code: code }
113
+ append_char(code)
114
+ :consumed
115
+ else
116
+ nil
117
+ end
118
+ end
119
+
120
+ private def append_char(char)
72
121
  @value += char if char.length == 1 && char.match?(PRINTABLE_PATTERN)
73
122
  end
74
123
 
75
- # Removes the last character from the input.
76
- def delete_char
124
+ private def delete_char
77
125
  @value = @value[0...-1]
78
126
  end
79
127
 
80
- # Replaces the entire input string.
81
- #
82
- # [text] String new input value
83
- def set(text)
128
+ private def set(text)
84
129
  @value = text
85
130
  end
86
131
 
87
- # Parses the current input as a Color.
88
- #
89
- # Returns a Color if valid; nil otherwise. Sets the error message on failure.
90
- # Clears the error message on success.
91
- #
92
- # === Example
93
- #
94
- # input = Input.new("#FF0000")
95
- # color = input.parse # => Color
96
- # input.error # => ""
97
- def parse
132
+ private def parse
98
133
  color = Color.parse(@value)
99
134
  if color
100
135
  clear_error
101
- color
136
+ @parsed_color = color
102
137
  else
103
138
  @error = "Invalid color format. Try: #ff0000, rgb(255,0,0), red"
104
- nil
139
+ @parsed_color = nil
105
140
  end
106
141
  end
107
142
 
108
- # Renders the input widget for display in a TUI frame.
109
- #
110
- # Shows the current input value with a cursor. Displays the error message
111
- # in red if one is set.
112
- #
113
- # [tui] Session or TUI factory object
114
- #
115
- # === Example
116
- #
117
- # input = Input.new
118
- # widget = input.render(tui)
119
- # frame.render_widget(widget, area)
120
- def render(tui)
143
+ private def build_widget(tui)
121
144
  input_lines = [
122
145
  tui.text_line(spans: [
123
146
  tui.text_span(content: @value),
124
- tui.text_span(content: "_", style: tui.style(modifiers: [:reversed])),
125
147
  ]),
126
148
  ]
127
149
 
@@ -139,4 +161,14 @@ class Input
139
161
  ]
140
162
  )
141
163
  end
164
+
165
+ # Calculates cursor position within the input area.
166
+ #
167
+ # Accounts for block border (1 cell) and current text length.
168
+ private def cursor_position_in(area)
169
+ # Border takes 1 cell on left, cursor goes after last character
170
+ x = area.x + 1 + @value.length
171
+ y = area.y + 1 # First line inside border
172
+ [x, y]
173
+ end
142
174
  end
@@ -0,0 +1,178 @@
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
+ require_relative "input"
7
+ require_relative "palette"
8
+ require_relative "export_pane"
9
+ require_relative "controls"
10
+ require_relative "clipboard"
11
+ require_relative "copy_dialog"
12
+
13
+ # The root container that owns all child components and orchestrates the UI.
14
+ #
15
+ # Building a complete color picker UI involves layout calculation, widget
16
+ # composition, event routing, and cross-component communication. The Container
17
+ # pattern centralizes this orchestration while keeping components decoupled.
18
+ #
19
+ # This container:
20
+ # - **Layout Phase**: Calculates Rects using tui.layout_split
21
+ # - **Delegation Phase**: Calls child.render(tui, frame, area) for each component
22
+ # - **Event Routing (Chain of Responsibility)**: Delegates events front-to-back
23
+ # - **Mediator Pattern**: Manages cross-component communication via symbolic signals
24
+ #
25
+ # === Component Contract
26
+ #
27
+ # - `render(tui, frame, area)`: Lays out and renders all children
28
+ # - `handle_event(event) -> Symbol | nil`: Routes events to children
29
+ # - `tick`: Delegates lifecycle updates (clipboard timer)
30
+ #
31
+ # === Example
32
+ #
33
+ # container = MainContainer.new(tui)
34
+ # container.render(tui, frame, frame.area)
35
+ # result = container.handle_event(event)
36
+ # container.tick
37
+ class MainContainer
38
+ def initialize(tui)
39
+ @tui = tui
40
+ @input = Input.new
41
+ @palette = Palette.new(@input.parsed_color)
42
+ @export_pane = ExportPane.new
43
+ @controls = Controls.new
44
+ @clipboard = Clipboard.new
45
+ @dialog = CopyDialog.new(@clipboard)
46
+
47
+ # Parse initial color
48
+ initial_result = simulate_initial_parse
49
+ @palette.update_color(initial_result) if initial_result
50
+ end
51
+
52
+ # Renders all child components into the given area.
53
+ #
54
+ # Calculates layout once per frame. Delegates rendering to each component.
55
+ # Renders the dialog overlay last for z-ordering.
56
+ #
57
+ # [tui] Session or TUI factory object
58
+ # [frame] Frame object from RatatuiRuby.draw block
59
+ # [area] Rect area to draw into
60
+ #
61
+ # === Example
62
+ #
63
+ # tui.draw { |frame| container.render(tui, frame, frame.area) }
64
+ def render(tui, frame, area)
65
+ # Layout Phase: calculate all areas
66
+ input_area, rest = tui.layout_split(
67
+ area,
68
+ direction: :vertical,
69
+ constraints: [
70
+ tui.constraint_length(3),
71
+ tui.constraint_fill(1),
72
+ ]
73
+ )
74
+
75
+ color_area, control_area = tui.layout_split(
76
+ rest,
77
+ direction: :vertical,
78
+ constraints: [
79
+ tui.constraint_length(14),
80
+ tui.constraint_fill(1),
81
+ ]
82
+ )
83
+
84
+ harmony_area, export_area = tui.layout_split(
85
+ color_area,
86
+ direction: :vertical,
87
+ constraints: [
88
+ tui.constraint_length(7),
89
+ tui.constraint_fill(1),
90
+ ]
91
+ )
92
+
93
+ # Delegation Phase: render each component
94
+ @input.render(tui, frame, input_area)
95
+ @palette.render(tui, frame, harmony_area)
96
+ @export_pane.render(tui, frame, export_area, palette: @palette)
97
+ @controls.render(tui, frame, control_area, clipboard: @clipboard)
98
+
99
+ # Overlay Logic: dialog rendered last for z-ordering
100
+ if @dialog.active?
101
+ dialog_area = calculate_center_area(area, 40, 8)
102
+ frame.render_widget(tui.clear, area)
103
+ @dialog.render(tui, frame, dialog_area)
104
+ end
105
+ end
106
+
107
+ # Routes events to child components in visual order (front-to-back).
108
+ #
109
+ # Implements Chain of Responsibility:
110
+ # 1. If dialog is active, offer it the event first
111
+ # 2. Then Input, ExportPane (which may trigger dialog)
112
+ # 3. Mediator pattern: interprets symbolic signals for cross-component effects
113
+ #
114
+ # Returns:
115
+ # - `:consumed` when any component handled the event
116
+ # - `nil` when no component handled the event
117
+ #
118
+ # [event] Event from RatatuiRuby.poll_event
119
+ #
120
+ # === Example
121
+ #
122
+ # result = container.handle_event(event)
123
+ def handle_event(event)
124
+ # Clear input error when not in dialog mode
125
+ @input.clear_error unless @dialog.active?
126
+
127
+ # Front-to-back: dialog has priority when active
128
+ if @dialog.active?
129
+ result = @dialog.handle_event(event)
130
+ return :consumed if result == :consumed
131
+ end
132
+
133
+ # Input component
134
+ result = @input.handle_event(event)
135
+ case result
136
+ when :submitted
137
+ # Mediator: sync Input -> Palette
138
+ @palette.update_color(@input.parsed_color)
139
+ return :consumed
140
+ when :consumed
141
+ return :consumed
142
+ end
143
+
144
+ # ExportPane: may request copy dialog
145
+ result = @export_pane.handle_event(event)
146
+ if result == :copy_requested && @palette.main
147
+ @dialog.open(@palette.main.hex)
148
+ return :consumed
149
+ end
150
+
151
+ # Palette and Controls are display-only
152
+ nil
153
+ end
154
+
155
+ # Delegates lifecycle tick to time-sensitive components.
156
+ #
157
+ # Currently handles clipboard feedback timer.
158
+ #
159
+ # === Example
160
+ #
161
+ # container.tick
162
+ def tick
163
+ @controls.tick(@clipboard)
164
+ end
165
+
166
+ private def calculate_center_area(parent_area, width, height)
167
+ x = (parent_area.width - width) / 2
168
+ y = (parent_area.height - height) / 2
169
+ @tui.rect(x:, y:, width:, height:)
170
+ end
171
+
172
+ # Simulates the initial parse that happens when the app starts.
173
+ # Input is initialized with a default color, so we need to parse it.
174
+ private def simulate_initial_parse
175
+ require_relative "color"
176
+ Color.parse(@input.value)
177
+ end
178
+ end
@@ -5,66 +5,95 @@
5
5
 
6
6
  require_relative "color"
7
7
 
8
- # Holds a primary color and its harmonies.
8
+ # A self-contained component displaying a color palette with harmonies.
9
9
  #
10
- # Color pickers need to show related colors: shades, tints, complements. Building
11
- # these relationships repeatedly is redundant. Passing them individually through
12
- # rendering pipelines is awkward.
10
+ # Color pickers need to show related colors: shades, tints, complements. This
11
+ # component owns a primary color and renders its harmonies.
13
12
  #
14
- # This object owns a primary color and generates its harmonies on demand. It
15
- # provides accessor methods and rendering helpers.
13
+ # === Component Contract
16
14
  #
17
- # Use it to organize color data for palette displays.
15
+ # - `render(tui, frame, area)`: Draws the harmony blocks; stores `area`
16
+ # - `handle_event(event) -> nil`: Display-only, always returns nil
17
+ # - `update_color(color)`: Updates the primary color (called by MainContainer)
18
18
  #
19
19
  # === Example
20
20
  #
21
- # color = Color.parse("#FF0000")
22
- # palette = Palette.new(color)
23
- # palette.main # => Color
24
- # palette.all # => [Harmony, Harmony, ...]
25
- # blocks = palette.as_blocks(tui) # => [Block, Block, ...]
21
+ # palette = Palette.new
22
+ # palette.update_color(Color.parse("#FF0000"))
23
+ # palette.render(tui, frame, palette_area)
26
24
  class Palette
27
- def initialize(primary_color)
25
+ def initialize(primary_color = nil)
28
26
  @primary = primary_color
27
+ @area = nil
29
28
  end
30
29
 
30
+ # The cached render area.
31
+ attr_reader :area
32
+
31
33
  # The primary (main) color, or nil if no color is set.
32
34
  #
33
35
  # === Example
34
36
  #
35
- # palette = Palette.new(color)
36
37
  # palette.main.hex # => "#FF0000"
37
38
  def main
38
39
  @primary
39
40
  end
40
41
 
41
- # All harmonies: main, shade, tint, complement, split 1, split 2, split-complement.
42
+ # Updates the primary color.
42
43
  #
43
- # Returns an empty array if no primary color is set.
44
+ # Called by the MainContainer when Input submits a new color.
44
45
  #
45
- # === Example
46
+ # [color] Color object or nil
47
+ def update_color(color)
48
+ @primary = color
49
+ end
50
+
51
+ # All harmonies: main, shade, tint, complement, split 1, split 2, split-complement.
46
52
  #
47
- # palette = Palette.new(color)
48
- # palette.all.size # => 7
53
+ # Returns an empty array if no primary color is set.
49
54
  def all
50
55
  return [] if @primary.nil?
51
56
 
52
57
  @primary.harmonies
53
58
  end
54
59
 
55
- # Renders all harmonies as TUI Block widgets.
60
+ # Renders the palette into the given area.
56
61
  #
57
- # Each harmony becomes a titled block showing its color swatch. Returns an empty
58
- # array if no primary color is set.
62
+ # Shows all harmony blocks in a horizontal layout. If no color is set,
63
+ # displays a placeholder message.
59
64
  #
60
65
  # [tui] Session or TUI factory object
66
+ # [frame] Frame object from RatatuiRuby.draw block
67
+ # [area] Rect area to draw into
61
68
  #
62
69
  # === Example
63
70
  #
64
- # palette = Palette.new(color)
65
- # blocks = palette.as_blocks(tui)
66
- # # blocks[0] => Block titled "Main" with color swatch
67
- def as_blocks(tui)
71
+ # palette.render(tui, frame, palette_area)
72
+ def render(tui, frame, area)
73
+ @area = area
74
+ widget = build_widget(tui)
75
+ frame.render_widget(widget, area)
76
+ end
77
+
78
+ # Display-only component; always returns nil.
79
+ def handle_event(_event)
80
+ nil
81
+ end
82
+
83
+ private def build_widget(tui)
84
+ if @primary.nil?
85
+ tui.paragraph(text: "No color selected")
86
+ else
87
+ blocks = as_blocks(tui)
88
+ tui.layout(
89
+ direction: :horizontal,
90
+ constraints: Array.new(blocks.size) { tui.constraint_fill(1) },
91
+ children: blocks
92
+ )
93
+ end
94
+ end
95
+
96
+ private def as_blocks(tui)
68
97
  return [] if @primary.nil?
69
98
 
70
99
  all.map do |harmony|
@@ -0,0 +1,47 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # Login Form Example
7
+
8
+ Demonstrates how to create a modal overlay for user input.
9
+
10
+ Many applications need to block interaction with the main UI while collecting specific information, like a login prompt or confirmation dialog. Managing the z-index and input focus for these overlays can be tricky.
11
+
12
+ This example solves this by using the `Overlay` widget to stack a centered popup on top of a base layer, conditionally rendering the popup based on state.
13
+
14
+ ## Features Demonstrated
15
+
16
+ - **Overlays:** Stacking widgets on top of each other using `tui.overlay`.
17
+ - **Centering:** Positioning a widget in the center of the screen using `tui.center`.
18
+ - **State Management:** Switching between "Base" and "Popup" views.
19
+ - **Input Handling:** Capturing text input and handling specific keys (Enter, Esc) to trigger state changes.
20
+ - **Cursor Positioning:** Manually calculating cursor position within a `Paragraph`.
21
+
22
+ ## Hotkeys
23
+
24
+ ### Form Mode
25
+ - **Text Input**: Type to enter username (supports all characters including 'q').
26
+ - **Backspace**: Deletes the last character.
27
+ - **Enter**: Submits the form and opens the success popup.
28
+ - **Esc**: Quits the application.
29
+ - **Ctrl+C**: Quits the application.
30
+
31
+ ### Popup Mode
32
+ - **q**: Closes the popup and quits the application.
33
+ - **Ctrl+C**: Quits the application.
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ ruby examples/app_login_form/app.rb
39
+ ```
40
+
41
+ ## Learning Outcomes
42
+
43
+ Use this example if you need to...
44
+ - Create a modal dialog or popup.
45
+ - Center a widget on the screen (vertically and horizontally).
46
+ - Implement a simple text input field with cursor management.
47
+ - layer widgets using the `Overlay` widget.
@@ -82,11 +82,10 @@ class AppLoginForm
82
82
 
83
83
  private def handle_input
84
84
  case @tui.poll_event
85
- in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
86
- return :quit if @show_popup
87
- nil
88
85
  in { type: :key, code: "c", modifiers: ["ctrl"] }
89
86
  :quit
87
+ in { type: :key, code: "q" } if @show_popup
88
+ :quit
90
89
  in { type: :key, code: "enter" }
91
90
  @show_popup ||= true
92
91
  nil
@@ -0,0 +1,31 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # Stateful Interaction Example
7
+
8
+ This example demonstrates High-Fidelity Interaction using **Stateful Widget Rendering**.
9
+
10
+ It showcases a "Database Viewer" layout where:
11
+ 1. **Selection Persistence:** `ListState` and `TableState` objects persist across frames, maintaining selection without manual index tracking variables.
12
+ 2. **Offset Read-back:** The application reads `state.offset` *after* rendering to know exactly which items were visible on screen.
13
+ 3. **Mouse Interaction:** Using the read-back offset, we can calculate exactly which row was clicked, even when the specific item wasn't drawn at that absolute Y position due to scrolling.
14
+
15
+ ## Key Concept: The "Read-back" Loop
16
+
17
+ Standard immediate-mode interaction often requires you to re-calculate layout logic to determine what was clicked.
18
+
19
+ In `ratatui_ruby`'s Stateful Rendering:
20
+ 1. **Update**: You modify `state` (e.g., `state.select(1)`).
21
+ 2. **Render**: You pass `state` to `render_stateful_widget`. Ratatui's Rust backend calculates layout and **updates** `state.offset` in-place if scrolling happened.
22
+ 3. **Interact**: On the next event loop, you use `state.offset` to correctly map mouse coordinates to data indices.
23
+
24
+ ## Hotkeys
25
+
26
+ | Key | Action |
27
+ | --- | --- |
28
+ | `↑` / `↓` | Scroll the active pane |
29
+ | `Tab` / `←` / `→` | Switch active pane (List vs Table) |
30
+ | `Mouse Click` | Select the clicked row (Works with scrolling!) |
31
+ | `q` | Quit |