ratatui_ruby 0.3.1 → 0.4.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +14 -12
  3. data/.builds/ruby-3.3.yml +14 -12
  4. data/.builds/ruby-3.4.yml +14 -12
  5. data/.builds/ruby-4.0.0.yml +14 -12
  6. data/AGENTS.md +54 -13
  7. data/CHANGELOG.md +186 -1
  8. data/README.md +17 -15
  9. data/doc/application_architecture.md +116 -0
  10. data/doc/application_testing.md +12 -7
  11. data/doc/contributors/better_dx.md +543 -0
  12. data/doc/contributors/design/ruby_frontend.md +1 -1
  13. data/doc/contributors/developing_examples.md +203 -0
  14. data/doc/contributors/documentation_style.md +97 -0
  15. data/doc/contributors/dwim_dx.md +366 -0
  16. data/doc/contributors/example_analysis.md +82 -0
  17. data/doc/custom.css +14 -0
  18. data/doc/event_handling.md +119 -0
  19. data/doc/images/all_events.png +0 -0
  20. data/doc/images/analytics.png +0 -0
  21. data/doc/images/block_padding.png +0 -0
  22. data/doc/images/block_titles.png +0 -0
  23. data/doc/images/box_demo.png +0 -0
  24. data/doc/images/calendar_demo.png +0 -0
  25. data/doc/images/cell_demo.png +0 -0
  26. data/doc/images/chart_demo.png +0 -0
  27. data/doc/images/custom_widget.png +0 -0
  28. data/doc/images/flex_layout.png +0 -0
  29. data/doc/images/gauge_demo.png +0 -0
  30. data/doc/images/hit_test.png +0 -0
  31. data/doc/images/line_gauge_demo.png +0 -0
  32. data/doc/images/list_demo.png +0 -0
  33. data/doc/images/list_styles.png +0 -0
  34. data/doc/images/login_form.png +0 -0
  35. data/doc/images/map_demo.png +0 -0
  36. data/doc/images/mouse_events.png +0 -0
  37. data/doc/images/popup_demo.png +0 -0
  38. data/doc/images/quickstart_dsl.png +0 -0
  39. data/doc/images/quickstart_lifecycle.png +0 -0
  40. data/doc/images/ratatui_logo_demo.png +0 -0
  41. data/doc/images/readme_usage.png +0 -0
  42. data/doc/images/rich_text.png +0 -0
  43. data/doc/images/scroll_text.png +0 -0
  44. data/doc/images/scrollbar_demo.png +0 -0
  45. data/doc/images/sparkline_demo.png +0 -0
  46. data/doc/images/table_flex.png +0 -0
  47. data/doc/images/table_select.png +0 -0
  48. data/doc/images/widget_style_colors.png +0 -0
  49. data/doc/index.md +1 -0
  50. data/doc/interactive_design.md +121 -0
  51. data/doc/quickstart.md +147 -72
  52. data/examples/all_events/app.rb +169 -0
  53. data/examples/all_events/app.rbs +7 -0
  54. data/examples/all_events/test_app.rb +139 -0
  55. data/examples/analytics/app.rb +258 -0
  56. data/examples/analytics/app.rbs +7 -0
  57. data/examples/analytics/test_app.rb +132 -0
  58. data/examples/block_padding/app.rb +63 -0
  59. data/examples/block_padding/app.rbs +7 -0
  60. data/examples/block_padding/test_app.rb +31 -0
  61. data/examples/block_titles/app.rb +61 -0
  62. data/examples/block_titles/app.rbs +7 -0
  63. data/examples/block_titles/test_app.rb +34 -0
  64. data/examples/box_demo/app.rb +216 -0
  65. data/examples/box_demo/app.rbs +7 -0
  66. data/examples/box_demo/test_app.rb +88 -0
  67. data/examples/calendar_demo/app.rb +101 -0
  68. data/examples/calendar_demo/app.rbs +7 -0
  69. data/examples/calendar_demo/test_app.rb +108 -0
  70. data/examples/cell_demo/app.rb +108 -0
  71. data/examples/cell_demo/app.rbs +7 -0
  72. data/examples/cell_demo/test_app.rb +36 -0
  73. data/examples/chart_demo/app.rb +203 -0
  74. data/examples/chart_demo/app.rbs +7 -0
  75. data/examples/chart_demo/test_app.rb +102 -0
  76. data/examples/custom_widget/app.rb +51 -0
  77. data/examples/custom_widget/app.rbs +7 -0
  78. data/examples/custom_widget/test_app.rb +30 -0
  79. data/examples/flex_layout/app.rb +156 -0
  80. data/examples/flex_layout/app.rbs +7 -0
  81. data/examples/flex_layout/test_app.rb +65 -0
  82. data/examples/gauge_demo/app.rb +182 -0
  83. data/examples/gauge_demo/app.rbs +7 -0
  84. data/examples/gauge_demo/test_app.rb +120 -0
  85. data/examples/hit_test/app.rb +175 -0
  86. data/examples/hit_test/app.rbs +7 -0
  87. data/examples/hit_test/test_app.rb +102 -0
  88. data/examples/line_gauge_demo/app.rb +190 -0
  89. data/examples/line_gauge_demo/app.rbs +7 -0
  90. data/examples/line_gauge_demo/test_app.rb +129 -0
  91. data/examples/list_demo/app.rb +253 -0
  92. data/examples/list_demo/app.rbs +12 -0
  93. data/examples/list_demo/test_app.rb +237 -0
  94. data/examples/list_styles/app.rb +140 -0
  95. data/examples/list_styles/app.rbs +7 -0
  96. data/examples/list_styles/test_app.rb +157 -0
  97. data/examples/{login_form.rb → login_form/app.rb} +12 -16
  98. data/examples/login_form/app.rbs +7 -0
  99. data/examples/login_form/test_app.rb +51 -0
  100. data/examples/map_demo/app.rb +90 -0
  101. data/examples/map_demo/app.rbs +7 -0
  102. data/examples/map_demo/test_app.rb +149 -0
  103. data/examples/{mouse_events.rb → mouse_events/app.rb} +29 -27
  104. data/examples/mouse_events/app.rbs +7 -0
  105. data/examples/mouse_events/test_app.rb +53 -0
  106. data/examples/{popup_demo.rb → popup_demo/app.rb} +15 -17
  107. data/examples/popup_demo/app.rbs +7 -0
  108. data/examples/{test_popup_demo.rb → popup_demo/test_app.rb} +18 -26
  109. data/examples/quickstart_dsl/app.rb +36 -0
  110. data/examples/quickstart_dsl/app.rbs +7 -0
  111. data/examples/quickstart_dsl/test_app.rb +29 -0
  112. data/examples/quickstart_lifecycle/app.rb +39 -0
  113. data/examples/quickstart_lifecycle/app.rbs +7 -0
  114. data/examples/quickstart_lifecycle/test_app.rb +29 -0
  115. data/examples/ratatui_logo_demo/app.rb +79 -0
  116. data/examples/ratatui_logo_demo/app.rbs +7 -0
  117. data/examples/ratatui_logo_demo/test_app.rb +51 -0
  118. data/examples/ratatui_mascot_demo/app.rb +84 -0
  119. data/examples/ratatui_mascot_demo/app.rbs +7 -0
  120. data/examples/ratatui_mascot_demo/test_app.rb +47 -0
  121. data/examples/readme_usage/app.rb +29 -0
  122. data/examples/readme_usage/app.rbs +7 -0
  123. data/examples/readme_usage/test_app.rb +29 -0
  124. data/examples/rich_text/app.rb +141 -0
  125. data/examples/rich_text/app.rbs +7 -0
  126. data/examples/rich_text/test_app.rb +166 -0
  127. data/examples/scroll_text/app.rb +103 -0
  128. data/examples/scroll_text/app.rbs +7 -0
  129. data/examples/scroll_text/test_app.rb +110 -0
  130. data/examples/scrollbar_demo/app.rb +143 -0
  131. data/examples/scrollbar_demo/app.rbs +7 -0
  132. data/examples/scrollbar_demo/test_app.rb +77 -0
  133. data/examples/sparkline_demo/app.rb +240 -0
  134. data/examples/sparkline_demo/app.rbs +10 -0
  135. data/examples/sparkline_demo/test_app.rb +107 -0
  136. data/examples/table_flex/app.rb +65 -0
  137. data/examples/table_flex/app.rbs +7 -0
  138. data/examples/table_flex/test_app.rb +36 -0
  139. data/examples/table_select/app.rb +198 -0
  140. data/examples/table_select/app.rbs +7 -0
  141. data/examples/table_select/test_app.rb +180 -0
  142. data/examples/widget_style_colors/app.rb +104 -0
  143. data/examples/widget_style_colors/app.rbs +14 -0
  144. data/examples/widget_style_colors/test_app.rb +48 -0
  145. data/ext/ratatui_ruby/Cargo.lock +889 -115
  146. data/ext/ratatui_ruby/Cargo.toml +4 -3
  147. data/ext/ratatui_ruby/clippy.toml +7 -0
  148. data/ext/ratatui_ruby/extconf.rb +7 -0
  149. data/ext/ratatui_ruby/src/events.rs +218 -229
  150. data/ext/ratatui_ruby/src/lib.rs +38 -10
  151. data/ext/ratatui_ruby/src/rendering.rs +90 -10
  152. data/ext/ratatui_ruby/src/style.rs +281 -98
  153. data/ext/ratatui_ruby/src/terminal.rs +119 -25
  154. data/ext/ratatui_ruby/src/text.rs +171 -0
  155. data/ext/ratatui_ruby/src/widgets/barchart.rs +97 -24
  156. data/ext/ratatui_ruby/src/widgets/block.rs +31 -3
  157. data/ext/ratatui_ruby/src/widgets/calendar.rs +45 -44
  158. data/ext/ratatui_ruby/src/widgets/canvas.rs +46 -29
  159. data/ext/ratatui_ruby/src/widgets/chart.rs +69 -27
  160. data/ext/ratatui_ruby/src/widgets/clear.rs +3 -1
  161. data/ext/ratatui_ruby/src/widgets/gauge.rs +11 -4
  162. data/ext/ratatui_ruby/src/widgets/layout.rs +218 -15
  163. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +92 -0
  164. data/ext/ratatui_ruby/src/widgets/list.rs +91 -11
  165. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  166. data/ext/ratatui_ruby/src/widgets/overlay.rs +3 -2
  167. data/ext/ratatui_ruby/src/widgets/paragraph.rs +35 -13
  168. data/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +29 -0
  169. data/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +44 -0
  170. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +59 -7
  171. data/ext/ratatui_ruby/src/widgets/sparkline.rs +70 -6
  172. data/ext/ratatui_ruby/src/widgets/table.rs +173 -64
  173. data/ext/ratatui_ruby/src/widgets/tabs.rs +105 -5
  174. data/lib/ratatui_ruby/cell.rb +166 -0
  175. data/lib/ratatui_ruby/event/focus_gained.rb +49 -0
  176. data/lib/ratatui_ruby/event/focus_lost.rb +50 -0
  177. data/lib/ratatui_ruby/event/key.rb +211 -0
  178. data/lib/ratatui_ruby/event/mouse.rb +124 -0
  179. data/lib/ratatui_ruby/event/paste.rb +71 -0
  180. data/lib/ratatui_ruby/event/resize.rb +80 -0
  181. data/lib/ratatui_ruby/event.rb +79 -0
  182. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +45 -0
  183. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +27 -0
  184. data/lib/ratatui_ruby/schema/bar_chart.rb +228 -19
  185. data/lib/ratatui_ruby/schema/block.rb +186 -14
  186. data/lib/ratatui_ruby/schema/calendar.rb +74 -17
  187. data/lib/ratatui_ruby/schema/canvas.rb +215 -48
  188. data/lib/ratatui_ruby/schema/center.rb +49 -11
  189. data/lib/ratatui_ruby/schema/chart.rb +151 -41
  190. data/lib/ratatui_ruby/schema/clear.rb +41 -72
  191. data/lib/ratatui_ruby/schema/constraint.rb +82 -22
  192. data/lib/ratatui_ruby/schema/cursor.rb +27 -9
  193. data/lib/ratatui_ruby/schema/draw.rb +53 -0
  194. data/lib/ratatui_ruby/schema/gauge.rb +59 -15
  195. data/lib/ratatui_ruby/schema/layout.rb +95 -13
  196. data/lib/ratatui_ruby/schema/line_gauge.rb +78 -0
  197. data/lib/ratatui_ruby/schema/list.rb +93 -19
  198. data/lib/ratatui_ruby/schema/overlay.rb +34 -8
  199. data/lib/ratatui_ruby/schema/paragraph.rb +87 -30
  200. data/lib/ratatui_ruby/schema/ratatui_logo.rb +25 -0
  201. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +29 -0
  202. data/lib/ratatui_ruby/schema/rect.rb +64 -15
  203. data/lib/ratatui_ruby/schema/scrollbar.rb +132 -24
  204. data/lib/ratatui_ruby/schema/shape/label.rb +66 -0
  205. data/lib/ratatui_ruby/schema/sparkline.rb +122 -15
  206. data/lib/ratatui_ruby/schema/style.rb +49 -21
  207. data/lib/ratatui_ruby/schema/table.rb +119 -21
  208. data/lib/ratatui_ruby/schema/tabs.rb +75 -13
  209. data/lib/ratatui_ruby/schema/text.rb +90 -0
  210. data/lib/ratatui_ruby/session.rb +146 -0
  211. data/lib/ratatui_ruby/test_helper.rb +156 -13
  212. data/lib/ratatui_ruby/version.rb +1 -1
  213. data/lib/ratatui_ruby.rb +143 -23
  214. data/sig/ratatui_ruby/event.rbs +69 -0
  215. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -1
  216. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +16 -0
  217. data/sig/ratatui_ruby/schema/bar_chart/bar_group.rbs +13 -0
  218. data/sig/ratatui_ruby/schema/bar_chart.rbs +20 -2
  219. data/sig/ratatui_ruby/schema/block.rbs +5 -4
  220. data/sig/ratatui_ruby/schema/calendar.rbs +6 -2
  221. data/sig/ratatui_ruby/schema/canvas.rbs +52 -39
  222. data/sig/ratatui_ruby/schema/center.rbs +3 -3
  223. data/sig/ratatui_ruby/schema/chart.rbs +8 -5
  224. data/sig/ratatui_ruby/schema/constraint.rbs +8 -5
  225. data/sig/ratatui_ruby/schema/cursor.rbs +1 -1
  226. data/sig/ratatui_ruby/schema/draw.rbs +23 -0
  227. data/sig/ratatui_ruby/schema/gauge.rbs +4 -2
  228. data/sig/ratatui_ruby/schema/layout.rbs +11 -1
  229. data/sig/ratatui_ruby/schema/line_gauge.rbs +16 -0
  230. data/sig/ratatui_ruby/schema/list.rbs +5 -1
  231. data/sig/ratatui_ruby/schema/paragraph.rbs +4 -1
  232. data/{lib/ratatui_ruby/output.rb → sig/ratatui_ruby/schema/ratatui_logo.rbs} +3 -2
  233. data/sig/ratatui_ruby/{buffer.rbs → schema/ratatui_mascot.rbs} +4 -3
  234. data/sig/ratatui_ruby/schema/rect.rbs +2 -1
  235. data/sig/ratatui_ruby/schema/scrollbar.rbs +18 -2
  236. data/sig/ratatui_ruby/schema/sparkline.rbs +6 -2
  237. data/sig/ratatui_ruby/schema/table.rbs +8 -1
  238. data/sig/ratatui_ruby/schema/tabs.rbs +5 -1
  239. data/sig/ratatui_ruby/schema/text.rbs +22 -0
  240. data/tasks/resources/build.yml.erb +13 -11
  241. data/tasks/terminal_preview/app_screenshot.rb +35 -0
  242. data/tasks/terminal_preview/crash_report.rb +54 -0
  243. data/tasks/terminal_preview/example_app.rb +25 -0
  244. data/tasks/terminal_preview/launcher_script.rb +48 -0
  245. data/tasks/terminal_preview/preview_collection.rb +60 -0
  246. data/tasks/terminal_preview/preview_timing.rb +22 -0
  247. data/tasks/terminal_preview/safety_confirmation.rb +58 -0
  248. data/tasks/terminal_preview/saved_screenshot.rb +55 -0
  249. data/tasks/terminal_preview/system_appearance.rb +11 -0
  250. data/tasks/terminal_preview/terminal_window.rb +138 -0
  251. data/tasks/terminal_preview/window_id.rb +14 -0
  252. data/tasks/terminal_preview.rake +28 -0
  253. data/tasks/test.rake +1 -1
  254. metadata +174 -53
  255. data/doc/images/examples-analytics.rb.png +0 -0
  256. data/doc/images/examples-box_demo.rb.png +0 -0
  257. data/doc/images/examples-calendar_demo.rb.png +0 -0
  258. data/doc/images/examples-chart_demo.rb.png +0 -0
  259. data/doc/images/examples-custom_widget.rb.png +0 -0
  260. data/doc/images/examples-dashboard.rb.png +0 -0
  261. data/doc/images/examples-list_styles.rb.png +0 -0
  262. data/doc/images/examples-login_form.rb.png +0 -0
  263. data/doc/images/examples-map_demo.rb.png +0 -0
  264. data/doc/images/examples-mouse_events.rb.png +0 -0
  265. data/doc/images/examples-popup_demo.rb.gif +0 -0
  266. data/doc/images/examples-quickstart_lifecycle.rb.png +0 -0
  267. data/doc/images/examples-scroll_text.rb.png +0 -0
  268. data/doc/images/examples-scrollbar_demo.rb.png +0 -0
  269. data/doc/images/examples-stock_ticker.rb.png +0 -0
  270. data/doc/images/examples-system_monitor.rb.png +0 -0
  271. data/doc/images/examples-table_select.rb.png +0 -0
  272. data/examples/analytics.rb +0 -88
  273. data/examples/box_demo.rb +0 -71
  274. data/examples/calendar_demo.rb +0 -55
  275. data/examples/chart_demo.rb +0 -84
  276. data/examples/custom_widget.rb +0 -43
  277. data/examples/dashboard.rb +0 -72
  278. data/examples/list_styles.rb +0 -66
  279. data/examples/map_demo.rb +0 -58
  280. data/examples/quickstart_dsl.rb +0 -30
  281. data/examples/quickstart_lifecycle.rb +0 -40
  282. data/examples/readme_usage.rb +0 -21
  283. data/examples/scroll_text.rb +0 -74
  284. data/examples/scrollbar_demo.rb +0 -75
  285. data/examples/stock_ticker.rb +0 -93
  286. data/examples/system_monitor.rb +0 -94
  287. data/examples/table_select.rb +0 -70
  288. data/examples/test_analytics.rb +0 -65
  289. data/examples/test_box_demo.rb +0 -38
  290. data/examples/test_calendar_demo.rb +0 -66
  291. data/examples/test_dashboard.rb +0 -38
  292. data/examples/test_list_styles.rb +0 -61
  293. data/examples/test_login_form.rb +0 -63
  294. data/examples/test_map_demo.rb +0 -100
  295. data/examples/test_scroll_text.rb +0 -130
  296. data/examples/test_stock_ticker.rb +0 -39
  297. data/examples/test_system_monitor.rb +0 -40
  298. data/examples/test_table_select.rb +0 -37
  299. data/ext/ratatui_ruby/src/buffer.rs +0 -54
  300. data/lib/ratatui_ruby/dsl.rb +0 -64
