gitpane 1.0.0__py3-none-any.whl

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 (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,125 @@
1
+ """Grid of the emoji keyboard that the staging window shares with the floating keyboard window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QEvent, Qt, Signal
6
+ from PySide6.QtGui import QFont, QResizeEvent
7
+ from PySide6.QtWidgets import QFrame, QGraphicsOpacityEffect, QLabel, QScrollArea, QToolButton, QVBoxLayout, QWidget
8
+
9
+ from gitpane.config import EMOJI_GRID_DISABLED_OPACITY, EMOJI_MARK_BUTTON_WIDTH_RATIO, EMOJI_POINT_SIZE, SPACING_SM, SPACING_XS, emoji_button_side
10
+ from gitpane.emoji.emoji_catalogue import COMBINING_MARK_ENTRIES, COMBINING_MARK_TITLE, EMOJI_ENTRIES
11
+ from gitpane.ui.emoji.emoji_font import emoji_capable_font
12
+ from gitpane.ui.widget.flow_layout import FlowLayout
13
+
14
+ EMOJI_BUTTON_WIDTH_RATIO = 1
15
+ # A frame and an inner margin sit on both edges of each axis.
16
+ EDGES_PER_AXIS = 2
17
+
18
+
19
+ class EmojiGrid(QScrollArea):
20
+ """Flat buttons for every catalogue symbol, wrapped to the available width.
21
+
22
+ The grid only reports which text was chosen and which button was clicked; the host decides what happens with it
23
+ (insertion into the commit message, or a copy to the clipboard).
24
+ """
25
+
26
+ chosen = Signal(str, object)
27
+
28
+ def __init__(self, parent: QWidget | None = None) -> None:
29
+ """Build every button once, in catalogue order, with the combining marks in their own titled row at the end."""
30
+ super().__init__(parent)
31
+ self.setWidgetResizable(True)
32
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
33
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
34
+ self.setFrameShape(QFrame.Shape.StyledPanel)
35
+ container = QWidget()
36
+ container_layout = QVBoxLayout(container)
37
+ container_layout.setContentsMargins(SPACING_XS, SPACING_XS, SPACING_XS, SPACING_XS)
38
+ container_layout.setSpacing(0)
39
+ self.emoji_area = QWidget()
40
+ emoji_layout = FlowLayout(self.emoji_area)
41
+ emoji_layout.setContentsMargins(0, 0, 0, 0)
42
+ self.mark_area = QWidget()
43
+ mark_layout = FlowLayout(self.mark_area)
44
+ mark_layout.setContentsMargins(0, 0, 0, 0)
45
+ # The symbol size is set on the two symbol areas only, so the marks title keeps the normal application font.
46
+ symbol_font = self._symbol_font()
47
+ self.emoji_area.setFont(symbol_font)
48
+ self.mark_area.setFont(symbol_font)
49
+ self.mark_title_label = QLabel(COMBINING_MARK_TITLE)
50
+ container_layout.addWidget(self.emoji_area)
51
+ container_layout.addSpacing(SPACING_SM)
52
+ container_layout.addWidget(self.mark_title_label)
53
+ container_layout.addSpacing(SPACING_XS)
54
+ container_layout.addWidget(self.mark_area)
55
+ container_layout.addStretch(1)
56
+ self.button_side = emoji_button_side(self.emoji_area.fontMetrics().height())
57
+ self.buttons: list[QToolButton] = []
58
+ for entries, layout, area, width_ratio in ((EMOJI_ENTRIES, emoji_layout, self.emoji_area, EMOJI_BUTTON_WIDTH_RATIO),
59
+ (COMBINING_MARK_ENTRIES, mark_layout, self.mark_area, EMOJI_MARK_BUTTON_WIDTH_RATIO)):
60
+ for entry in entries:
61
+ button = QToolButton(area)
62
+ button.setAutoRaise(True)
63
+ button.setText(entry.shown_text())
64
+ button.setToolTip(entry.name)
65
+ button.setAccessibleName(entry.name)
66
+ button.setFixedSize(self.button_side * width_ratio, self.button_side)
67
+ button.clicked.connect(lambda checked=False, text=entry.text, button=button: self.chosen.emit(text, button))
68
+ layout.addWidget(button)
69
+ self.buttons.append(button)
70
+ self.setWidget(container)
71
+
72
+ def changeEvent(self, event: QEvent) -> None:
73
+ """Dim the symbols while the grid is disabled, and only then.
74
+
75
+ Colour emoji glyphs ignore the palette's disabled colour, so without this a disabled grid would look clickable.
76
+ """
77
+ if event.type() == QEvent.Type.EnabledChange:
78
+ if self.isEnabled():
79
+ self.widget().setGraphicsEffect(None)
80
+ else:
81
+ effect = QGraphicsOpacityEffect(self.widget())
82
+ effect.setOpacity(EMOJI_GRID_DISABLED_OPACITY)
83
+ self.widget().setGraphicsEffect(effect)
84
+ super().changeEvent(event)
85
+
86
+ def resizeEvent(self, event: QResizeEvent) -> None:
87
+ """Follow the new size, then drop a vertical scroll bar the symbols no longer need."""
88
+ super().resizeEvent(event)
89
+ self._drop_needless_scroll_bar()
90
+
91
+ def _drop_needless_scroll_bar(self) -> None:
92
+ """Hide the vertical scroll bar once every symbol fits at the full width of the grid.
93
+
94
+ Workaround: QScrollArea decides whether the bar is needed from the height its content asks at the current viewport
95
+ width, which is narrower while the bar is shown. When the symbols only fit at the full width (the bar's width
96
+ would push one symbol to a new row), the bar never goes away on its own, even at a height that shows every
97
+ symbol. Switching the bar off for a moment makes Qt measure the content again at the full width.
98
+ """
99
+ scroll_bar = self.verticalScrollBar()
100
+ if scroll_bar.isHidden() or self.widget() is None:
101
+ return
102
+ full_size = self.maximumViewportSize()
103
+ if self.widget().heightForWidth(full_size.width()) > full_size.height():
104
+ return
105
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
106
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
107
+
108
+ def _symbol_font(self) -> QFont:
109
+ """Return the font the symbols are drawn with: the bundled colour emoji font at the keyboard size."""
110
+ return emoji_capable_font(EMOJI_POINT_SIZE)
111
+
112
+ def _length_for_cells(self, cell_count: int) -> int:
113
+ """Return the grid length that shows exactly this many symbol cells along one axis, frame and inner margins included.
114
+
115
+ Cells are square and the gap between them equals the inner margin, so the same length holds across and down.
116
+ """
117
+ return cell_count * self.button_side + (cell_count - 1) * SPACING_XS + EDGES_PER_AXIS * (SPACING_XS + self.frameWidth())
118
+
119
+ def row_height(self, row_count: int) -> int:
120
+ """Return the grid height that shows this many rows of symbols, frame and inner margins included."""
121
+ return self._length_for_cells(row_count)
122
+
123
+ def content_height_for_width(self, width: int) -> int:
124
+ """Return the grid height that shows every symbol without scrolling at this width, frame included."""
125
+ return self.widget().heightForWidth(width - EDGES_PER_AXIS * self.frameWidth()) + EDGES_PER_AXIS * self.frameWidth()
@@ -0,0 +1,154 @@
1
+ """Show the floating emoji keyboard every repository window shares, where a click copies a symbol to the clipboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QByteArray, QPoint, QRect, QSize, Qt, QTimer, Signal
8
+ from PySide6.QtGui import QAction, QClipboard, QKeySequence, QPalette
9
+ from PySide6.QtWidgets import QFrame, QLabel, QStyle, QVBoxLayout, QWidget
10
+
11
+ from gitpane.config import (
12
+ COPIED_TO_CLIPBOARD_TEXT,
13
+ EMOJI_COPY_TOAST_MS,
14
+ EMOJI_KEYBOARD_TITLE,
15
+ EMOJI_WINDOW_MIN_HEIGHT,
16
+ EMOJI_WINDOW_MIN_WIDTH,
17
+ EMOJI_WINDOW_SCREEN_WIDTH_SHARE,
18
+ SCREEN_MARGIN,
19
+ SPACING_SM,
20
+ SPACING_XS,
21
+ fit_window,
22
+ frame_position_inside,
23
+ )
24
+ from gitpane.ui.emoji.emoji_grid import EmojiGrid
25
+ from gitpane.ui.window_control.window_placement import mark_window_placed
26
+
27
+ if TYPE_CHECKING:
28
+ from PySide6.QtGui import QCloseEvent
29
+
30
+
31
+ def toast_frame(button_frame: QRect, toast_size: QSize, available: QRect) -> QRect:
32
+ """Return where the copy bubble goes for a button, in global coordinates.
33
+
34
+ The bubble is centred on the button, a small gap above it; when that would cross the top of the usable screen area it
35
+ goes the same gap below the button instead, and it is always pulled back inside the screen.
36
+ """
37
+ left = button_frame.left() + (button_frame.width() - toast_size.width()) // 2
38
+ top = button_frame.top() - SPACING_XS - toast_size.height()
39
+ if top < available.top():
40
+ top = button_frame.bottom() + 1 + SPACING_XS
41
+ frame = QRect(QPoint(left, top), toast_size)
42
+ return QRect(frame_position_inside(frame, available), toast_size)
43
+
44
+
45
+ class CopyToast(QLabel):
46
+ """Short-lived bubble above the clicked button that never takes the focus or the clicks; a new copy moves it and restarts its delay."""
47
+
48
+ def __init__(self, parent: QWidget) -> None:
49
+ """Build the hidden bubble once; the owning window moves and shows it on every copy."""
50
+ super().__init__(COPIED_TO_CLIPBOARD_TEXT, parent, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowTransparentForInput)
51
+ self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
52
+ self.setFrameShape(QFrame.Shape.Box)
53
+ self.setAutoFillBackground(True)
54
+ self.setBackgroundRole(QPalette.ColorRole.ToolTipBase)
55
+ self.setForegroundRole(QPalette.ColorRole.ToolTipText)
56
+ self.setContentsMargins(SPACING_SM, SPACING_XS, SPACING_SM, SPACING_XS)
57
+ self.hide_timer = QTimer(self)
58
+ self.hide_timer.setSingleShot(True)
59
+ self.hide_timer.setInterval(EMOJI_COPY_TOAST_MS)
60
+ self.hide_timer.timeout.connect(self.hide)
61
+
62
+ def show_above(self, button: QWidget) -> None:
63
+ """Place the bubble over this button, inside its screen, and restart the countdown that hides it."""
64
+ self.adjustSize()
65
+ button_frame = QRect(button.mapToGlobal(QPoint(0, 0)), button.size())
66
+ frame = toast_frame(button_frame, self.size(), button.screen().availableGeometry())
67
+ self.move(frame.topLeft())
68
+ self.show()
69
+ self.raise_()
70
+ self.hide_timer.start()
71
+
72
+
73
+ class EmojiWindow(QWidget):
74
+ """Always-on-top window showing the shared emoji grid; a click puts the exact symbol on the clipboard and pastes nothing.
75
+
76
+ The window has no Qt parent, so it belongs to no repository and outlives a repository switch in the window that opened it.
77
+ Its geometry is handed out on every close, as ASCII text, so the owner can keep it from one session to the next.
78
+ """
79
+
80
+ geometry_saved = Signal(str)
81
+
82
+ def __init__(self, clipboard: QClipboard) -> None:
83
+ """Build the grid once and write only to the clipboard it is given."""
84
+ super().__init__(None, Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint)
85
+ # A window without a parent that keeps this attribute holds back the "last window closed" signal when the last
86
+ # repository window closes, and GitPane would never stop while the keyboard is open.
87
+ self.setAttribute(Qt.WidgetAttribute.WA_QuitOnClose, False)
88
+ self.setWindowTitle(EMOJI_KEYBOARD_TITLE)
89
+ self.clipboard = clipboard
90
+ layout = QVBoxLayout(self)
91
+ layout.setContentsMargins(SPACING_SM, SPACING_SM, SPACING_SM, SPACING_SM)
92
+ self.grid = EmojiGrid()
93
+ layout.addWidget(self.grid)
94
+ self.grid.chosen.connect(self.copy_symbol)
95
+ self.toast = CopyToast(self)
96
+ close_action = QAction("Close", self)
97
+ close_action.setShortcuts([QKeySequence(Qt.Key.Key_Escape), QKeySequence("Ctrl+W")])
98
+ close_action.triggered.connect(self.close)
99
+ self.addAction(close_action)
100
+
101
+ def copy_symbol(self, text: str, button: QWidget) -> None:
102
+ """Put the exact symbol on the system clipboard and confirm it over the clicked button.
103
+
104
+ This is the only clipboard access of the window, and it never reads the clipboard.
105
+ """
106
+ self.clipboard.setText(text, QClipboard.Mode.Clipboard)
107
+ self.toast.show_above(button)
108
+
109
+ def resize_to_whole_grid(self, available: QRect) -> None:
110
+ """Take the first-open size from the grid's real layout, so every symbol and mark shows without scrolling, within the screen.
111
+
112
+ The usable area is the one of the screen the window is about to open on: before it is shown the window does not
113
+ know that screen yet, and would otherwise report the primary one. The window starts at a share of the usable
114
+ width, with the height the laid-out grid asks for at that width. When that height does not fit, the window grows
115
+ wider one symbol at a time, which removes rows, until it fits or reaches the usable width; only then does the
116
+ grid scroll.
117
+ """
118
+ inner_area = available.adjusted(SCREEN_MARGIN, SCREEN_MARGIN, -SCREEN_MARGIN, -SCREEN_MARGIN)
119
+ # The title bar only has a real height once the window is shown; the style's title bar height stands in for it.
120
+ title_bar_height = self.style().pixelMetric(QStyle.PixelMetric.PM_TitleBarHeight, None, self)
121
+ maximum_width = max(1, inner_area.width())
122
+ maximum_height = max(1, inner_area.height() - title_bar_height)
123
+ width = min(maximum_width, max(EMOJI_WINDOW_MIN_WIDTH, round(inner_area.width() * EMOJI_WINDOW_SCREEN_WIDTH_SHARE)))
124
+ height = self._height_for_width(width)
125
+ symbol_step = self.grid.button_side + SPACING_XS
126
+ while height > maximum_height and width < maximum_width:
127
+ width = min(maximum_width, width + symbol_step)
128
+ height = self._height_for_width(width)
129
+ self.resize(width, min(height, maximum_height))
130
+ fit_window(self, EMOJI_WINDOW_MIN_WIDTH, EMOJI_WINDOW_MIN_HEIGHT, available)
131
+
132
+ def _height_for_width(self, width: int) -> int:
133
+ """Return the window height that shows the whole laid-out grid at this window width, margins included."""
134
+ margins = self.layout().contentsMargins()
135
+ grid_width = width - margins.left() - margins.right()
136
+ return self.grid.content_height_for_width(grid_width) + margins.top() + margins.bottom()
137
+
138
+ def restore_saved_geometry(self, value: str) -> bool:
139
+ """Put the window back where a previous session left it; return True only when Qt could read the saved geometry.
140
+
141
+ A restored window is marked as placed, so its first show keeps that place instead of centring it again. An empty
142
+ or non-ASCII value cannot come from a saved geometry, so it is refused before Qt reads anything.
143
+ """
144
+ if not value or not value.isascii():
145
+ return False
146
+ restored = self.restoreGeometry(QByteArray.fromBase64(value.encode("ascii")))
147
+ if restored:
148
+ mark_window_placed(self)
149
+ return restored
150
+
151
+ def closeEvent(self, event: QCloseEvent) -> None:
152
+ """Hand the current geometry to the owner before closing, with the same text encoding as the other saved window states."""
153
+ self.geometry_saved.emit(bytes(self.saveGeometry().toBase64()).decode("ascii"))
154
+ event.accept()
@@ -0,0 +1,131 @@
1
+ """Produce colour icons for file menu actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING, assert_never
7
+
8
+ from PySide6.QtCore import QPointF, QRectF, Qt
9
+ from PySide6.QtGui import QIcon, QPainter, QPixmap, QPixmapCache, QTextLayout
10
+ from PySide6.QtWidgets import QStyle
11
+
12
+ from gitpane.config import EMOJI_GRID_DISABLED_OPACITY
13
+ from gitpane.ui.emoji.emoji_font import emoji_capable_font
14
+ from gitpane.ui.look.icon_density import physical_icon_side
15
+ from gitpane.worktree.file_menu_action import FileAction
16
+ from gitpane.worktree.ignore_rule import IgnoreDestination
17
+
18
+ if TYPE_CHECKING:
19
+ from PySide6.QtGui import QFont
20
+ from PySide6.QtWidgets import QMenu
21
+
22
+ SYMBOL_MARGIN = 1.0
23
+ PIXMAP_CACHE_PREFIX = "gitpane-file-action:"
24
+
25
+
26
+ class FileActionSymbol(Enum):
27
+ """Keep each file action's complete emoji sequence in one UI definition."""
28
+
29
+ STAGE = "📥"
30
+ RESET = "↩️"
31
+ DELETE = "🗑️"
32
+ COPY = "📋"
33
+ FOLDER = "📂"
34
+ TEXT_EDITOR = "📝"
35
+ HISTORY = "📜"
36
+ COMPARE = "⚖️"
37
+ SAVE_AS = "💾"
38
+ IGNORE = "🙈"
39
+ STOP_TRACKING = "🔓"
40
+ HISTORY_REMOVE = "🧹"
41
+
42
+
43
+ def file_action_symbol(action: FileAction | IgnoreDestination) -> FileActionSymbol:
44
+ """Choose a symbol by domain identity without interpreting the displayed label."""
45
+ match action:
46
+ case FileAction.STAGE:
47
+ return FileActionSymbol.STAGE
48
+ case FileAction.RESET | FileAction.RESET_TO:
49
+ return FileActionSymbol.RESET
50
+ case FileAction.DELETE:
51
+ return FileActionSymbol.DELETE
52
+ case FileAction.COPY_PATHS | FileAction.COPY_BASENAME | FileAction.COPY_ABSOLUTE:
53
+ return FileActionSymbol.COPY
54
+ case FileAction.FOLDER:
55
+ return FileActionSymbol.FOLDER
56
+ case FileAction.TEXT_EDITOR:
57
+ return FileActionSymbol.TEXT_EDITOR
58
+ case FileAction.HISTORY | FileAction.DISPLAY_HISTORY:
59
+ return FileActionSymbol.HISTORY
60
+ case FileAction.COMPARE:
61
+ return FileActionSymbol.COMPARE
62
+ case FileAction.SAVE_AS:
63
+ return FileActionSymbol.SAVE_AS
64
+ case FileAction.STOP_TRACKING:
65
+ return FileActionSymbol.STOP_TRACKING
66
+ case FileAction.REMOVE_FROM_HISTORY:
67
+ return FileActionSymbol.HISTORY_REMOVE
68
+ case IgnoreDestination.WORKTREE | IgnoreDestination.EXCLUDE:
69
+ return FileActionSymbol.IGNORE
70
+ assert_never(action)
71
+
72
+
73
+ def file_action_icon(action: FileAction | IgnoreDestination, menu: QMenu) -> QIcon:
74
+ """Render both action states at the menu's font size and screen density using Qt's bounded cache."""
75
+ symbol = file_action_symbol(action)
76
+ font = emoji_capable_font()
77
+ menu_font = menu.font()
78
+ if menu_font.pixelSize() > 0:
79
+ font.setPixelSize(menu_font.pixelSize())
80
+ else:
81
+ font.setPointSizeF(menu_font.pointSizeF())
82
+ side = max(menu.fontMetrics().height(), menu.style().pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, None, menu))
83
+ density = menu.devicePixelRatioF()
84
+ cache_prefix = PIXMAP_CACHE_PREFIX + repr((symbol.value, font.toString(), menu_font.toString(), side, density))
85
+ icon = QIcon()
86
+ normal = None
87
+ for mode in (QIcon.Mode.Normal, QIcon.Mode.Disabled):
88
+ cache_key = cache_prefix + mode.name
89
+ pixmap = QPixmapCache.find(cache_key)
90
+ if pixmap is None:
91
+ physical_side = physical_icon_side(side, density)
92
+ pixmap = QPixmap(physical_side, physical_side)
93
+ pixmap.setDevicePixelRatio(density)
94
+ pixmap.fill(Qt.GlobalColor.transparent)
95
+ painter = QPainter(pixmap)
96
+ if mode == QIcon.Mode.Normal:
97
+ _paint_symbol(painter, symbol, font, QRectF(0, 0, side, side))
98
+ else:
99
+ painter.setOpacity(EMOJI_GRID_DISABLED_OPACITY)
100
+ painter.drawPixmap(QPointF(0, 0), normal)
101
+ painter.end()
102
+ QPixmapCache.insert(cache_key, pixmap)
103
+ if mode == QIcon.Mode.Normal:
104
+ normal = pixmap
105
+ icon.addPixmap(pixmap, mode)
106
+ return icon
107
+
108
+
109
+ def _paint_symbol(painter: QPainter, symbol: FileActionSymbol, font: QFont, target: QRectF) -> None:
110
+ """Fit a complete shaped emoji into a transparent margin without replacing its colour glyphs."""
111
+ layout = QTextLayout(symbol.value, font)
112
+ layout.beginLayout()
113
+ layout.createLine()
114
+ layout.endLayout()
115
+ runs = layout.glyphRuns()
116
+ bounds = QRectF()
117
+ for run in runs:
118
+ bounds = bounds.united(run.boundingRect())
119
+ if bounds.isEmpty():
120
+ return
121
+ # Centred drawText uses line metrics that can clip colour emoji; fit the shaped glyph bounds instead.
122
+ inset = target.adjusted(SYMBOL_MARGIN, SYMBOL_MARGIN, -SYMBOL_MARGIN, -SYMBOL_MARGIN)
123
+ scale = min(inset.width() / bounds.width(), inset.height() / bounds.height())
124
+ painter.save()
125
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
126
+ painter.translate(inset.center())
127
+ painter.scale(scale, scale)
128
+ painter.translate(-bounds.center())
129
+ for run in runs:
130
+ painter.drawGlyphRun(QPointF(0, 0), run)
131
+ painter.restore()
@@ -0,0 +1,182 @@
1
+ """Return a copy of OpenType font bytes whose `name` table gives the font another family name, every other table left as it is.
2
+
3
+ The new name table is appended at the end of the copy and the table directory points to it; the old one stays in place,
4
+ unreferenced. The table checksum and the whole-font checksum adjustment of the `head` table are computed again, so the
5
+ copy stays a valid font for any font engine. Malformed bytes raise ValueError and nothing is written anywhere: the copy
6
+ lives in memory only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ import sys
13
+ from array import array
14
+
15
+ # Versions a single (not collection) OpenType font starts with: TrueType outlines, the Apple "true" tag, CFF outlines.
16
+ SINGLE_FONT_SFNT_VERSION_SET = {b"\x00\x01\x00\x00", b"true", b"OTTO"}
17
+ OFFSET_TABLE_FORMAT = ">4sHHHH"
18
+ TABLE_RECORD_FORMAT = ">4sIII"
19
+ NAME_HEADER_FORMAT = ">HHH"
20
+ NAME_RECORD_FORMAT = ">HHHHHH"
21
+ OFFSET_TABLE_SIZE = struct.calcsize(OFFSET_TABLE_FORMAT)
22
+ TABLE_RECORD_SIZE = struct.calcsize(TABLE_RECORD_FORMAT)
23
+ NAME_HEADER_SIZE = struct.calcsize(NAME_HEADER_FORMAT)
24
+ NAME_RECORD_SIZE = struct.calcsize(NAME_RECORD_FORMAT)
25
+ NAME_TABLE_TAG = b"name"
26
+ HEAD_TABLE_TAG = b"head"
27
+ # Name table format 1 adds language tag records after the name records; both keep their strings in the same storage.
28
+ SUPPORTED_NAME_FORMAT_SET = {0, 1}
29
+ # Byte offsets of the fields patched in a table record and in a name record.
30
+ TABLE_RECORD_CHECKSUM_OFFSET = 4
31
+ NAME_RECORD_LENGTH_OFFSET = 8
32
+ # In the head table, the field that makes the whole font sum to the magic number below.
33
+ HEAD_CHECKSUM_ADJUSTMENT_OFFSET = 8
34
+ HEAD_CHECKSUM_ADJUSTMENT_FORMAT = ">I"
35
+ CHECKSUM_MAGIC = 0xB1B0AFBA
36
+ UINT32_MODULO = 1 << 32
37
+ UINT16_LIMIT = 1 << 16
38
+ TABLE_ALIGNMENT = 4
39
+ # Name ids that carry the family: family (1), unique id (3), full name (4), typographic family (16), WWS family (21).
40
+ # Fontconfig and DirectWrite read the family from several of them, so all are renamed, never one alone.
41
+ FAMILY_NAME_ID_SET = {1, 3, 4, 16, 21}
42
+ POSTSCRIPT_NAME_ID = 6
43
+ # Unicode (0) and Windows (3) strings are UTF-16 big endian; Macintosh (1) strings are in a Mac script encoding, which
44
+ # matches ASCII for the ASCII names written here.
45
+ UNICODE_PLATFORM_ID = 0
46
+ MACINTOSH_PLATFORM_ID = 1
47
+ WINDOWS_PLATFORM_ID = 3
48
+ TEXT_ENCODING_BY_PLATFORM_ID = {UNICODE_PLATFORM_ID: "utf-16-be", MACINTOSH_PLATFORM_ID: "ascii", WINDOWS_PLATFORM_ID: "utf-16-be"}
49
+ # array("I") holds machine words; the font words are big endian.
50
+ UINT32_TYPECODE = "I"
51
+ UINT32_SIZE = 4
52
+
53
+
54
+ def postscript_name(family: str) -> str:
55
+ """Return the PostScript name of a family: the family without spaces, the form the PostScript name id expects."""
56
+ return family.replace(" ", "")
57
+
58
+
59
+ def font_with_family_name(content: bytes, family: str) -> bytes:
60
+ """Return a copy of the font bytes whose name table names `family`, the original bytes left untouched.
61
+
62
+ Every family record (family, unique id, full name, typographic and WWS family) takes `family` and the PostScript
63
+ record takes it without spaces; other records keep their strings. `family` must be ASCII so the Macintosh records
64
+ can hold it. A font collection, a missing table, a record outside its table or an unknown platform raises ValueError.
65
+ """
66
+ if not family.isascii() or family == "":
67
+ raise ValueError("The new family name must be non-empty ASCII")
68
+ try:
69
+ name_record_offset, name_offset, name_length = name_table_location(content)
70
+ new_name_table = renamed_name_table(content[name_offset:name_offset + name_length], family)
71
+ copy = bytearray(content)
72
+ copy.extend(bytes(padding_length(len(copy))))
73
+ new_name_offset = len(copy)
74
+ copy.extend(new_name_table)
75
+ copy.extend(bytes(padding_length(len(copy))))
76
+ struct.pack_into(">III", copy, name_record_offset + TABLE_RECORD_CHECKSUM_OFFSET, table_checksum(new_name_table), new_name_offset, len(new_name_table))
77
+ set_checksum_adjustment(copy)
78
+ except struct.error as error:
79
+ raise ValueError("The font is truncated: " + str(error)) from error
80
+ return bytes(copy)
81
+
82
+
83
+ def table_records(content: bytes | bytearray) -> list[tuple[bytes, int, int, int]]:
84
+ """Return each table record of a single font as (tag, record offset, table offset, table length), checked to lie in the bytes."""
85
+ sfnt_version, table_count, _search_range, _entry_selector, _range_shift = struct.unpack_from(OFFSET_TABLE_FORMAT, content, 0)
86
+ if sfnt_version not in SINGLE_FONT_SFNT_VERSION_SET:
87
+ raise ValueError("Not a single OpenType font (version " + sfnt_version.hex() + ")")
88
+ records = []
89
+ for index in range(table_count):
90
+ record_offset = OFFSET_TABLE_SIZE + index * TABLE_RECORD_SIZE
91
+ tag, _checksum, table_offset, table_length = struct.unpack_from(TABLE_RECORD_FORMAT, content, record_offset)
92
+ if table_offset + table_length > len(content):
93
+ raise ValueError("The table " + ascii(tag) + " lies past the end of the font")
94
+ records.append((tag, record_offset, table_offset, table_length))
95
+ return records
96
+
97
+
98
+ def name_table_location(content: bytes) -> tuple[int, int, int]:
99
+ """Return the offset of the name table record, then the offset and length of the name table itself."""
100
+ for tag, record_offset, table_offset, table_length in table_records(content):
101
+ if tag == NAME_TABLE_TAG:
102
+ return record_offset, table_offset, table_length
103
+ raise ValueError("The font has no name table")
104
+
105
+
106
+ def renamed_name_table(name_table: bytes, family: str) -> bytes:
107
+ """Return the name table with its family and PostScript records pointing to new strings appended to its storage.
108
+
109
+ The header, the records left alone, the language tags and the old strings keep their bytes and their offsets.
110
+ """
111
+ name_format, record_count, storage_offset = struct.unpack_from(NAME_HEADER_FORMAT, name_table, 0)
112
+ if name_format not in SUPPORTED_NAME_FORMAT_SET:
113
+ raise ValueError("Unknown name table format " + str(name_format))
114
+ if storage_offset > len(name_table):
115
+ raise ValueError("The name strings lie past the end of the name table")
116
+ table = bytearray(name_table)
117
+ for index in range(record_count):
118
+ record_offset = NAME_HEADER_SIZE + index * NAME_RECORD_SIZE
119
+ platform_id, _encoding_id, _language_id, name_id, string_length, string_offset = struct.unpack_from(NAME_RECORD_FORMAT, name_table, record_offset)
120
+ if storage_offset + string_offset + string_length > len(name_table):
121
+ raise ValueError("The name record " + str(name_id) + " lies past the end of the name table")
122
+ text = new_name_text(name_id, family)
123
+ if text is None:
124
+ continue
125
+ encoding = TEXT_ENCODING_BY_PLATFORM_ID.get(platform_id)
126
+ if encoding is None:
127
+ raise ValueError("Unknown platform " + str(platform_id) + " in the name record " + str(name_id))
128
+ encoded = text.encode(encoding)
129
+ new_string_offset = len(table) - storage_offset
130
+ if new_string_offset + len(encoded) >= UINT16_LIMIT:
131
+ raise ValueError("The renamed name table would be too large")
132
+ table.extend(encoded)
133
+ struct.pack_into(">HH", table, record_offset + NAME_RECORD_LENGTH_OFFSET, len(encoded), new_string_offset)
134
+ return bytes(table)
135
+
136
+
137
+ def new_name_text(name_id: int, family: str) -> str | None:
138
+ """Return the new string of a name record, or None when the record keeps its own."""
139
+ if name_id in FAMILY_NAME_ID_SET:
140
+ return family
141
+ if name_id == POSTSCRIPT_NAME_ID:
142
+ return postscript_name(family)
143
+ return None
144
+
145
+
146
+ def padding_length(length: int) -> int:
147
+ """Return how many zero bytes bring a length up to the next table alignment."""
148
+ return -length % TABLE_ALIGNMENT
149
+
150
+
151
+ def word_sum(data: bytes | bytearray) -> int:
152
+ """Return the sum, modulo 2**32, of the big endian 32 bit words of the data, padded with zeros to a whole word."""
153
+ words = array(UINT32_TYPECODE)
154
+ if words.itemsize != UINT32_SIZE:
155
+ raise ValueError("This Python has no 32 bit unsigned array")
156
+ padding = padding_length(len(data))
157
+ if padding:
158
+ data = bytes(data) + bytes(padding)
159
+ words.frombytes(data)
160
+ if sys.byteorder == "little":
161
+ words.byteswap()
162
+ return sum(words) % UINT32_MODULO
163
+
164
+
165
+ def table_checksum(table: bytes) -> int:
166
+ """Return the OpenType checksum of a table."""
167
+ return word_sum(table)
168
+
169
+
170
+ def set_checksum_adjustment(font: bytearray) -> None:
171
+ """Write the head table's checksum adjustment so the whole font sums to the OpenType magic number.
172
+
173
+ The adjustment counts as zero in the sum, which is also why the head table's own checksum does not change.
174
+ """
175
+ for tag, _record_offset, table_offset, _table_length in table_records(font):
176
+ if tag != HEAD_TABLE_TAG:
177
+ continue
178
+ adjustment_offset = table_offset + HEAD_CHECKSUM_ADJUSTMENT_OFFSET
179
+ struct.pack_into(HEAD_CHECKSUM_ADJUSTMENT_FORMAT, font, adjustment_offset, 0)
180
+ struct.pack_into(HEAD_CHECKSUM_ADJUSTMENT_FORMAT, font, adjustment_offset, (CHECKSUM_MAGIC - word_sum(font)) % UINT32_MODULO)
181
+ return
182
+ raise ValueError("The font has no head table")
File without changes
@@ -0,0 +1,78 @@
1
+ """Present complete history rewrite details as selectable plain text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtWidgets import QApplication, QDialog, QPlainTextEdit, QPushButton, QVBoxLayout
9
+
10
+ from gitpane.config import fit_window
11
+ from gitpane.ui.history_rewrite.preview_model import CommitColumn, ModelText, author_date, safe_text
12
+ from gitpane.ui.widget.dialog_layout import install_banded_layout
13
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
14
+
15
+ if TYPE_CHECKING:
16
+ from PySide6.QtWidgets import QWidget
17
+
18
+ from gitpane.history_rewrite.value import RewriteRow
19
+
20
+ DETAIL_WIDTH, DETAIL_HEIGHT = 720, 480
21
+ DETAIL_MINIMUM_WIDTH, DETAIL_MINIMUM_HEIGHT = 320, 240
22
+
23
+
24
+ class DetailsText(Enum):
25
+ """Name the complete-text window and its actions."""
26
+
27
+ TITLE = "History rewrite details"
28
+ COPY = "Copy details"
29
+ CLOSE = "Close"
30
+ INCOMPLETE = "The complete commit message is unavailable. Recompute the preview before confirming."
31
+
32
+
33
+ def commit_details(row: RewriteRow, message_filename: str = "") -> str:
34
+ """Read only the chosen message from its private spool, never from Git."""
35
+ message = row.message
36
+ if row.message_offset is not None:
37
+ if not message_filename:
38
+ raise ValueError(DetailsText.INCOMPLETE.value)
39
+ with open(message_filename, "rb") as stream:
40
+ stream.seek(row.message_offset)
41
+ message = stream.read(row.message_length)
42
+ if len(message) != row.message_length:
43
+ raise ValueError(DetailsText.INCOMPLETE.value)
44
+ lines = [f"{CommitColumn.COMMIT.value}: {row.oid}", f"{CommitColumn.AUTHOR.value}: {safe_text(row.author)}",
45
+ f"{CommitColumn.DATE.value}: {author_date(row.date)}", f"{CommitColumn.MESSAGE.value}:", safe_text(message, multiline=True) if message else ModelText.NO_MESSAGE.value]
46
+ return "\n".join(lines)
47
+
48
+
49
+ class HistoryRewriteDetails(QDialog):
50
+ """Offer a complete, copyable detail with no repository operation."""
51
+
52
+ def __init__(self, text: str, parent: QWidget | None = None) -> None:
53
+ """Build a native read-only text area with explicit copy and close actions."""
54
+ super().__init__(parent)
55
+ self.setWindowTitle(DetailsText.TITLE.value)
56
+ self.resize(DETAIL_WIDTH, DETAIL_HEIGHT)
57
+ self.text = QPlainTextEdit()
58
+ self.text.setReadOnly(True)
59
+ self.text.setPlainText(text)
60
+ self.text.setAccessibleName(DetailsText.TITLE.value)
61
+ self.copy_button = QPushButton(DetailsText.COPY.value)
62
+ self.close_button = QPushButton(DetailsText.CLOSE.value)
63
+ self.copy_button.setAutoDefault(False)
64
+ self.close_button.setAutoDefault(False)
65
+ self.copy_button.clicked.connect(self.copy_details)
66
+ self.close_button.clicked.connect(self.reject)
67
+ body = QVBoxLayout()
68
+ body.addWidget(self.text)
69
+ self.button_band = install_banded_layout(self, body, [self.copy_button, self.close_button])
70
+ fit_window(self, DETAIL_MINIMUM_WIDTH, DETAIL_MINIMUM_HEIGHT)
71
+
72
+ def copy_details(self) -> None:
73
+ """Copy the exact visible text, including escaped raw bytes."""
74
+ QApplication.clipboard().setText(self.text.toPlainText())
75
+
76
+ def show_modal(self) -> None:
77
+ """Open through the common native modality policy."""
78
+ open_window_modal(self)