ratatui_ruby 0.4.0 → 0.5.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 (351) 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 +87 -171
  7. data/CHANGELOG.md +38 -1
  8. data/README.md +8 -3
  9. data/REUSE.toml +20 -0
  10. data/doc/application_architecture.md +105 -45
  11. data/doc/application_testing.md +5 -3
  12. data/doc/contributors/design/ruby_frontend.md +9 -5
  13. data/doc/contributors/developing_examples.md +76 -18
  14. data/doc/contributors/documentation_style.md +7 -0
  15. data/doc/contributors/index.md +2 -0
  16. data/doc/event_handling.md +10 -4
  17. data/doc/images/app_all_events.png +0 -0
  18. data/doc/images/app_color_picker.png +0 -0
  19. data/doc/images/verify_readme_usage.png +0 -0
  20. data/doc/images/widget_barchart_demo.png +0 -0
  21. data/doc/images/widget_block_padding.png +0 -0
  22. data/doc/images/widget_block_titles.png +0 -0
  23. data/doc/images/widget_box_demo.png +0 -0
  24. data/doc/images/widget_calendar_demo.png +0 -0
  25. data/doc/images/widget_cell_demo.png +0 -0
  26. data/doc/images/widget_chart_demo.png +0 -0
  27. data/doc/images/widget_gauge_demo.png +0 -0
  28. data/doc/images/widget_layout_split.png +0 -0
  29. data/doc/images/widget_line_gauge_demo.png +0 -0
  30. data/doc/images/widget_list_demo.png +0 -0
  31. data/doc/images/widget_ratatui_logo_demo.png +0 -0
  32. data/doc/images/widget_ratatui_mascot_demo.png +0 -0
  33. data/doc/images/widget_render.png +0 -0
  34. data/doc/images/widget_scrollbar_demo.png +0 -0
  35. data/doc/images/widget_sparkline_demo.png +0 -0
  36. data/doc/images/widget_style_colors.png +0 -0
  37. data/doc/images/widget_table_flex.png +0 -0
  38. data/doc/images/widget_tabs_demo.png +0 -0
  39. data/doc/interactive_design.md +25 -30
  40. data/doc/quickstart.md +147 -120
  41. data/examples/app_all_events/README.md +81 -0
  42. data/examples/app_all_events/app.rb +93 -0
  43. data/examples/app_all_events/model/event_color_cycle.rb +41 -0
  44. data/examples/app_all_events/model/event_entry.rb +75 -0
  45. data/examples/app_all_events/model/events.rb +180 -0
  46. data/examples/app_all_events/model/highlight.rb +57 -0
  47. data/examples/app_all_events/model/timestamp.rb +54 -0
  48. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +24 -0
  49. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +24 -0
  50. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +24 -0
  51. data/examples/app_all_events/test/snapshots/after_key_a.txt +24 -0
  52. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +24 -0
  53. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +24 -0
  54. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +24 -0
  55. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +24 -0
  56. data/examples/app_all_events/test/snapshots/after_paste.txt +24 -0
  57. data/examples/app_all_events/test/snapshots/after_resize.txt +24 -0
  58. data/examples/app_all_events/test/snapshots/after_right_click.txt +24 -0
  59. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +24 -0
  60. data/examples/app_all_events/test/snapshots/initial_state.txt +24 -0
  61. data/examples/app_all_events/view/app_view.rb +78 -0
  62. data/examples/app_all_events/view/controls_view.rb +50 -0
  63. data/examples/app_all_events/view/counts_view.rb +55 -0
  64. data/examples/app_all_events/view/live_view.rb +69 -0
  65. data/examples/app_all_events/view/log_view.rb +60 -0
  66. data/examples/app_all_events/view.rb +7 -0
  67. data/examples/app_all_events/view_state.rb +42 -0
  68. data/examples/app_color_picker/README.md +94 -0
  69. data/examples/app_color_picker/app.rb +112 -0
  70. data/examples/app_color_picker/clipboard.rb +84 -0
  71. data/examples/app_color_picker/color.rb +191 -0
  72. data/examples/app_color_picker/copy_dialog.rb +170 -0
  73. data/examples/app_color_picker/harmony.rb +56 -0
  74. data/examples/app_color_picker/input.rb +142 -0
  75. data/examples/app_color_picker/palette.rb +80 -0
  76. data/examples/app_color_picker/scene.rb +201 -0
  77. data/examples/{login_form → app_login_form}/app.rb +39 -42
  78. data/examples/{map_demo → app_map_demo}/app.rb +24 -21
  79. data/examples/{table_select → app_table_select}/app.rb +68 -65
  80. data/examples/{quickstart_dsl → verify_quickstart_dsl}/app.rb +15 -6
  81. data/examples/verify_quickstart_layout/app.rb +69 -0
  82. data/examples/{quickstart_lifecycle → verify_quickstart_lifecycle}/app.rb +19 -10
  83. data/examples/verify_readme_usage/app.rb +34 -0
  84. data/examples/widget_barchart_demo/app.rb +238 -0
  85. data/examples/{block_padding → widget_block_padding}/app.rb +17 -13
  86. data/examples/{block_titles → widget_block_titles}/app.rb +25 -17
  87. data/examples/{box_demo → widget_box_demo}/app.rb +99 -65
  88. data/examples/widget_calendar_demo/app.rb +109 -0
  89. data/examples/widget_cell_demo/app.rb +104 -0
  90. data/examples/widget_chart_demo/app.rb +213 -0
  91. data/examples/widget_gauge_demo/app.rb +212 -0
  92. data/examples/widget_layout_split/app.rb +246 -0
  93. data/examples/widget_line_gauge_demo/app.rb +217 -0
  94. data/examples/widget_list_demo/app.rb +382 -0
  95. data/examples/widget_list_styles/app.rb +141 -0
  96. data/examples/widget_popup_demo/app.rb +104 -0
  97. data/examples/widget_ratatui_logo_demo/app.rb +103 -0
  98. data/examples/widget_ratatui_mascot_demo/app.rb +93 -0
  99. data/examples/widget_rect/app.rb +205 -0
  100. data/examples/widget_render/app.rb +184 -0
  101. data/examples/widget_rich_text/app.rb +137 -0
  102. data/examples/widget_scroll_text/app.rb +108 -0
  103. data/examples/widget_scrollbar_demo/app.rb +153 -0
  104. data/examples/widget_sparkline_demo/app.rb +274 -0
  105. data/examples/widget_style_colors/app.rb +19 -21
  106. data/examples/widget_table_flex/app.rb +95 -0
  107. data/examples/widget_tabs_demo/app.rb +167 -0
  108. data/ext/ratatui_ruby/Cargo.lock +1 -1
  109. data/ext/ratatui_ruby/Cargo.toml +1 -1
  110. data/ext/ratatui_ruby/src/events.rs +121 -36
  111. data/ext/ratatui_ruby/src/frame.rs +115 -0
  112. data/ext/ratatui_ruby/src/lib.rs +79 -26
  113. data/ext/ratatui_ruby/src/rendering.rs +8 -4
  114. data/ext/ratatui_ruby/src/style.rs +138 -57
  115. data/ext/ratatui_ruby/src/terminal.rs +5 -9
  116. data/ext/ratatui_ruby/src/text.rs +13 -6
  117. data/ext/ratatui_ruby/src/widgets/barchart.rs +56 -54
  118. data/ext/ratatui_ruby/src/widgets/block.rs +7 -6
  119. data/ext/ratatui_ruby/src/widgets/canvas.rs +21 -3
  120. data/ext/ratatui_ruby/src/widgets/chart.rs +20 -10
  121. data/ext/ratatui_ruby/src/widgets/layout.rs +9 -4
  122. data/ext/ratatui_ruby/src/widgets/list.rs +32 -9
  123. data/ext/ratatui_ruby/src/widgets/overlay.rs +2 -1
  124. data/ext/ratatui_ruby/src/widgets/paragraph.rs +1 -1
  125. data/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +19 -8
  126. data/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +17 -10
  127. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +4 -2
  128. data/ext/ratatui_ruby/src/widgets/sparkline.rs +14 -11
  129. data/ext/ratatui_ruby/src/widgets/table.rs +8 -4
  130. data/ext/ratatui_ruby/src/widgets/tabs.rs +11 -11
  131. data/lib/ratatui_ruby/cell.rb +3 -3
  132. data/lib/ratatui_ruby/event/key.rb +1 -1
  133. data/lib/ratatui_ruby/event/none.rb +43 -0
  134. data/lib/ratatui_ruby/event.rb +56 -4
  135. data/lib/ratatui_ruby/frame.rb +87 -0
  136. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +11 -11
  137. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +1 -5
  138. data/lib/ratatui_ruby/schema/bar_chart.rb +217 -217
  139. data/lib/ratatui_ruby/schema/block.rb +163 -168
  140. data/lib/ratatui_ruby/schema/calendar.rb +66 -67
  141. data/lib/ratatui_ruby/schema/canvas.rb +63 -63
  142. data/lib/ratatui_ruby/schema/center.rb +46 -46
  143. data/lib/ratatui_ruby/schema/chart.rb +135 -143
  144. data/lib/ratatui_ruby/schema/clear.rb +42 -42
  145. data/lib/ratatui_ruby/schema/constraint.rb +76 -76
  146. data/lib/ratatui_ruby/schema/cursor.rb +25 -25
  147. data/lib/ratatui_ruby/schema/gauge.rb +53 -53
  148. data/lib/ratatui_ruby/schema/layout.rb +87 -87
  149. data/lib/ratatui_ruby/schema/line_gauge.rb +62 -62
  150. data/lib/ratatui_ruby/schema/list.rb +86 -84
  151. data/lib/ratatui_ruby/schema/overlay.rb +31 -31
  152. data/lib/ratatui_ruby/schema/paragraph.rb +80 -80
  153. data/lib/ratatui_ruby/schema/ratatui_logo.rb +10 -6
  154. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +10 -5
  155. data/lib/ratatui_ruby/schema/rect.rb +60 -60
  156. data/lib/ratatui_ruby/schema/scrollbar.rb +119 -119
  157. data/lib/ratatui_ruby/schema/shape/label.rb +1 -1
  158. data/lib/ratatui_ruby/schema/sparkline.rb +111 -110
  159. data/lib/ratatui_ruby/schema/style.rb +46 -46
  160. data/lib/ratatui_ruby/schema/table.rb +112 -119
  161. data/lib/ratatui_ruby/schema/tabs.rb +66 -67
  162. data/lib/ratatui_ruby/session/autodoc.rb +417 -0
  163. data/lib/ratatui_ruby/session.rb +40 -23
  164. data/lib/ratatui_ruby/test_helper.rb +185 -19
  165. data/lib/ratatui_ruby/version.rb +1 -1
  166. data/lib/ratatui_ruby.rb +65 -39
  167. data/{examples/sparkline_demo → sig/examples/app_all_events}/app.rbs +3 -2
  168. data/sig/examples/app_all_events/model/event_entry.rbs +16 -0
  169. data/sig/examples/app_all_events/model/events.rbs +15 -0
  170. data/sig/examples/app_all_events/model/timestamp.rbs +11 -0
  171. data/sig/examples/app_all_events/view/app_view.rbs +8 -0
  172. data/sig/examples/app_all_events/view/controls_view.rbs +6 -0
  173. data/sig/examples/app_all_events/view/counts_view.rbs +6 -0
  174. data/sig/examples/app_all_events/view/live_view.rbs +6 -0
  175. data/sig/examples/app_all_events/view/log_view.rbs +6 -0
  176. data/sig/examples/app_all_events/view.rbs +8 -0
  177. data/sig/examples/app_all_events/view_state.rbs +15 -0
  178. data/{examples/list_demo → sig/examples/app_color_picker}/app.rbs +2 -2
  179. data/sig/examples/app_login_form/app.rbs +11 -0
  180. data/sig/examples/app_map_demo/app.rbs +11 -0
  181. data/sig/examples/app_table_select/app.rbs +11 -0
  182. data/sig/examples/verify_quickstart_dsl/app.rbs +11 -0
  183. data/sig/examples/verify_quickstart_lifecycle/app.rbs +11 -0
  184. data/sig/examples/verify_readme_usage/app.rbs +11 -0
  185. data/sig/examples/widget_block_padding/app.rbs +11 -0
  186. data/sig/examples/widget_block_titles/app.rbs +11 -0
  187. data/sig/examples/widget_box_demo/app.rbs +11 -0
  188. data/sig/examples/widget_calendar_demo/app.rbs +11 -0
  189. data/sig/examples/widget_cell_demo/app.rbs +11 -0
  190. data/sig/examples/widget_chart_demo/app.rbs +11 -0
  191. data/{examples/gauge_demo → sig/examples/widget_gauge_demo}/app.rbs +4 -0
  192. data/sig/examples/widget_layout_split/app.rbs +10 -0
  193. data/sig/examples/widget_line_gauge_demo/app.rbs +11 -0
  194. data/sig/examples/widget_list_demo/app.rbs +12 -0
  195. data/sig/examples/widget_list_styles/app.rbs +11 -0
  196. data/sig/examples/widget_popup_demo/app.rbs +11 -0
  197. data/sig/examples/widget_ratatui_logo_demo/app.rbs +11 -0
  198. data/sig/examples/widget_ratatui_mascot_demo/app.rbs +11 -0
  199. data/sig/examples/widget_rect/app.rbs +12 -0
  200. data/sig/examples/widget_render/app.rbs +10 -0
  201. data/sig/examples/widget_rich_text/app.rbs +11 -0
  202. data/sig/examples/widget_scroll_text/app.rbs +11 -0
  203. data/sig/examples/widget_scrollbar_demo/app.rbs +11 -0
  204. data/sig/examples/widget_sparkline_demo/app.rbs +10 -0
  205. data/{examples → sig/examples}/widget_style_colors/app.rbs +1 -1
  206. data/sig/examples/widget_table_flex/app.rbs +11 -0
  207. data/sig/ratatui_ruby/frame.rbs +9 -0
  208. data/sig/ratatui_ruby/ratatui_ruby.rbs +3 -2
  209. data/sig/ratatui_ruby/schema/draw.rbs +4 -0
  210. data/sig/ratatui_ruby/schema/layout.rbs +1 -1
  211. data/sig/ratatui_ruby/session.rbs +94 -0
  212. data/tasks/autodoc/inventory.rb +61 -0
  213. data/tasks/autodoc/member.rb +56 -0
  214. data/tasks/autodoc/name.rb +19 -0
  215. data/tasks/autodoc/notice.rb +26 -0
  216. data/tasks/autodoc/rbs.rb +38 -0
  217. data/tasks/autodoc/rdoc.rb +45 -0
  218. data/tasks/autodoc.rake +47 -0
  219. data/tasks/bump/history.rb +2 -2
  220. data/tasks/doc.rake +600 -6
  221. data/tasks/example_viewer.html.erb +172 -0
  222. data/tasks/lint.rake +8 -4
  223. data/tasks/resources/index.html.erb +6 -0
  224. data/tasks/sourcehut.rake +4 -4
  225. data/tasks/terminal_preview/app_screenshot.rb +1 -3
  226. data/tasks/terminal_preview/crash_report.rb +7 -9
  227. data/tasks/terminal_preview/launcher_script.rb +4 -6
  228. data/tasks/terminal_preview/preview_collection.rb +4 -6
  229. data/tasks/terminal_preview/safety_confirmation.rb +3 -5
  230. data/tasks/terminal_preview/saved_screenshot.rb +7 -9
  231. data/tasks/terminal_preview/terminal_window.rb +7 -9
  232. data/tasks/test.rake +1 -1
  233. data/tasks/website/index_page.rb +3 -3
  234. data/tasks/website/version.rb +10 -10
  235. data/tasks/website/version_menu.rb +10 -12
  236. data/tasks/website/versioned_documentation.rb +49 -17
  237. data/tasks/website/website.rb +6 -8
  238. data/tasks/website.rake +4 -4
  239. metadata +156 -125
  240. data/LICENSES/BSD-2-Clause.txt +0 -9
  241. data/doc/contributors/better_dx.md +0 -543
  242. data/doc/contributors/example_analysis.md +0 -82
  243. data/doc/images/all_events.png +0 -0
  244. data/doc/images/block_padding.png +0 -0
  245. data/doc/images/block_titles.png +0 -0
  246. data/doc/images/box_demo.png +0 -0
  247. data/doc/images/calendar_demo.png +0 -0
  248. data/doc/images/cell_demo.png +0 -0
  249. data/doc/images/chart_demo.png +0 -0
  250. data/doc/images/flex_layout.png +0 -0
  251. data/doc/images/gauge_demo.png +0 -0
  252. data/doc/images/line_gauge_demo.png +0 -0
  253. data/doc/images/list_demo.png +0 -0
  254. data/doc/images/readme_usage.png +0 -0
  255. data/doc/images/scrollbar_demo.png +0 -0
  256. data/doc/images/sparkline_demo.png +0 -0
  257. data/doc/images/table_flex.png +0 -0
  258. data/examples/all_events/app.rb +0 -169
  259. data/examples/all_events/app.rbs +0 -7
  260. data/examples/all_events/test_app.rb +0 -139
  261. data/examples/analytics/app.rb +0 -258
  262. data/examples/analytics/app.rbs +0 -7
  263. data/examples/analytics/test_app.rb +0 -132
  264. data/examples/block_padding/app.rbs +0 -7
  265. data/examples/block_padding/test_app.rb +0 -31
  266. data/examples/block_titles/app.rbs +0 -7
  267. data/examples/block_titles/test_app.rb +0 -34
  268. data/examples/box_demo/app.rbs +0 -7
  269. data/examples/box_demo/test_app.rb +0 -88
  270. data/examples/calendar_demo/app.rb +0 -101
  271. data/examples/calendar_demo/app.rbs +0 -7
  272. data/examples/calendar_demo/test_app.rb +0 -108
  273. data/examples/cell_demo/app.rb +0 -108
  274. data/examples/cell_demo/app.rbs +0 -7
  275. data/examples/cell_demo/test_app.rb +0 -36
  276. data/examples/chart_demo/app.rb +0 -203
  277. data/examples/chart_demo/app.rbs +0 -7
  278. data/examples/chart_demo/test_app.rb +0 -102
  279. data/examples/custom_widget/app.rb +0 -51
  280. data/examples/custom_widget/app.rbs +0 -7
  281. data/examples/custom_widget/test_app.rb +0 -30
  282. data/examples/flex_layout/app.rb +0 -156
  283. data/examples/flex_layout/app.rbs +0 -7
  284. data/examples/flex_layout/test_app.rb +0 -65
  285. data/examples/gauge_demo/app.rb +0 -182
  286. data/examples/gauge_demo/test_app.rb +0 -120
  287. data/examples/hit_test/app.rb +0 -175
  288. data/examples/hit_test/app.rbs +0 -7
  289. data/examples/hit_test/test_app.rb +0 -102
  290. data/examples/line_gauge_demo/app.rb +0 -190
  291. data/examples/line_gauge_demo/app.rbs +0 -7
  292. data/examples/line_gauge_demo/test_app.rb +0 -129
  293. data/examples/list_demo/app.rb +0 -253
  294. data/examples/list_demo/test_app.rb +0 -237
  295. data/examples/list_styles/app.rb +0 -140
  296. data/examples/list_styles/app.rbs +0 -7
  297. data/examples/list_styles/test_app.rb +0 -157
  298. data/examples/login_form/app.rbs +0 -7
  299. data/examples/login_form/test_app.rb +0 -51
  300. data/examples/map_demo/app.rbs +0 -7
  301. data/examples/map_demo/test_app.rb +0 -149
  302. data/examples/mouse_events/app.rb +0 -97
  303. data/examples/mouse_events/app.rbs +0 -7
  304. data/examples/mouse_events/test_app.rb +0 -53
  305. data/examples/popup_demo/app.rb +0 -103
  306. data/examples/popup_demo/app.rbs +0 -7
  307. data/examples/popup_demo/test_app.rb +0 -54
  308. data/examples/quickstart_dsl/app.rbs +0 -7
  309. data/examples/quickstart_dsl/test_app.rb +0 -29
  310. data/examples/quickstart_lifecycle/app.rbs +0 -7
  311. data/examples/quickstart_lifecycle/test_app.rb +0 -29
  312. data/examples/ratatui_logo_demo/app.rb +0 -79
  313. data/examples/ratatui_logo_demo/app.rbs +0 -7
  314. data/examples/ratatui_logo_demo/test_app.rb +0 -51
  315. data/examples/ratatui_mascot_demo/app.rb +0 -84
  316. data/examples/ratatui_mascot_demo/app.rbs +0 -7
  317. data/examples/ratatui_mascot_demo/test_app.rb +0 -47
  318. data/examples/readme_usage/app.rb +0 -29
  319. data/examples/readme_usage/app.rbs +0 -7
  320. data/examples/readme_usage/test_app.rb +0 -29
  321. data/examples/rich_text/app.rb +0 -141
  322. data/examples/rich_text/app.rbs +0 -7
  323. data/examples/rich_text/test_app.rb +0 -166
  324. data/examples/scroll_text/app.rb +0 -103
  325. data/examples/scroll_text/app.rbs +0 -7
  326. data/examples/scroll_text/test_app.rb +0 -110
  327. data/examples/scrollbar_demo/app.rb +0 -143
  328. data/examples/scrollbar_demo/app.rbs +0 -7
  329. data/examples/scrollbar_demo/test_app.rb +0 -77
  330. data/examples/sparkline_demo/app.rb +0 -240
  331. data/examples/sparkline_demo/test_app.rb +0 -107
  332. data/examples/table_flex/app.rb +0 -65
  333. data/examples/table_flex/app.rbs +0 -7
  334. data/examples/table_flex/test_app.rb +0 -36
  335. data/examples/table_select/app.rbs +0 -7
  336. data/examples/table_select/test_app.rb +0 -180
  337. data/examples/widget_style_colors/test_app.rb +0 -48
  338. /data/doc/images/{analytics.png → app_analytics.png} +0 -0
  339. /data/doc/images/{custom_widget.png → app_custom_widget.png} +0 -0
  340. /data/doc/images/{login_form.png → app_login_form.png} +0 -0
  341. /data/doc/images/{map_demo.png → app_map_demo.png} +0 -0
  342. /data/doc/images/{mouse_events.png → app_mouse_events.png} +0 -0
  343. /data/doc/images/{table_select.png → app_table_select.png} +0 -0
  344. /data/doc/images/{quickstart_dsl.png → verify_quickstart_dsl.png} +0 -0
  345. /data/doc/images/{ratatui_logo_demo.png → verify_quickstart_layout.png} +0 -0
  346. /data/doc/images/{quickstart_lifecycle.png → verify_quickstart_lifecycle.png} +0 -0
  347. /data/doc/images/{list_styles.png → widget_list_styles.png} +0 -0
  348. /data/doc/images/{popup_demo.png → widget_popup_demo.png} +0 -0
  349. /data/doc/images/{hit_test.png → widget_rect.png} +0 -0
  350. /data/doc/images/{rich_text.png → widget_rich_text.png} +0 -0
  351. /data/doc/images/{scroll_text.png → widget_scroll_text.png} +0 -0