@@ -1,19 +1,19 @@
1
1
  # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
2
  # SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
- image: alpine/edge
4
+ image: archlinux
5
5
  packages:
6
6
  - bash
7
- - build-base
7
+ - base-devel
8
8
  - curl
9
- - openssl-dev
10
- - yaml-dev
11
- - zlib-dev
12
- - readline-dev
13
- - gdbm-dev
14
- - ncurses-dev
15
- - libffi-dev
16
- - clang-dev
9
+ - openssl
10
+ - libyaml
11
+ - zlib
12
+ - readline
13
+ - gdbm
14
+ - ncurses
15
+ - libffi
16
+ - clang
17
17
  - git
18
18
  artifacts:
19
19
  - ratatui_ruby/pkg/<%= gem_filename %>
@@ -24,8 +24,10 @@ tasks:
24
24
  curl https://mise.jdx.dev/install.sh | sh
25
25
  echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.buildenv
26
26
  echo 'eval "$($HOME/.local/bin/mise activate bash)"' >> ~/.buildenv
27
+ echo 'export LANG="en_US.UTF-8"' >> ~/.buildenv
28
+ echo 'export LC_ALL="en_US.UTF-8"' >> ~/.buildenv
29
+ echo 'export BINDGEN_EXTRA_CLANG_ARGS="-include stdbool.h"' >> ~/.buildenv
27
30
  . ~/.buildenv
