ratatui_ruby 1.2.2 → 1.3.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 (269) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +54 -0
  3. data/.builds/ruby-3.3.yml +54 -0
  4. data/.builds/ruby-3.4.yml +54 -0
  5. data/.builds/ruby-4.0.0.yml +54 -0
  6. data/.pre-commit-config.yaml +16 -0
  7. data/.rubocop.yml +10 -0
  8. data/AGENTS.md +147 -0
  9. data/CHANGELOG.md +771 -0
  10. data/README.md +187 -0
  11. data/README.rdoc +302 -0
  12. data/Rakefile +11 -0
  13. data/Steepfile +50 -0
  14. data/doc/concepts/application_architecture.md +321 -0
  15. data/doc/concepts/application_testing.md +193 -0
  16. data/doc/concepts/async.md +190 -0
  17. data/doc/concepts/custom_widgets.md +247 -0
  18. data/doc/concepts/debugging.md +401 -0
  19. data/doc/concepts/event_handling.md +162 -0
  20. data/doc/concepts/interactive_design.md +146 -0
  21. data/doc/contributors/auditing/parity.md +239 -0
  22. data/doc/contributors/design/ruby_frontend.md +448 -0
  23. data/doc/contributors/design/rust_backend.md +434 -0
  24. data/doc/contributors/design.md +11 -0
  25. data/doc/contributors/developing_examples.md +400 -0
  26. data/doc/contributors/documentation_style.md +121 -0
  27. data/doc/contributors/index.md +21 -0
  28. data/doc/contributors/releasing.md +215 -0
  29. data/doc/contributors/todo/align/api_completeness_audit-finished.md +381 -0
  30. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +200 -0
  31. data/doc/contributors/todo/align/term.md +351 -0
  32. data/doc/contributors/todo/align/terminal.md +647 -0
  33. data/doc/contributors/todo/future_work.md +169 -0
  34. data/doc/contributors/upstream_requests/paragraph_span_rects.md +259 -0
  35. data/doc/contributors/upstream_requests/tab_rects.md +173 -0
  36. data/doc/contributors/upstream_requests/title_rects.md +132 -0
  37. data/doc/custom.css +22 -0
  38. data/doc/getting_started/quickstart.md +291 -0
  39. data/doc/getting_started/why.md +93 -0
  40. data/doc/images/app_all_events.png +0 -0
  41. data/doc/images/app_cli_rich_moments.gif +0 -0
  42. data/doc/images/app_color_picker.png +0 -0
  43. data/doc/images/app_debugging_showcase.gif +0 -0
  44. data/doc/images/app_debugging_showcase.png +0 -0
  45. data/doc/images/app_external_editor.gif +0 -0
  46. data/doc/images/app_login_form.png +0 -0
  47. data/doc/images/app_stateful_interaction.png +0 -0
  48. data/doc/images/verify_quickstart_dsl.png +0 -0
  49. data/doc/images/verify_quickstart_layout.png +0 -0
  50. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  51. data/doc/images/verify_readme_usage.png +0 -0
  52. data/doc/images/widget_barchart.png +0 -0
  53. data/doc/images/widget_block.png +0 -0
  54. data/doc/images/widget_box.png +0 -0
  55. data/doc/images/widget_calendar.png +0 -0
  56. data/doc/images/widget_canvas.png +0 -0
  57. data/doc/images/widget_cell.png +0 -0
  58. data/doc/images/widget_center.png +0 -0
  59. data/doc/images/widget_chart.png +0 -0
  60. data/doc/images/widget_gauge.png +0 -0
  61. data/doc/images/widget_layout_split.png +0 -0
  62. data/doc/images/widget_line_gauge.png +0 -0
  63. data/doc/images/widget_list.png +0 -0
  64. data/doc/images/widget_map.png +0 -0
  65. data/doc/images/widget_overlay.png +0 -0
  66. data/doc/images/widget_popup.png +0 -0
  67. data/doc/images/widget_ratatui_logo.png +0 -0
  68. data/doc/images/widget_ratatui_mascot.png +0 -0
  69. data/doc/images/widget_rect.png +0 -0
  70. data/doc/images/widget_render.png +0 -0
  71. data/doc/images/widget_rich_text.png +0 -0
  72. data/doc/images/widget_scroll_text.png +0 -0
  73. data/doc/images/widget_scrollbar.png +0 -0
  74. data/doc/images/widget_sparkline.png +0 -0
  75. data/doc/images/widget_style_colors.png +0 -0
  76. data/doc/images/widget_table.png +0 -0
  77. data/doc/images/widget_tabs.png +0 -0
  78. data/doc/images/widget_text_width.png +0 -0
  79. data/doc/index.md +34 -0
  80. data/doc/troubleshooting/async.md +4 -0
  81. data/doc/troubleshooting/terminal_limitations.md +131 -0
  82. data/doc/troubleshooting/tui_output.md +197 -0
  83. data/examples/app_all_events/README.md +114 -0
  84. data/examples/app_all_events/app.rb +98 -0
  85. data/examples/app_all_events/model/app_model.rb +159 -0
  86. data/examples/app_all_events/model/event_color_cycle.rb +43 -0
  87. data/examples/app_all_events/model/event_entry.rb +94 -0
  88. data/examples/app_all_events/model/msg.rb +39 -0
  89. data/examples/app_all_events/model/timestamp.rb +56 -0
  90. data/examples/app_all_events/update.rb +75 -0
  91. data/examples/app_all_events/view/app_view.rb +80 -0
  92. data/examples/app_all_events/view/controls_view.rb +54 -0
  93. data/examples/app_all_events/view/counts_view.rb +61 -0
  94. data/examples/app_all_events/view/live_view.rb +72 -0
  95. data/examples/app_all_events/view/log_view.rb +57 -0
  96. data/examples/app_all_events/view.rb +9 -0
  97. data/examples/app_cli_rich_moments/README.md +81 -0
  98. data/examples/app_cli_rich_moments/app.rb +189 -0
  99. data/examples/app_color_picker/README.md +156 -0
  100. data/examples/app_color_picker/app.rb +76 -0
  101. data/examples/app_color_picker/clipboard.rb +86 -0
  102. data/examples/app_color_picker/color.rb +193 -0
  103. data/examples/app_color_picker/controls.rb +92 -0
  104. data/examples/app_color_picker/copy_dialog.rb +168 -0
  105. data/examples/app_color_picker/export_pane.rb +128 -0
  106. data/examples/app_color_picker/harmony.rb +58 -0
  107. data/examples/app_color_picker/input.rb +176 -0
  108. data/examples/app_color_picker/main_container.rb +180 -0
  109. data/examples/app_color_picker/palette.rb +111 -0
  110. data/examples/app_debugging_showcase/README.md +119 -0
  111. data/examples/app_debugging_showcase/app.rb +318 -0
  112. data/examples/app_external_editor/README.md +62 -0
  113. data/examples/app_external_editor/app.rb +344 -0
  114. data/examples/app_login_form/README.md +58 -0
  115. data/examples/app_login_form/app.rb +109 -0
  116. data/examples/app_stateful_interaction/README.md +35 -0
  117. data/examples/app_stateful_interaction/app.rb +328 -0
  118. data/examples/timeout_demo.rb +45 -0
  119. data/examples/verify_quickstart_dsl/README.md +55 -0
  120. data/examples/verify_quickstart_dsl/app.rb +49 -0
  121. data/examples/verify_quickstart_layout/README.md +77 -0
  122. data/examples/verify_quickstart_layout/app.rb +73 -0
  123. data/examples/verify_quickstart_lifecycle/README.md +68 -0
  124. data/examples/verify_quickstart_lifecycle/app.rb +62 -0
  125. data/examples/verify_readme_usage/README.md +49 -0
  126. data/examples/verify_readme_usage/app.rb +42 -0
  127. data/examples/verify_website_managed/README.md +48 -0
  128. data/examples/verify_website_managed/app.rb +36 -0
  129. data/examples/verify_website_menu/README.md +60 -0
  130. data/examples/verify_website_menu/app.rb +84 -0
  131. data/examples/verify_website_spinner/README.md +44 -0
  132. data/examples/verify_website_spinner/app.rb +34 -0
  133. data/examples/widget_barchart/README.md +58 -0
  134. data/examples/widget_barchart/app.rb +240 -0
  135. data/examples/widget_block/README.md +44 -0
  136. data/examples/widget_block/app.rb +258 -0
  137. data/examples/widget_box/README.md +54 -0
  138. data/examples/widget_box/app.rb +255 -0
  139. data/examples/widget_calendar/README.md +48 -0
  140. data/examples/widget_calendar/app.rb +115 -0
  141. data/examples/widget_canvas/README.md +31 -0
  142. data/examples/widget_canvas/app.rb +130 -0
  143. data/examples/widget_cell/README.md +45 -0
  144. data/examples/widget_cell/app.rb +112 -0
  145. data/examples/widget_center/README.md +33 -0
  146. data/examples/widget_center/app.rb +118 -0
  147. data/examples/widget_chart/README.md +50 -0
  148. data/examples/widget_chart/app.rb +220 -0
  149. data/examples/widget_gauge/README.md +50 -0
  150. data/examples/widget_gauge/app.rb +229 -0
  151. data/examples/widget_layout_split/README.md +53 -0
  152. data/examples/widget_layout_split/app.rb +260 -0
  153. data/examples/widget_line_gauge/README.md +50 -0
  154. data/examples/widget_line_gauge/app.rb +219 -0
  155. data/examples/widget_list/README.md +58 -0
  156. data/examples/widget_list/app.rb +382 -0
  157. data/examples/widget_map/README.md +48 -0
  158. data/examples/widget_map/app.rb +95 -0
  159. data/examples/widget_overlay/README.md +45 -0
  160. data/examples/widget_overlay/app.rb +250 -0
  161. data/examples/widget_popup/README.md +45 -0
  162. data/examples/widget_popup/app.rb +106 -0
  163. data/examples/widget_ratatui_logo/README.md +43 -0
  164. data/examples/widget_ratatui_logo/app.rb +104 -0
  165. data/examples/widget_ratatui_mascot/README.md +43 -0
  166. data/examples/widget_ratatui_mascot/app.rb +95 -0
  167. data/examples/widget_rect/README.md +53 -0
  168. data/examples/widget_rect/app.rb +222 -0
  169. data/examples/widget_render/README.md +46 -0
  170. data/examples/widget_render/app.rb +186 -0
  171. data/examples/widget_render/app.rbs +41 -0
  172. data/examples/widget_rich_text/README.md +44 -0
  173. data/examples/widget_rich_text/app.rb +193 -0
  174. data/examples/widget_scroll_text/README.md +46 -0
  175. data/examples/widget_scroll_text/app.rb +109 -0
  176. data/examples/widget_scrollbar/README.md +46 -0
  177. data/examples/widget_scrollbar/app.rb +155 -0
  178. data/examples/widget_sparkline/README.md +51 -0
  179. data/examples/widget_sparkline/app.rb +277 -0
  180. data/examples/widget_style_colors/README.md +43 -0
  181. data/examples/widget_style_colors/app.rb +83 -0
  182. data/examples/widget_table/README.md +57 -0
  183. data/examples/widget_table/app.rb +285 -0
  184. data/examples/widget_tabs/README.md +50 -0
  185. data/examples/widget_tabs/app.rb +183 -0
  186. data/examples/widget_text_width/README.md +44 -0
  187. data/examples/widget_text_width/app.rb +117 -0
  188. data/ext/ratatui_ruby/Cargo.lock +1 -2
  189. data/ext/ratatui_ruby/Cargo.toml +1 -2
  190. data/ext/ratatui_ruby/src/events.rs +18 -157
  191. data/lib/ratatui_ruby/event/focus_gained.rb +50 -0
  192. data/lib/ratatui_ruby/event/focus_lost.rb +51 -0
  193. data/lib/ratatui_ruby/event/key/dwim.rb +301 -0
  194. data/lib/ratatui_ruby/event/key/modifier.rb +2 -0
  195. data/lib/ratatui_ruby/event/key.rb +9 -0
  196. data/lib/ratatui_ruby/event/mouse.rb +33 -0
  197. data/lib/ratatui_ruby/event/paste.rb +25 -0
  198. data/lib/ratatui_ruby/event/resize.rb +65 -0
  199. data/lib/ratatui_ruby/version.rb +1 -1
  200. data/migrate_to_buffer.rb +145 -0
  201. data/mise.toml +8 -0
  202. data/sig/ratatui_ruby/event.rbs +97 -0
  203. data/tasks/autodoc/examples.rb +87 -0
  204. data/tasks/autodoc/member.rb +58 -0
  205. data/tasks/autodoc/name.rb +21 -0
  206. data/tasks/autodoc.rake +21 -0
  207. data/tasks/bump/bump_workflow.rb +49 -0
  208. data/tasks/bump/cargo_lockfile.rb +21 -0
  209. data/tasks/bump/changelog.rb +104 -0
  210. data/tasks/bump/header.rb +32 -0
  211. data/tasks/bump/history.rb +32 -0
  212. data/tasks/bump/links.rb +69 -0
  213. data/tasks/bump/manifest.rb +33 -0
  214. data/tasks/bump/patch_release.rb +19 -0
  215. data/tasks/bump/release_branch.rb +17 -0
  216. data/tasks/bump/release_from_trunk.rb +49 -0
  217. data/tasks/bump/repository.rb +54 -0
  218. data/tasks/bump/ruby_gem.rb +29 -0
  219. data/tasks/bump/sem_ver.rb +44 -0
  220. data/tasks/bump/unreleased_section.rb +73 -0
  221. data/tasks/bump.rake +61 -0
  222. data/tasks/doc/documentation.rb +59 -0
  223. data/tasks/doc/link/file_url.rb +30 -0
  224. data/tasks/doc/link/relative_path.rb +61 -0
  225. data/tasks/doc/link/web_url.rb +55 -0
  226. data/tasks/doc/link.rb +52 -0
  227. data/tasks/doc/link_audit.rb +116 -0
  228. data/tasks/doc/problem.rb +40 -0
  229. data/tasks/doc/source_file.rb +93 -0
  230. data/tasks/doc.rake +905 -0
  231. data/tasks/example_viewer.html.erb +172 -0
  232. data/tasks/extension.rake +14 -0
  233. data/tasks/license/headers_md.rb +223 -0
  234. data/tasks/license/headers_rb.rb +210 -0
  235. data/tasks/license/license_utils.rb +130 -0
  236. data/tasks/license/snippets_md.rb +315 -0
  237. data/tasks/license/snippets_rdoc.rb +150 -0
  238. data/tasks/license.rake +91 -0
  239. data/tasks/lint.rake +170 -0
  240. data/tasks/rbs_predicates/predicate_catalog.rb +52 -0
  241. data/tasks/rbs_predicates/predicate_tests.rb +124 -0
  242. data/tasks/rbs_predicates/rbs_signature.rb +63 -0
  243. data/tasks/rbs_predicates.rake +31 -0
  244. data/tasks/rdoc_config.rb +29 -0
  245. data/tasks/resources/build.yml.erb +60 -0
  246. data/tasks/resources/index.html.erb +141 -0
  247. data/tasks/resources/rubies.yml +7 -0
  248. data/tasks/sourcehut.rake +122 -0
  249. data/tasks/steep.rake +11 -0
  250. data/tasks/terminal_preview/app_screenshot.rb +45 -0
  251. data/tasks/terminal_preview/crash_report.rb +54 -0
  252. data/tasks/terminal_preview/example_app.rb +27 -0
  253. data/tasks/terminal_preview/launcher_script.rb +48 -0
  254. data/tasks/terminal_preview/preview_collection.rb +60 -0
  255. data/tasks/terminal_preview/preview_timing.rb +24 -0
  256. data/tasks/terminal_preview/safety_confirmation.rb +58 -0
  257. data/tasks/terminal_preview/saved_screenshot.rb +56 -0
  258. data/tasks/terminal_preview/system_appearance.rb +13 -0
  259. data/tasks/terminal_preview/terminal_window.rb +138 -0
  260. data/tasks/terminal_preview/window_id.rb +16 -0
  261. data/tasks/terminal_preview.rake +30 -0
  262. data/tasks/test.rake +36 -0
  263. data/tasks/website/index_page.rb +30 -0
  264. data/tasks/website/version.rb +122 -0
  265. data/tasks/website/version_menu.rb +68 -0
  266. data/tasks/website/versioned_documentation.rb +83 -0
  267. data/tasks/website/website.rb +53 -0
  268. data/tasks/website.rake +28 -0
  269. metadata +256 -1
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Lockfiles need to be refreshed by a tool after Manifests are changed.
9
+ class CargoLockfile < Data.define(:path, :dir, :name)
10
+ def exists?
11
+ File.exist?(path)
12
+ end
13
+
14
+ def refresh
15
+ return unless exists?
16
+
17
+ Dir.chdir(dir) do
18
+ system("cargo update -p #{name} --offline")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "links"
9
+ require_relative "unreleased_section"
10
+ require_relative "history"
11
+ require_relative "header"
12
+
13
+ # Changelog manages the project's CHANGELOG.md file.
14
+ class Changelog
15
+ # Creates a new Changelog for the file at the given path.
16
+ def initialize(path: "CHANGELOG.md")
17
+ @path = path
18
+ end
19
+
20
+ # Releases a new version in the changelog.
21
+ # This moves the unreleased changes to a new version heading and resets the unreleased section.
22
+ def release(new_version)
23
+ content = File.read(@path)
24
+
25
+ header = Header.parse(content)
26
+ unreleased = UnreleasedSection.parse(content)
27
+ links = Links.from_markdown(content)
28
+
29
+ raise "Could not parse CHANGELOG.md" unless header && unreleased && links
30
+
31
+ history = History.parse(content, header.length, unreleased.to_s.length, links.to_s)
32
+
33
+ links.release(new_version)
34
+ history.add(unreleased.as_version(new_version))
35
+
36
+ File.write(@path, "#{header}#{UnreleasedSection.fresh}\n\n#{history}\n#{links}")
37
+ nil
38
+ end
39
+
40
+ # Removes entries from [Unreleased] that were released in the given version.
41
+ # Used when creating a release branch from trunk.
42
+ def prune_released_entries(released_entries)
43
+ content = File.read(@path)
44
+
45
+ header = Header.parse(content)
46
+ unreleased = UnreleasedSection.parse(content)
47
+ links = Links.from_markdown(content)
48
+
49
+ raise "Could not parse CHANGELOG.md" unless header && unreleased && links
50
+
51
+ history = History.parse(content, header.length, unreleased.to_s.length, links.to_s)
52
+
53
+ pruned = unreleased.without_entries(released_entries)
54
+
55
+ File.write(@path, "#{header}#{pruned}\n\n#{history}\n#{links}")
56
+ nil
57
+ end
58
+
59
+ # Imports a release section from another branch's changelog.
60
+ # Adds the version section to history and dedupes from [Unreleased].
61
+ # Uses "first wins" — if entry already deduped, doesn't re-add it.
62
+ def import_release(version, release_changelog_content)
63
+ release_section = extract_version_section(release_changelog_content, version)
64
+ return unless release_section
65
+
66
+ content = File.read(@path)
67
+
68
+ header = Header.parse(content)
69
+ unreleased = UnreleasedSection.parse(content)
70
+ links = Links.from_markdown(content)
71
+
72
+ raise "Could not parse CHANGELOG.md" unless header && unreleased && links
73
+
74
+ history = History.parse(content, header.length, unreleased.to_s.length, links.to_s)
75
+
76
+ # Add the release section to history (inserted in version order)
77
+ history.add(release_section)
78
+ links.release(version)
79
+
80
+ # Dedupe from [Unreleased] (first-wins: if already gone, no-op)
81
+ release_entries = release_section.lines.select { |l| l.strip.start_with?("- ") }.map(&:strip)
82
+ pruned = unreleased.without_entries(release_entries)
83
+
84
+ File.write(@path, "#{header}#{pruned}\n\n#{history}\n#{links}")
85
+ nil
86
+ end
87
+
88
+ def commit_message(version)
89
+ content = File.read(@path)
90
+ unreleased = UnreleasedSection.parse(content)
91
+ return nil unless unreleased
92
+
93
+ "chore: release v#{version}\n\n#{unreleased.commit_body}"
94
+ end
95
+
96
+ private def extract_version_section(changelog_content, version)
97
+ # Match the version heading and capture until the next version heading or links section
98
+ pattern = /^## \[#{Regexp.escape(version.to_s)}\][^\n]*\n(.*?)(?=^## \[|\n\[Unreleased\]:)/m
99
+ match = changelog_content.match(pattern)
100
+ return nil unless match
101
+
102
+ "## [#{version}]#{match[0].split("\n", 2).first.split(']', 2).last}\n#{match[1]}"
103
+ end
104
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Header manages the header section of the changelog.
9
+ class Header
10
+ PATTERN = /^(.*?)(?=## \[Unreleased\])/m
11
+
12
+ # Extracts the header section from the given content.
13
+ def self.parse(content)
14
+ match = content.match(PATTERN)
15
+ new(match[1]) if match
16
+ end
17
+
18
+ # Creates a new Header from the given content.
19
+ def initialize(content)
20
+ @content = content.dup
21
+ end
22
+
23
+ # Returns the length of the header content.
24
+ def length
25
+ @content.length
26
+ end
27
+
28
+ # Returns the current state of the header as a string.
29
+ def to_s
30
+ @content
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # History manages the versioned history of the changelog.
9
+ class History
10
+ # Extracts the history section from the given content, between unreleased and links.
11
+ def self.parse(content, header_length, unreleased_length, links_text)
12
+ start = header_length + unreleased_length
13
+ text = "#{content[start...(content.index(links_text))].strip}\n"
14
+ new(text)
15
+ end
16
+
17
+ # Creates a new History from the given content.
18
+ def initialize(content)
19
+ @content = content.dup
20
+ end
21
+
22
+ # Adds a new versioned section to the history.
23
+ def add(section)
24
+ @content = "#{"#{section}\n\n#{@content}".strip}\n"
25
+ nil
26
+ end
27
+
28
+ # Returns the current state of the history as a string.
29
+ def to_s
30
+ @content
31
+ end
32
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Manages the version comparison links at the botton of the changelog.
9
+ #
10
+ # Release automation needs to update links. Manually calculating git diff URLs
11
+ # for every release is tedious and error-prone. SourceHut does not have
12
+ # standard comparison views, complicating matters further.
13
+ #
14
+ # This class manages the collection of links. It parses them from the markdown.
15
+ # It generates the correct tree links for SourceHut. It properly shifts the
16
+ # "Unreleased" pointer.
17
+ #
18
+ # Use it to update the changelog during a release.
19
+ class Links
20
+ PATTERN = /^(\[Unreleased\]: .*)$/m
21
+ UNRELEASED_PATTERN = %r{^\[Unreleased\]: (.*?/refs/)HEAD$}
22
+
23
+ # Creates a Links object from the full markdown content.
24
+ #
25
+ # [content] String. The full text of the changelog.
26
+ def self.from_markdown(content)
27
+ match = content.match(PATTERN)
28
+ return unless match
29
+
30
+ new(match[1].strip)
31
+ end
32
+
33
+ # Returns the raw text of the links.
34
+ attr_reader :text
35
+
36
+ # Creates a new Links object.
37
+ #
38
+ # [text] String. The raw text of the links section.
39
+ def initialize(text)
40
+ @text = text.dup
41
+ end
42
+
43
+ # Releases a new version.
44
+ #
45
+ # Updates the "Unreleased" link to point to the new head. Adds a new link for
46
+ # the just-released version pointing to its specific tag.
47
+ #
48
+ # [version] String. The new version number (e.g., <tt>"0.5.0"</tt>).
49
+ def release(version)
50
+ return unless base_url
51
+
52
+ new_unreleased = "[Unreleased]: #{base_url}HEAD" # .../HEAD
53
+ new_version_link = "[#{version}]: #{base_url}v#{version}" # .../v1.0.0
54
+
55
+ @text.sub!(UNRELEASED_PATTERN, "#{new_unreleased}\n#{new_version_link}")
56
+ self
57
+ end
58
+
59
+ # Returns the string representation of the links.
60
+ def to_s
61
+ @text
62
+ end
63
+
64
+ # The base URL for the repository's references.
65
+ private def base_url
66
+ match = @text.match(UNRELEASED_PATTERN)
67
+ match[1] if match
68
+ end
69
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Manifests hold a copy of the version number and should be changed manually.
9
+ # Use Regexp lookarounds in `pattern` to match the version number.
10
+ class Manifest < Data.define(:path, :pattern, :primary)
11
+ def read
12
+ File.read(path)
13
+ end
14
+
15
+ def initialize(path:, pattern:, primary: false)
16
+ super
17
+ end
18
+
19
+ def version
20
+ content = read
21
+ match = content.match(pattern)
22
+ raise "Version missing in manifest #{path}" unless match
23
+
24
+ SemVer.parse(match[0])
25
+ end
26
+
27
+ def write(version)
28
+ return unless File.exist?(path)
29
+
30
+ new_content = read.gsub(pattern, version.to_s)
31
+ File.write(path, new_content)
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "bump_workflow"
9
+
10
+ # PatchRelease performs a patch release on the current release branch.
11
+ class PatchRelease < BumpWorkflow
12
+ private def prepare(segment)
13
+ puts "Bumping #{segment}: #{@gem.version} -> #{target}"
14
+ end
15
+
16
+ private def finalize
17
+ puts "\nCommitted. Push and run: bundle exec rake release"
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # ReleaseBranch represents a release/X.Y branch for a version series.
9
+ class ReleaseBranch < Data.define(:major, :minor)
10
+ def self.for_version(semver)
11
+ new(semver.major, semver.minor)
12
+ end
13
+
14
+ def name
15
+ "release/#{major}.#{minor}"
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "bump_workflow"
9
+ require_relative "release_branch"
10
+
11
+ # ReleaseFromTrunk creates a release branch, releases there, syncs trunk, returns to release branch.
12
+ class ReleaseFromTrunk < BumpWorkflow
13
+ private def prepare(segment)
14
+ @branch = ReleaseBranch.for_version(target)
15
+
16
+ puts "Creating release branch: #{@branch.name}"
17
+ puts "Bumping #{segment}: #{@gem.version} -> #{target}"
18
+
19
+ @repository.create_branch(@branch.name)
20
+ end
21
+
22
+ private def release_on_branch
23
+ super
24
+ puts "\nCommitted on #{@branch.name}."
25
+
26
+ # Capture for trunk import
27
+ @released_changelog_content = File.read("CHANGELOG.md")
28
+ end
29
+
30
+ private def finalize
31
+ sync_trunk
32
+ return_to_release_branch
33
+ end
34
+
35
+ private def sync_trunk
36
+ @repository.checkout("trunk")
37
+ trunk_changelog = Changelog.new
38
+ trunk_changelog.import_release(target, @released_changelog_content)
39
+ @gem.update_version(target)
40
+ generate_ci_manifests
41
+ @repository.commit_all("chore: import v#{target} to trunk")
42
+ puts "Committed on trunk."
43
+ end
44
+
45
+ private def return_to_release_branch
46
+ @repository.checkout(@branch.name)
47
+ puts "\nBack on #{@branch.name}. Review, push both branches, then: bundle exec rake release"
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "shellwords"
9
+ require "tmpdir"
10
+
11
+ class Repository
12
+ TRUNK = "trunk"
13
+
14
+ def on_trunk? = current_branch == TRUNK
15
+
16
+ def current_branch
17
+ `git branch --show-current`.strip
18
+ end
19
+
20
+ def create_branch(name)
21
+ system("git checkout -b #{name}", exception: true)
22
+ end
23
+
24
+ def checkout(name)
25
+ system("git checkout #{name}", exception: true)
26
+ end
27
+
28
+ def assert_can_bump!(segment)
29
+ assert_pristine!
30
+
31
+ if on_trunk? && segment == :patch
32
+ raise ArgumentError, "Cannot bump:patch from trunk. Use a release branch."
33
+ end
34
+
35
+ if !on_trunk? && [:minor, :major].include?(segment)
36
+ raise ArgumentError, "Cannot bump:#{segment} from #{current_branch}. Switch to trunk first."
37
+ end
38
+ end
39
+
40
+ def assert_pristine!
41
+ return if `git status --porcelain`.strip.empty?
42
+
43
+ raise ArgumentError, "Working tree is not clean. Commit or stash changes first."
44
+ end
45
+
46
+ def commit_all(message)
47
+ msg_file = File.join(Dir.tmpdir, "ratatui_ruby_commit_msg_#{$$}.txt")
48
+ system("git add -A", exception: true)
49
+ File.write(msg_file, message)
50
+ system("git commit -F #{msg_file.shellescape}", exception: true)
51
+ ensure
52
+ File.delete(msg_file) if msg_file && File.exist?(msg_file)
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # RubyGem knows how to update its version: manifests, lockfiles.
9
+ class RubyGem
10
+ def initialize(manifests:, lockfile:)
11
+ raise ArgumentError, "Must have exactly one primary manifest" unless manifests.count(&:primary) == 1
12
+ @manifests = manifests
13
+ @lockfile = lockfile
14
+ end
15
+
16
+ def version
17
+ @manifests.find(&:primary).version
18
+ end
19
+
20
+ def update_version(target)
21
+ @manifests.each { |manifest| manifest.write(target) }
22
+ @lockfile.refresh
23
+ refresh_bundler_lockfile
24
+ end
25
+
26
+ private def refresh_bundler_lockfile
27
+ system("bundle install", exception: true)
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # See https://semver.org/spec/v2.0.0.html
9
+ class SemVer
10
+ SEGMENTS = [:major, :minor, :patch].freeze
11
+
12
+ def self.parse(string)
13
+ require "rubygems"
14
+ # Extract prerelease suffix (e.g., "-beta.1", "-alpha.2", "-rc.1")
15
+ base, prerelease = string.split("-", 2)
16
+ segments = Gem::Version.new(base).segments.fill(0, 3).first(3)
17
+ new(segments, prerelease:)
18
+ end
19
+
20
+ def initialize(segments, prerelease: nil)
21
+ @segments = segments
22
+ @prerelease = prerelease
23
+ end
24
+
25
+ def major = @segments[0]
26
+ def minor = @segments[1]
27
+ def patch = @segments[2]
28
+
29
+ def next(segment)
30
+ index = SEGMENTS.index(segment)
31
+ raise ArgumentError, "Invalid segment: #{segment}" unless index
32
+
33
+ new_segments = @segments.dup
34
+ new_segments[index] += 1
35
+ new_segments.fill(0, (index + 1)..2)
36
+
37
+ SemVer.new(new_segments)
38
+ end
39
+
40
+ def to_s
41
+ base = @segments.join(".")
42
+ @prerelease ? "#{base}-#{@prerelease}" : base
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "date"
9
+ require "rdoc"
10
+
11
+ # UnreleasedSection manages the [Unreleased] section of the changelog.
12
+ class UnreleasedSection
13
+ PATTERN = /^(## \[Unreleased\].*?)(?=## \[\d)/m
14
+
15
+ # Extracts the unreleased section from the given content.
16
+ def self.parse(content)
17
+ match = content.match(PATTERN)
18
+ new(match[1].strip) if match
19
+ end
20
+
21
+ # Creates a new UnreleasedSection from the given unreleased content.
22
+ def initialize(content)
23
+ @content = content.dup
24
+ end
25
+
26
+ # Returns the unreleased content as a versioned section.
27
+ def as_version(new_version)
28
+ date = Date.today.iso8601
29
+ @content.sub(/^## \[Unreleased\]/, "## [#{new_version}] - #{date}")
30
+ end
31
+
32
+ # Returns a fresh unreleased section.
33
+ def self.fresh
34
+ new("## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n### Removed")
35
+ end
36
+
37
+ # Returns the current state of the section as a string.
38
+ def to_s
39
+ @content
40
+ end
41
+
42
+ def commit_body
43
+ formatter = Class.new { include RDoc::Text }.new
44
+ @content
45
+ .sub(/^## \[Unreleased\].*$/, "")
46
+ .gsub(/^### (Added|Changed|Fixed|Removed)\n*$/, "")
47
+ .gsub(/^- \*\*([^*]+)\*\*:/, '\1:')
48
+ .gsub(/`([^`]+)`/, '\1')
49
+ .strip
50
+ .lines
51
+ .map { |line| line.gsub(/^- /, "").strip }
52
+ .reject(&:empty?)
53
+ .map { |line| formatter.wrap(line, 72) }
54
+ .join("\n\n")
55
+ end
56
+
57
+ # Returns all changelog entry lines (lines starting with "- ")
58
+ def entries
59
+ @content.lines.select { |line| line.strip.start_with?("- ") }.map(&:strip)
60
+ end
61
+
62
+ # Returns a new UnreleasedSection with entries removed that appear in the given list.
63
+ def without_entries(entries_to_remove)
64
+ return self if entries_to_remove.empty?
65
+
66
+ new_lines = @content.lines.reject do |line|
67
+ stripped = line.strip
68
+ entries_to_remove.include?(stripped)
69
+ end
70
+
71
+ self.class.new(new_lines.join)
72
+ end
73
+ end
data/tasks/bump.rake ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "rubygems"
9
+
10
+ require_relative "bump/sem_ver"
11
+ require_relative "bump/manifest"
12
+ require_relative "bump/cargo_lockfile"
13
+ require_relative "bump/ruby_gem"
14
+ require_relative "bump/release_from_trunk"
15
+ require_relative "bump/patch_release"
16
+
17
+ namespace :bump do
18
+ gem = RubyGem.new(
19
+ manifests: [
20
+ Manifest.new(
21
+ path: "lib/ratatui_ruby/version.rb",
22
+ pattern: /(?<=VERSION = ")[^"]+(?=")/,
23
+ primary: true
24
+ ),
25
+ Manifest.new(
26
+ path: "ext/ratatui_ruby/Cargo.toml",
27
+ pattern: /(?<=^version = ")[^"]+(?=")/,
28
+ primary: false
29
+ ),
30
+ ],
31
+ lockfile: CargoLockfile.new(
32
+ path: "ext/ratatui_ruby/Cargo.lock",
33
+ dir: "ext/ratatui_ruby",
34
+ name: "ratatui_ruby"
35
+ )
36
+ )
37
+
38
+ desc "Bump major version"
39
+ task :major do
40
+ ReleaseFromTrunk.new(gem:).call(:major)
41
+ end
42
+
43
+ desc "Bump minor version"
44
+ task :minor do
45
+ ReleaseFromTrunk.new(gem:).call(:minor)
46
+ end
47
+
48
+ desc "Bump patch version"
49
+ task :patch do
50
+ PatchRelease.new(gem:).call(:patch)
51
+ end
52
+
53
+ desc "Set exact version (e.g. rake bump:exact[0.1.0])"
54
+ task :exact, [:version] do |_, args|
55
+ target = SemVer.parse(args[:version])
56
+ changelog = Changelog.new
57
+ changelog.release(target)
58
+ gem.update_version(target)
59
+ Rake::Task["sourcehut"].invoke
60
+ end
61
+ end