@@ -0,0 +1,191 @@
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 "chroma"
7
+ require "wcag_color_contrast"
8
+ require_relative "harmony"
9
+
10
+ # Represents a single color with format conversion and harmony generation.
11
+ #
12
+ # Colors are central to visual design. Users need to work with colors in multiple
13
+ # formats: hex, RGB, HSL. They also need to generate color schemes: shades, tints,
14
+ # and complementary colors. Managing these conversions and relationships manually
15
+ # is tedious and error-prone.
16
+ #
17
+ # This object wraps a Chroma color. It exposes format conversions. It generates
18
+ # color harmonies. It calculates contrast ratios to choose readable text colors.
19
+ #
20
+ # Use it to parse user input, transform colors, and build color palettes.
21
+ #
22
+ # === Example
23
+ #
24
+ # color = Color.parse("#FF0000")
25
+ # puts color.hex # => "#FF0000"
26
+ # puts color.rgb # => "rgb(255, 0, 0)"
27
+ # puts color.hsl_string # => "hsl(0, 100%, 50%)"
28
+ #
29
+ # # Generate harmonies
30
+ # harmonies = color.harmonies # => [main, shade, tint, complement, ...]
31
+ #
32
+ # # Transform colors
33
+ # lighter = color.tint(5)
34
+ # darker = color.shade(3)
35
+ # rotated = color.spin(180)
36
+ class Color
37
+ def initialize(chroma_color)
38
+ @chroma = chroma_color
39
+ end
40
+
41
+ # Parses a color string and returns a Color, or nil if the string is invalid.
42
+ #
43
+ # Accepts hex, RGB, HSL, and named colors. Trims whitespace and handles
44
+ # empty strings gracefully.
45
+ #
46
+ # [input_str] String in any format Chroma supports (e.g., <tt>"#FF0000"</tt>, <tt>"red"</tt>, <tt>"rgb(255,0,0)"</tt>)
47
+ #
48
+ # === Example
49
+ #
50
+ # Color.parse("#FF0000") # => Color
51
+ # Color.parse("red") # => Color
52
+ # Color.parse("invalid") # => nil
53
+ # Color.parse("") # => nil
54
+ def self.parse(input_str)
55
+ input_str = input_str.to_s.strip
56
+ return nil if input_str.empty?
57
+
58
+ new(Chroma.paint(input_str.dup))
59
+ rescue
60
+ nil
61
+ end
62
+
63
+ # Hex color code (uppercase).
64
+ #
65
+ # === Example
66
+ #
67
+ # color = Color.parse("red")
68
+ # color.hex # => "#FF0000"
69
+ def hex
70
+ @chroma.to_hex.upcase
71
+ end
72
+
73
+ # RGB color code.
74
+ #
75
+ # === Example
76
+ #
77
+ # color = Color.parse("red")
78
+ # color.rgb # => "rgb(255, 0, 0)"
79
+ def rgb
80
+ @chroma.to_rgb
81
+ end
82
+
83
+ # HSL color string with percentage formatting.
84
+ #
85
+ # === Example
86
+ #
87
+ # color = Color.parse("red")
88
+ # color.hsl_string # => "hsl(0, 100%, 50%)"
89
+ def hsl_string
90
+ hsl_obj = @chroma.hsl
91
+ h = hsl_obj.h
92
+ s = hsl_obj.s
93
+ l = hsl_obj.l
94
+ format("hsl(%.0f, %.1f%%, %.1f%%)", h, s * 100, l * 100)
95
+ end
96
+
97
+ # Darkens the color. Returns a new Color.
98
+ #
99
+ # [amount] Integer amount to darken (default: 3)
100
+ #
101
+ # === Example
102
+ #
103
+ # color = Color.parse("red")
104
+ # color.shade(5).hex # => darker red
105
+ def shade(amount = 3)
106
+ Color.new(@chroma.darken(amount))
107
+ end
108
+
109
+ # Lightens the color. Returns a new Color.
110
+ #
111
+ # [amount] Integer amount to lighten (default: 3)
112
+ #
113
+ # === Example
114
+ #
115
+ # color = Color.parse("red")
116
+ # color.tint(5).hex # => lighter red
117
+ def tint(amount = 3)
118
+ Color.new(@chroma.lighten(amount))
119
+ end
120
+
121
+ # Rotates the hue. Returns a new Color.
122
+ #
123
+ # [degrees] Integer degrees to rotate (0-360)
124
+ #
125
+ # === Example
126
+ #
127
+ # color = Color.parse("red")
128
+ # color.spin(180).hex # => cyan
129
+ def spin(degrees)
130
+ Color.new(@chroma.spin(degrees))
131
+ end
132
+
133
+ # Determines optimal text color (:white or :black) for maximum contrast.
134
+ #
135
+ # Uses WCAG contrast ratio calculation. Returns <tt>:white</tt> if white has
136
+ # higher contrast; <tt>:black</tt> otherwise.
137
+ #
138
+ # === Example
139
+ #
140
+ # Color.parse("yellow").contrasting_text_color # => :black
141
+ # Color.parse("navy").contrasting_text_color # => :white
142
+ def contrasting_text_color
143
+ white_contrast = WCAGColorContrast.ratio(hex.sub(/^#/, ""), "ffffff")
144
+ black_contrast = WCAGColorContrast.ratio(hex.sub(/^#/, ""), "000000")
145
+ (white_contrast > black_contrast) ? :white : :black
146
+ end
147
+
148
+ # Background color for rendering this color as a swatch.
149
+ #
150
+ # Returns <tt>"#000000"</tt> if text should be white; <tt>"#ffffff"</tt> if black.
151
+ # Used to frame color swatches with contrasting borders.
152
+ #
153
+ # === Example
154
+ #
155
+ # Color.parse("yellow").frame_color # => "#000000"
156
+ def frame_color
157
+ (contrasting_text_color == :white) ? "#000000" : "#ffffff"
158
+ end
159
+
160
+ # Seven-color harmony: main, shade, tint, complement, split 1, split 2, split-complement.
161
+ #
162
+ # Generates a complete color scheme for UI design. Each harmony is a Harmony
163
+ # value object with label, hex, and styling information.
164
+ #
165
+ # === Example
166
+ #
167
+ # color = Color.parse("red")
168
+ # harmonies = color.harmonies
169
+ # harmonies.first.label # => "Main"
170
+ # harmonies.size # => 7
171
+ def harmonies
172
+ [
173
+ harmony_with_label("Main"),
174
+ shade.harmony_with_label("Shade"),
175
+ tint.harmony_with_label("Tint"),
176
+ spin(180).harmony_with_label("Comp"),
177
+ spin(150).harmony_with_label("Split 1"),
178
+ spin(210).harmony_with_label("Split 2"),
179
+ spin(30).harmony_with_label("S.Comp"),
180
+ ]
181
+ end
182
+
183
+ def harmony_with_label(label)
184
+ Harmony.new(
185
+ label:,
186
+ hex:,
187
+ text_color: contrasting_text_color,
188
+ frame_color:,
189
+ )
190
+ end
191
+ end
@@ -0,0 +1,170 @@
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 "clipboard"
7
+
8
+ # A confirmation dialog for copying text to the clipboard.
9
+ #
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.
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.
16
+ #
17
+ # Use it to build copy-on-click interactions with user confirmation.
18
+ #
19
+ # === Example
20
+ #
21
+ # clipboard = Clipboard.new
22
+ # dialog = CopyDialog.new(clipboard)
23
+ #
24
+ # # Open the dialog
25
+ # dialog.open("#FF0000")
26
+ # dialog.active? # => true
27
+ #
28
+ # # Handle input
29
+ # result = dialog.handle_input(event) # Routes to :copied or :cancelled
30
+ #
31
+ # # Render
32
+ # widget = dialog.render(tui, area)
33
+ class CopyDialog
34
+ def initialize(clipboard)
35
+ @clipboard = clipboard
36
+ @text = ""
37
+ @selected = :yes
38
+ @active = false
39
+ end
40
+
41
+ # Opens the dialog with text to copy.
42
+ #
43
+ # Initializes selection to <tt>:yes</tt> and sets active to true.
44
+ #
45
+ # [text] String text to show and copy
46
+ #
47
+ # === Example
48
+ #
49
+ # dialog.open("#FF0000")
50
+ # dialog.active? # => true
51
+ # dialog.text # => "#FF0000"
52
+ def open(text)
53
+ @text = text
54
+ @selected = :yes
55
+ @active = true
56
+ end
57
+
58
+ # Closes the dialog and deactivates it.
59
+ def close
60
+ @active = false
61
+ end
62
+
63
+ # True if the dialog is currently open and visible.
64
+ #
65
+ # === Example
66
+ #
67
+ # dialog.open("text")
68
+ # dialog.active? # => true
69
+ # dialog.close
70
+ # dialog.active? # => false
71
+ def active?
72
+ @active
73
+ end
74
+
75
+ # Processes a keyboard event and updates selection or closes the dialog.
76
+ #
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.
80
+ #
81
+ # [event] Hash event from RatatuiRuby.poll_event
82
+ #
83
+ # === Example
84
+ #
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)
90
+ return nil unless @active
91
+
92
+ case event
93
+ in { type: :key, code: "left" } | { type: :key, code: "h" }
94
+ @selected = :yes
95
+ nil
96
+ in { type: :key, code: "right" } | { type: :key, code: "l" }
97
+ @selected = :no
98
+ nil
99
+ in { type: :key, code: "enter" }
100
+ if @selected == :yes
101
+ @clipboard.copy(@text)
102
+ end
103
+ @active = false
104
+ nil
105
+ in { type: :key, code: "y" }
106
+ @clipboard.copy(@text)
107
+ @active = false
108
+ nil
109
+ in { type: :key, code: "n" }
110
+ @active = false
111
+ nil
112
+ else
113
+ nil
114
+ end
115
+ end
116
+
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)
131
+ yes_style = if @selected == :yes
132
+ tui.style(bg: :cyan, fg: :black, modifiers: [:bold])
133
+ else
134
+ tui.style(fg: :gray)
135
+ end
136
+
137
+ no_style = if @selected == :no
138
+ tui.style(bg: :cyan, fg: :black, modifiers: [:bold])
139
+ else
140
+ tui.style(fg: :gray)
141
+ end
142
+
143
+ tui.block(
144
+ title: "Copy to Clipboard",
145
+ borders: [:all],
146
+ border_type: :rounded,
147
+ style: tui.style(bg: :black, fg: :white),
148
+ children: [
149
+ tui.paragraph(
150
+ text: [
151
+ tui.text_line(spans: [
152
+ tui.text_span(content: "Copy #{@text}?", style: tui.style(fg: :white)),
153
+ ]),
154
+ tui.text_line(spans: []),
155
+ tui.text_line(spans: [
156
+ tui.text_span(content: "[", style: tui.style(fg: :white)),
157
+ tui.text_span(content: "Yes", style: yes_style),
158
+ tui.text_span(content: "] [", style: tui.style(fg: :white)),
159
+ tui.text_span(content: "No", style: no_style),
160
+ tui.text_span(content: "]", style: tui.style(fg: :white)),
161
+ ]),
162
+ tui.text_line(spans: [
163
+ tui.text_span(content: "Use ←/→ or h/l to select, Enter to confirm", style: tui.style(fg: :gray, modifiers: [:italic])),
164
+ ]),
165
+ ]
166
+ ),
167
+ ]
168
+ )
169
+ end
170
+ end
@@ -0,0 +1,56 @@
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 single color variant with label and styling information.
7
+ #
8
+ # Color palettes need to show individual colors with labels (Main, Shade, Tint,
9
+ # Complement). Bundling a color's hex code, text color, and frame color together
10
+ # is natural—they're always used as a set.
11
+ #
12
+ # This value object pairs a color with its metadata and rendering styles.
13
+ #
14
+ # Use it to represent colors in a palette or harmony.
15
+ #
16
+ # === Attributes
17
+ #
18
+ # [label] String label for this color variant
19
+ # [hex] String hex color code
20
+ # [text_color] Symbol (:white or :black) for readable text
21
+ # [frame_color] String background color for the swatch frame
22
+ #
23
+ # === Example
24
+ #
25
+ # harmony = Harmony.new(
26
+ # label: "Main",
27
+ # hex: "#FF0000",
28
+ # text_color: :white,
29
+ # frame_color: "#000000"
30
+ # )
31
+ Harmony = Data.define(:label, :hex, :text_color, :frame_color) do
32
+ # Renders a 4-line color swatch for display in a TUI Block.
33
+ #
34
+ # Produces a visual representation: a 7-character-wide box with the color
35
+ # centered and the hex code below.
36
+ #
37
+ # [tui] Session or TUI factory object
38
+ #
39
+ # === Example
40
+ #
41
+ # harmony = Harmony.new(...)
42
+ # lines = harmony.color_swatch_lines(tui)
43
+ # # => [TextLine, TextLine, TextLine, TextLine]
44
+ def color_swatch_lines(tui)
45
+ [
46
+ tui.text_line(spans: Array.new(7) { tui.text_span(content: " ", style: tui.style(bg: frame_color)) }),
47
+ tui.text_line(spans: [
48
+ *Array.new(3) { tui.text_span(content: " ", style: tui.style(bg: frame_color)) },
49
+ tui.text_span(content: " ", style: tui.style(bg: hex, fg: text_color)),
50
+ *Array.new(3) { tui.text_span(content: " ", style: tui.style(bg: frame_color)) },
51
+ ]),
52
+ tui.text_line(spans: Array.new(7) { tui.text_span(content: " ", style: tui.style(bg: frame_color)) }),
53
+ tui.text_line(spans: [tui.text_span(content: hex, style: tui.style(fg: :white))]),
54
+ ]
55
+ end
56
+ end
@@ -0,0 +1,142 @@
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 "color"
7
+
8
+ # Manages text input and color parsing with error feedback.
9
+ #
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.
14
+ #
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).
18
+ #
19
+ # Use it to build text input forms where validation feedback matters.
20
+ #
21
+ # === Example
22
+ #
23
+ # 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
29
+ class Input
30
+ PRINTABLE_PATTERN = /[\w#,().\s%]/
31
+
32
+ # Creates a new Input with an optional initial value.
33
+ #
34
+ # [initial_value] String initial color input (default: <tt>"#F96302"</tt>)
35
+ def initialize(initial_value = "#F96302")
36
+ @value = initial_value
37
+ @error = ""
38
+ end
39
+
40
+ # Current input string.
41
+ #
42
+ # === Example
43
+ #
44
+ # input = Input.new
45
+ # input.value # => "#F96302"
46
+ def value
47
+ @value
48
+ end
49
+
50
+ # 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
59
+
60
+ # Clears the current error message.
61
+ def clear_error
62
+ @error = ""
63
+ end
64
+
65
+ # Appends a character to the input if it matches the printable pattern.
66
+ #
67
+ # Silently ignores non-printable characters. Valid characters include
68
+ # letters, digits, hash, comma, parentheses, dot, space, and percent.
69
+ #
70
+ # [char] String single character
71
+ def append_char(char)
72
+ @value += char if char.length == 1 && char.match?(PRINTABLE_PATTERN)
73
+ end
74
+
75
+ # Removes the last character from the input.
76
+ def delete_char
77
+ @value = @value[0...-1]
78
+ end
79
+
80
+ # Replaces the entire input string.
81
+ #
82
+ # [text] String new input value
83
+ def set(text)
84
+ @value = text
85
+ end
86
+
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
98
+ color = Color.parse(@value)
99
+ if color
100
+ clear_error
101
+ color
102
+ else
103
+ @error = "Invalid color format. Try: #ff0000, rgb(255,0,0), red"
104
+ nil
105
+ end
106
+ end
107
+
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)
121
+ input_lines = [
122
+ tui.text_line(spans: [
123
+ tui.text_span(content: @value),
124
+ tui.text_span(content: "_", style: tui.style(modifiers: [:reversed])),
125
+ ]),
126
+ ]
127
+
128
+ unless @error.empty?
129
+ input_lines << tui.text_line(spans: [
130
+ tui.text_span(content: @error, style: tui.style(fg: :red)),
131
+ ])
132
+ end
133
+
134
+ tui.block(
135
+ title: "Color Input",
136
+ borders: [:all],
137
+ children: [
138
+ tui.paragraph(text: input_lines),
139
+ ]
140
+ )
141
+ end
142
+ end
@@ -0,0 +1,80 @@
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 "color"
7
+
8
+ # Holds a primary color and its harmonies.
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.
13
+ #
14
+ # This object owns a primary color and generates its harmonies on demand. It
15
+ # provides accessor methods and rendering helpers.
16
+ #
17
+ # Use it to organize color data for palette displays.
18
+ #
19
+ # === Example
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, ...]
26
+ class Palette
27
+ def initialize(primary_color)
28
+ @primary = primary_color
29
+ end
30
+
31
+ # The primary (main) color, or nil if no color is set.
32
+ #
33
+ # === Example
34
+ #
35
+ # palette = Palette.new(color)
36
+ # palette.main.hex # => "#FF0000"
37
+ def main
38
+ @primary
39
+ end
40
+
41
+ # All harmonies: main, shade, tint, complement, split 1, split 2, split-complement.
42
+ #
43
+ # Returns an empty array if no primary color is set.
44
+ #
45
+ # === Example
46
+ #
47
+ # palette = Palette.new(color)
48
+ # palette.all.size # => 7
49
+ def all
50
+ return [] if @primary.nil?
51
+
52
+ @primary.harmonies
53
+ end
54
+
55
+ # Renders all harmonies as TUI Block widgets.
56
+ #
57
+ # Each harmony becomes a titled block showing its color swatch. Returns an empty
58
+ # array if no primary color is set.
59
+ #
60
+ # [tui] Session or TUI factory object
61
+ #
62
+ # === Example
63
+ #
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)
68
+ return [] if @primary.nil?
69
+
70
+ all.map do |harmony|
71
+ tui.block(
72
+ title: harmony.label,
73
+ borders: [:all],
74
+ children: [
75
+ tui.paragraph(text: harmony.color_swatch_lines(tui)),
76
+ ]
77
+ )
78
+ end
79
+ end
80
+ end