28
- export RUSTFLAGS="-C target-feature=-crt-static"
29
31
  export CI="true"
30
32
  cd ratatui_ruby
31
33
  sed -i 's/ruby = .*/ruby = "<%= ruby_version %>"/' mise.toml
@@ -0,0 +1,35 @@
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 "tmpdir"
7
+ require_relative "launcher_script"
8
+ require_relative "terminal_window"
9
+ require_relative "crash_report"
10
+
11
+ class AppScreenshot < Data.define(:app, :output_path)
12
+ def capture
13
+ print " 📸 #{app}..."
14
+
15
+ LauncherScript.new(app.app_path, Dir.pwd).run do |launcher|
16
+ TerminalWindow.new(launcher.path, launcher.pid_file).open do |window|
17
+ take_snapshot(window.window_id)
18
+ puts " done."
19
+ true
20
+ end
21
+ end
22
+ rescue => e
23
+ puts " FAILED"
24
+ puts
25
+ puts CrashReport.new(app, e, "Program crashed before screenshot could be taken:")
26
+ puts
27
+ false
28
+ end
29
+
30
+ private
31
+
32
+ def take_snapshot(window_id)
33
+ system("screencapture -l #{window_id} -o -x '#{output_path}'")
34
+ end
35
+ end
@@ -0,0 +1,54 @@
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
+ class CrashReport < Data.define(:app, :error, :preamble)
7
+ def self.new(app, error, preamble = nil)
8
+ # Allow preamble to be optional while Data.define requires all fields
9
+ super(app:, error:, preamble:)
10
+ end
11
+
12
+ def to_s
13
+ output = error.message.strip
14
+ formatted_error = output.split("\n").map { |line| format_line(line) }.join("\n")
15
+ preamble_section = preamble ? <<~PREAMBLE.chomp : ""
16
+ #{box_top}
17
+ #{format_line(preamble)}
18
+ #{box_bottom}
19
+ PREAMBLE
20
+
21
+ <<~TEXT
22
+ #{preamble_section}
23
+ #{border_top(app.to_s)}
24
+ #{formatted_error}
25
+ #{box_bottom}
26
+ TEXT
27
+ end
28
+
29
+ private
30
+
31
+ def box_top
32
+ "┌" + "─" * (width - 2) + "┐"
33
+ end
34
+
35
+ def box_bottom
36
+ "└" + "─" * (width - 2) + "┘"
37
+ end
38
+
39
+ def border_top(title)
40
+ left = "┌─ #{title} "
41
+ right = "┐"
42
+ dashes = "─" * (width - left.length - right.length)
43
+ left + dashes + right
44
+ end
45
+
46
+ def format_line(line)
47
+ truncated = line[0...(width - 4)]
48
+ "│ #{truncated.ljust(width - 4)} │"
49
+ end
50
+
51
+ def width
52
+ 80
53
+ end
54
+ end
@@ -0,0 +1,25 @@
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
+ class ExampleApp < Data.define(:directory)
7
+ def self.all
8
+ examples_dir = File.expand_path("../../examples", __dir__)
9
+ Dir.glob("#{examples_dir}/*/app.rb").map do |path|
10
+ new(File.basename(File.dirname(path)))
11
+ end.sort_by(&:directory)
12
+ end
13
+
14
+ def app_path
15
+ "examples/#{directory}/app.rb"
16
+ end
17
+
18
+ def screenshot_filename
19
+ "#{directory}.png"
20
+ end
21
+
22
+ def to_s
23
+ directory
24
+ end
25
+ end
@@ -0,0 +1,48 @@
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 "fileutils"
7
+ require "tmpdir"
8
+
9
+ class LauncherScript < Data.define(:app_path, :repo_root)
10
+ def initialize(app_path:, repo_root:)
11
+ super
12
+ write
13
+ end
14
+
15
+ def run
16
+ yield self
17
+ ensure
18
+ cleanup
19
+ end
20
+
21
+ def path
22
+ File.join(Dir.tmpdir, "preview_launcher.sh")
23
+ end
24
+
25
+ def pid_file
26
+ File.join(Dir.tmpdir, "preview_launcher.pid")
27
+ end
28
+
29
+ private
30
+
31
+ def cleanup
32
+ File.delete(path) if File.exist?(path)
33
+ File.delete(pid_file) if File.exist?(pid_file)
34
+ rescue Errno::ENOENT
35
+ # Already deleted
36
+ end
37
+
38
+ def write
39
+ File.open(path, "w") do |f|
40
+ f.puts "#!/bin/zsh"
41
+ f.puts "cd '#{repo_root}'"
42
+ f.puts "clear"
43
+ f.puts "echo $$ > '#{pid_file}'"
44
+ f.puts "exec bundle exec ruby '#{app_path}'"
45
+ end
46
+ FileUtils.chmod(0o755, path)
47
+ end
48
+ end
@@ -0,0 +1,60 @@
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 "fileutils"
7
+ require_relative "example_app"
8
+ require_relative "app_screenshot"
9
+ require_relative "crash_report"
10
+ require_relative "preview_timing"
11
+ require_relative "safety_confirmation"
12
+ require_relative "saved_screenshot"
13
+
14
+ class PreviewCollection
15
+ def initialize(output_dir)
16
+ @output_dir = output_dir
17
+ end
18
+
19
+ def generate
20
+ abort "Error: This task requires macOS." unless RUBY_PLATFORM.match?(/darwin/)
21
+
22
+ apps = ExampleApp.all
23
+ stale_count = count_stale_apps(apps)
24
+
25
+ if stale_count.zero?
26
+ puts "\n✨ All #{apps.count} screenshots are up to date."
27
+ return
28
+ end
29
+
30
+ SafetyConfirmation.new(stale_count, apps.count).request
31
+
32
+ puts "\nHere we go!"
33
+ failures = apps.count { |app| !capture_app(app) }
34
+
35
+ if failures.zero?
36
+ puts "\n✨ All captures complete. Check doc/images/."
37
+ else
38
+ abort "\n❌ #{failures} capture(s) failed."
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def count_stale_apps(apps)
45
+ apps.count { |app| SavedScreenshot.for(app, @output_dir).stale? }
46
+ end
47
+
48
+ def capture_app(app)
49
+ saved = SavedScreenshot.for(app, @output_dir)
50
+
51
+ if saved.stale?
52
+ success = AppScreenshot.new(app, saved.path).capture
53
+ sleep PreviewTiming.between_captures
54
+ success
55
+ else
56
+ puts " ⏭️ #{app} (unchanged)"
57
+ true
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,22 @@
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
+ class PreviewTiming
7
+ def self.window_startup
8
+ 1.5
9
+ end
10
+
11
+ def self.between_captures
12
+ 0.2
13
+ end
14
+
15
+ def self.close_delay
16
+ 1.0
17
+ end
18
+
19
+ def self.total
20
+ window_startup + close_delay + between_captures
21
+ end
22
+ end
@@ -0,0 +1,58 @@
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 "preview_timing"
7
+ require_relative "system_appearance"
8
+
9
+ class SafetyConfirmation
10
+ def initialize(stale_count, total_count)
11
+ @stale_count = stale_count
12
+ @total_count = total_count
13
+ end
14
+
15
+ def request
16
+ print_warning
17
+ wait_for_user
18
+ end
19
+
20
+ private
21
+
22
+ def print_warning
23
+ unchanged_count = @total_count - @stale_count
24
+ puts "\n" + "=" * 60
25
+ puts " 📸 NATIVE TERMINAL CAPTURE 📸"
26
+ puts "=" * 60
27
+ puts "This task will:"
28
+ puts " 1. Take control of your mouse/keyboard focus"
29
+ puts " 2. Rapidly open and close Terminal windows"
30
+ puts " 3. Capture #{@stale_count} screenshots (#{unchanged_count} unchanged)"
31
+ puts
32
+ puts "Before starting, be sure Terminal.app has the following permissions"
33
+ puts "in System Settings.app -> Privacy & Security:"
34
+ puts " - Screen & System Audio Recording"
35
+ puts " - Automation -> System Events"
36
+ puts
37
+ puts "⚠️ PLEASE DO NOT TOUCH YOUR COMPUTER WHILE THIS RUNS."
38
+ min_time = (@stale_count * PreviewTiming.total).to_i
39
+ max_time = (@stale_count * (PreviewTiming.total + PreviewTiming.close_delay)).to_i
40
+ puts " (Estimated time: #{min_time}-#{max_time} seconds)"
41
+ puts
42
+ end
43
+
44
+ def wait_for_user
45
+ loop do
46
+ print "Continue? [Y/n]: "
47
+ response = $stdin.gets.strip.downcase
48
+
49
+ if response.empty? || response == "y"
50
+ return if SystemAppearance.dark?
51
+ puts "⚠️ Dark Mode is not enabled. Please enable it in System Settings or Control Center before proceeding."
52
+ puts
53
+ elsif response == "n"
54
+ abort "Cancelled."
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
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 "time"
7
+
8
+ class SavedScreenshot < Data.define(:app, :path)
9
+ def self.for(app, output_dir)
10
+ new(app, File.join(output_dir, app.screenshot_filename))
11
+ end
12
+
13
+ def stale?
14
+ return true unless exists?
15
+
16
+ app_last_modified > screenshot_last_commit_time
17
+ end
18
+
19
+ private
20
+
21
+ def exists?
22
+ File.exist?(path)
23
+ end
24
+
25
+ def app_last_modified
26
+ # If the file has staged or unstaged changes, it's definitely stale
27
+ return Time.now.to_i if changed?
28
+
29
+ # Otherwise, compare against the last git commit time
30
+ app_last_commit_time
31
+ end
32
+
33
+ def changed?
34
+ system("git diff HEAD --quiet #{app.app_path} 2>/dev/null")
35
+ !$?.success?
36
+ end
37
+
38
+ def app_last_commit_time
39
+ output = `git log -1 --format=%cI "#{app.app_path}" 2>/dev/null`.strip
40
+ return 0 if output.empty?
41
+
42
+ Time.iso8601(output).to_i
43
+ rescue StandardError
44
+ 0
45
+ end
46
+
47
+ def screenshot_last_commit_time
48
+ output = `git log -1 --format=%cI "#{path}" 2>/dev/null`.strip
49
+ return Time.now.to_i if output.empty?
50
+
51
+ Time.iso8601(output).to_i
52
+ rescue StandardError
53
+ Time.now.to_i
54
+ end
55
+ end
@@ -0,0 +1,11 @@
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
+ class SystemAppearance
7
+ def self.dark?
8
+ result = `osascript -e 'tell application "System Events" to tell appearance preferences to get dark mode'`.strip
9
+ result == "true"
10
+ end
11
+ end
@@ -0,0 +1,138 @@
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 "window_id"
7
+ require_relative "preview_timing"
8
+
9
+ class TerminalWindow
10
+ CTRL_C = "ASCII character 3"
11
+
12
+ def initialize(launcher_script_path, pid_file)
13
+ @launcher_script_path = launcher_script_path
14
+ @pid_file = pid_file
15
+ @window_id = nil
16
+ end
17
+
18
+ def open
19
+ setup_script = <<~APPLESCRIPT
20
+ tell application "Terminal"
21
+ set newTab to do script "#{@launcher_script_path}"
22
+ set currentWindow to window 1
23
+
24
+ set number of rows of currentWindow to 24
25
+ set number of columns of currentWindow to 80
26
+ set position of currentWindow to {100, 100}
27
+ set frontmost of currentWindow to true
28
+
29
+ return id of currentWindow
30
+ end tell
31
+ APPLESCRIPT
32
+
33
+ @window_id = WindowID.new(`osascript -e '#{setup_script}'`.strip)
34
+ wait_for_startup
35
+ yield self
36
+ ensure
37
+ close if @window_id
38
+ end
39
+
40
+ def window_id
41
+ @window_id
42
+ end
43
+
44
+ private
45
+
46
+ def close
47
+ try_graceful_shutdown
48
+ kill_process if process_still_alive?
49
+
50
+ delay_script = <<~APPLESCRIPT
51
+ tell application "Terminal"
52
+ delay #{PreviewTiming.close_delay}
53
+
54
+ try
55
+ close window id #{@window_id}
56
+ end try
57
+ end tell
58
+ APPLESCRIPT
59
+
60
+ system("osascript", "-e", delay_script, out: File::NULL, err: File::NULL)
61
+ end
62
+
63
+ def wait_for_startup
64
+ sleep PreviewTiming.window_startup
65
+
66
+ unless @window_id.valid?
67
+ raise "Failed to open terminal window"
68
+ end
69
+
70
+ unless process_running?
71
+ error_output = contents
72
+ raise error_output
73
+ end
74
+ end
75
+
76
+ def try_graceful_shutdown
77
+ shutdown_script = <<~APPLESCRIPT
78
+ tell application "Terminal"
79
+ try
80
+ do script (#{CTRL_C}) in window id #{@window_id}
81
+ end try
82
+ end tell
83
+ APPLESCRIPT
84
+
85
+ system("osascript", "-e", shutdown_script, out: File::NULL, err: File::NULL)
86
+ sleep 0.2
87
+ end
88
+
89
+ def process_still_alive?
90
+ return false unless @pid_file && File.exist?(@pid_file)
91
+
92
+ pid = File.read(@pid_file).strip.to_i
93
+ Process.kill(0, pid)
94
+ true
95
+ rescue Errno::ESRCH, Errno::ENOENT
96
+ false
97
+ end
98
+
99
+ def kill_process
100
+ return unless @pid_file && File.exist?(@pid_file)
101
+
102
+ pid = File.read(@pid_file).strip.to_i
103
+ Process.kill("TERM", pid)
104
+ rescue Errno::ESRCH, Errno::ENOENT
105
+ # Process already gone or PID file doesn't exist
106
+ end
107
+
108
+ def process_running?
109
+ check_script = <<~APPLESCRIPT
110
+ tell application "Terminal"
111
+ try
112
+ set theWindow to window id #{@window_id}
113
+ return busy of theWindow
114
+ on error
115
+ return false
116
+ end try
117
+ end tell
118
+ APPLESCRIPT
119
+
120
+ result = `osascript -e '#{check_script}'`.strip
121
+ result == "true"
122
+ end
123
+
124
+ def contents
125
+ read_script = <<~APPLESCRIPT
126
+ tell application "Terminal"
127
+ try
128
+ set theWindow to window id #{@window_id}
129
+ return contents of selected tab of theWindow
130
+ on error
131
+ return ""
132
+ end try
133
+ end tell
134
+ APPLESCRIPT
135
+
136
+ `osascript -e '#{read_script}'`
137
+ end
138
+ end
@@ -0,0 +1,14 @@
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
+ class WindowID < Data.define(:value)
7
+ def valid?
8
+ !value.empty? && value.match?(/^\d+$/)
9
+ end
10
+
11
+ def to_s
12
+ value
13
+ end
14
+ end
@@ -0,0 +1,28 @@
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 "fileutils"
7
+ require_relative "terminal_preview/preview_collection"
8
+ require_relative "terminal_preview/example_app"
9
+
10
+ namespace :terminal_preview do
11
+ desc "Generate native PNG screenshots using Terminal.app"
12
+ task :generate do
13
+ img_dir = File.expand_path("../doc/images", __dir__)
14
+ FileUtils.mkdir_p(img_dir)
15
+
16
+ # Create empty placeholder files for any missing images that compile depends on.
17
+ # This prevents Rake from trying to build them as dependencies.
18
+ ExampleApp.all.each do |app|
19
+ image_path = File.join(img_dir, "#{app}.png")
20
+ FileUtils.touch(image_path) unless File.exist?(image_path)
21
+ end
22
+
23
+ Rake::Task["compile"].invoke
24
+
25
+ collection = PreviewCollection.new(img_dir)
26
+ collection.generate
27
+ end
28
+ end
data/tasks/test.rake CHANGED
@@ -16,7 +16,7 @@ end
16
16
  Rake::Task["test"].clear if Rake::Task.task_defined?("test")
17
17
 
18
18
  desc "Run all tests (Ruby and Rust)"
19
- task test: %w[test:ruby test:rust]
19
+ task test: %w[compile test:ruby test:rust]
20
20
 
21
21
  namespace :test do
22
22
  desc "Run Rust tests"