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,75 @@
1
+ """Give each system its own keys for the shortcuts GitPane offers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from PySide6.QtCore import Qt
8
+
9
+ from gitpane.config import MACOS_PLATFORM
10
+
11
+ # On macOS Cmd+R (Ctrl in Qt's portable notation, where Ctrl stands for the Command key) comes first so the menu shows
12
+ # it, since a menu shows only the primary shortcut of an action; F5 stays. Windows and Linux keep F5 alone.
13
+ REFRESH_SHORTCUTS_BY_PLATFORM: dict[str, tuple[str, ...]] = {MACOS_PLATFORM: ("Ctrl+R", "F5")}
14
+ DEFAULT_REFRESH_SHORTCUTS: tuple[str, ...] = ("F5",)
15
+
16
+
17
+ def refresh_shortcuts(platform: str = sys.platform) -> tuple[str, ...]:
18
+ """Return the portable sequences that refresh a repository window on the given system, the primary one first.
19
+
20
+ A system missing from the table keeps F5 alone rather than another system's keys.
21
+ """
22
+ return REFRESH_SHORTCUTS_BY_PLATFORM.get(platform, DEFAULT_REFRESH_SHORTCUTS)
23
+
24
+
25
+ # The delete key of a Mac keyboard sends Backspace, and the Finder moves a file to the trash with Cmd+Backspace (Ctrl in
26
+ # Qt, where Ctrl stands for the Command key); Delete, sent by fn+delete or a PC keyboard, keeps working. On Windows and
27
+ # Linux Backspace erases a character and Ctrl+Backspace a word, so only Delete trashes there.
28
+ DEFAULT_TRASH_KEY_SET: frozenset[tuple[Qt.Key, Qt.KeyboardModifier]] = frozenset({(Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier)})
29
+ TRASH_KEY_SET_BY_PLATFORM: dict[str, frozenset[tuple[Qt.Key, Qt.KeyboardModifier]]] = {
30
+ MACOS_PLATFORM: DEFAULT_TRASH_KEY_SET | {(Qt.Key.Key_Backspace, Qt.KeyboardModifier.NoModifier), (Qt.Key.Key_Backspace, Qt.KeyboardModifier.ControlModifier)},
31
+ }
32
+ # macOS may report the Delete key of a PC keyboard with the keypad modifier; it is the same key for the user.
33
+ KEYPAD_MODIFIER_IGNORED_PLATFORM_SET = frozenset({MACOS_PLATFORM})
34
+
35
+
36
+ def trash_key_set(platform: str = sys.platform) -> frozenset[tuple[Qt.Key, Qt.KeyboardModifier]]:
37
+ """Return the keys, each with its exact modifiers, that move the selected files to the trash on the given system.
38
+
39
+ A system missing from the table trashes with Delete alone rather than with another system's keys.
40
+ """
41
+ return TRASH_KEY_SET_BY_PLATFORM.get(platform, DEFAULT_TRASH_KEY_SET)
42
+
43
+
44
+ def trash_key_pressed(key: int, modifiers: Qt.KeyboardModifier, platform: str = sys.platform) -> bool:
45
+ """Tell whether a pressed key and its modifiers ask for the trash on the given system."""
46
+ if platform in KEYPAD_MODIFIER_IGNORED_PLATFORM_SET:
47
+ modifiers = modifiers & ~Qt.KeyboardModifier.KeypadModifier
48
+ return (key, modifiers) in trash_key_set(platform)
49
+
50
+
51
+ FIND_NEXT_ACTION = "next"
52
+ FIND_PREVIOUS_ACTION = "previous"
53
+ DEFAULT_FIND_SHORTCUTS: tuple[tuple[str, str], ...] = (("F3", FIND_NEXT_ACTION), ("Shift+F3", FIND_PREVIOUS_ACTION))
54
+ # F3 needs Fn on a Mac laptop and Cmd+G / Cmd+Shift+G is how macOS finds the next and previous match (Ctrl in Qt's
55
+ # portable notation); F3 stays. Windows and Linux keep F3 and Shift+F3.
56
+ FIND_SHORTCUTS_BY_PLATFORM: dict[str, tuple[tuple[str, str], ...]] = {
57
+ MACOS_PLATFORM: (*DEFAULT_FIND_SHORTCUTS, ("Ctrl+G", FIND_NEXT_ACTION), ("Ctrl+Shift+G", FIND_PREVIOUS_ACTION)),
58
+ }
59
+
60
+
61
+ def find_shortcuts(platform: str = sys.platform) -> tuple[tuple[str, str], ...]:
62
+ """Return the portable sequences that find the next or previous match in a reading panel, each with its action name.
63
+
64
+ The diff and the commit message read this one table, so their keys cannot drift apart. A system missing from the
65
+ table keeps F3 and Shift+F3 rather than another system's keys.
66
+ """
67
+ return FIND_SHORTCUTS_BY_PLATFORM.get(platform, DEFAULT_FIND_SHORTCUTS)
68
+
69
+
70
+ # The keys of the rebase editor window: Ctrl+Return and Ctrl+Enter of the keypad press its validating button, Ctrl+S
71
+ # saves without answering Git. Written out because the standard keys come from the platform theme, and the offscreen
72
+ # platform the headless tests run on has none. Ctrl stands for the Command key on macOS in Qt's portable notation, so
73
+ # the same sequences give Cmd+Return and Cmd+S there without a table per system.
74
+ EDITOR_VALIDATE_SHORTCUTS: tuple[str, ...] = ("Ctrl+Return", "Ctrl+Enter")
75
+ EDITOR_SAVE_SHORTCUTS: tuple[str, ...] = ("Ctrl+S",)
@@ -0,0 +1,29 @@
1
+ """Turn the reasons of greyed buttons into visible text, since a disabled button's tooltip is easy to miss."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def joined_names(names: list[str]) -> str:
7
+ """Join button names the way a sentence lists them: "A", "A and B", "A, B and C"."""
8
+ if len(names) == 1:
9
+ return names[0]
10
+ *leading_names, last_name = names
11
+ return ", ".join(leading_names) + " and " + last_name
12
+
13
+
14
+ def unavailable_reason_text(reason_by_name: dict[str, str], shown_text: str = "") -> str:
15
+ """Name every unavailable button with its reason, one line per distinct reason, in the order the buttons are given.
16
+
17
+ Buttons that share a reason are named together so the reason is written once, and a reason already present in
18
+ ``shown_text`` (the text displayed next to it) is not repeated.
19
+ """
20
+ names_by_reason: dict[str, list[str]] = {}
21
+ for name, reason in reason_by_name.items():
22
+ if not reason or reason in shown_text:
23
+ continue
24
+ names_by_reason.setdefault(reason, []).append(name)
25
+ lines: list[str] = []
26
+ for reason, names in names_by_reason.items():
27
+ verb = " is" if len(names) == 1 else " are"
28
+ lines.append(joined_names(names) + verb + " unavailable: " + reason)
29
+ return "\n".join(lines)
@@ -0,0 +1,24 @@
1
+ """Offer a drop-down list that leaves the mouse wheel to the form around it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtWidgets import QComboBox
8
+
9
+ if TYPE_CHECKING:
10
+ from PySide6.QtGui import QWheelEvent
11
+ from PySide6.QtWidgets import QWidget
12
+
13
+
14
+ class UrlSelector(QComboBox):
15
+ """Require deliberate selection while letting wheel input scroll the containing form."""
16
+
17
+ def __init__(self, parent: QWidget | None = None) -> None:
18
+ """Keep long URLs from expanding the remote editor beyond its screen."""
19
+ super().__init__(parent)
20
+ self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
21
+
22
+ def wheelEvent(self, event: QWheelEvent) -> None:
23
+ """Ignore wheel input so navigation cannot change an edited URL accidentally."""
24
+ event.ignore()
File without changes
@@ -0,0 +1,42 @@
1
+ """Disable a button while its work runs without Qt handing its focus to the next field, then give it back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import Qt
8
+
9
+ if TYPE_CHECKING:
10
+ from PySide6.QtWidgets import QAbstractButton
11
+
12
+ # Set on a button whose focus was parked on its window while it was disabled, so only that button takes it back.
13
+ PARKED_FOCUS_PROPERTY = "gitpaneParkedFocus"
14
+
15
+
16
+ def set_button_enabled(button: QAbstractButton, enabled: bool) -> None:
17
+ """Enable or disable a button that may hold the focus of its window, leaving every other widget as it was.
18
+
19
+ Disabling the widget that holds the focus of its window makes Qt move that focus to the next widget of the tab
20
+ chain, as a Tab press would. A line edit reached that way selects all its text, so clicking a check button
21
+ selected a field the user never touched. Before the button is disabled its focus is parked on the window itself,
22
+ which takes no input and selects nothing; once enabled again, the button takes the focus back if it is still
23
+ parked there. A focus the user moved elsewhere meanwhile is left where the user put it.
24
+ """
25
+ if not enabled:
26
+ _disable_keeping_focus(button)
27
+ return
28
+ button.setEnabled(True)
29
+ parked = bool(button.property(PARKED_FOCUS_PROPERTY))
30
+ button.setProperty(PARKED_FOCUS_PROPERTY, False)
31
+ window = button.window()
32
+ if parked and window.focusWidget() is window and button.isVisible():
33
+ button.setFocus(Qt.FocusReason.OtherFocusReason)
34
+
35
+
36
+ def _disable_keeping_focus(button: QAbstractButton) -> None:
37
+ """Park the focus of the button on its window when the button holds it, then disable the button."""
38
+ window = button.window()
39
+ if window is not button and window.focusWidget() is button:
40
+ window.setFocus(Qt.FocusReason.OtherFocusReason)
41
+ button.setProperty(PARKED_FOCUS_PROPERTY, True)
42
+ button.setEnabled(False)
@@ -0,0 +1,152 @@
1
+ """Hold a size change asked just after a window is shown until the window system has answered that show.
2
+
3
+ Workaround: a compositor that places the windows itself (Mutter under Wayland) answers the first show of a window with a
4
+ configure that carries the activated state and the size it knew when it read the window's first commit, and Qt applies
5
+ that size. A size GitPane asks a few milliseconds after the show, before Qt has applied that configure, is overwritten by
6
+ it for good: it is not undone then applied again, as under X11. Such a size change therefore waits for the answer, which
7
+ is two facts, in either order: the activation delivered to the window (``WindowActivate``), and a configure Qt applied
8
+ while the window has the keyboard.
9
+
10
+ ``isActiveWindow()`` only tells that the compositor has given the window the keyboard: Qt Wayland reports it true as soon
11
+ as the keyboard arrives, which comes with the activated configure, before Qt applies that configure at the end of the
12
+ frame in progress. ``WindowActivate`` is no proof either: Qt delivers it after a round trip to the compositor, which can
13
+ end before the configure is applied. A configure applied reaches Python as a spontaneous ``WindowStateChange`` that Qt
14
+ Wayland sends the native window each time it applies one, whether the size changed or not, or as a spontaneous resize
15
+ when the size changed. The configure applied before the window has the keyboard answers the window's first commit, not
16
+ its activation, and does not count.
17
+
18
+ A window the compositor never activates (opened while the user is in another application), or a Qt that stops sending
19
+ that state change, still gets its size change once ``COMPOSITOR_ANSWER_TIMEOUT_MS`` has passed. Only the answer to the
20
+ last show is waited for: once it has come, a later change is made in the call, even while the window is inactive, until
21
+ the window is hidden and shown again. Where the window system has no such answer (X11, Windows, macOS), the change is
22
+ made in the call, as it always was.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ from PySide6.QtCore import QEvent, QObject, Qt, QTimer
30
+ from PySide6.QtWidgets import QApplication
31
+ from shiboken6 import isValid
32
+
33
+ from gitpane.config import COMPOSITOR_ANSWER_TIMEOUT_MS, window_placement
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable
37
+
38
+ from PySide6.QtGui import QWindow
39
+ from PySide6.QtWidgets import QWidget
40
+
41
+ # Name of the object that follows, for one window, whether the compositor has answered its last show.
42
+ ANSWER_WATCH_OBJECT_NAME = "gitpane_compositor_answer_watch"
43
+
44
+
45
+ class _CompositorAnswerWatch(QObject):
46
+ """Follow whether the compositor has answered the window's last show, and keep the size changes asked before that answer.
47
+
48
+ It is a child of the window, so it and its bounding timer go with the window: a window deleted before the answer
49
+ never runs its pending changes. It stays for the window's life, so a window shown again waits for the new answer.
50
+ It watches the window and its native window, where Qt reports each configure it applies. It reaches the window as
51
+ its parent and keeps no reference of its own to it, so no reference cycle holds the window longer than its owners.
52
+ """
53
+
54
+ def __init__(self, window: QWidget, answered: bool) -> None:
55
+ """Watch the window's shows and the compositor's answers; a window still waiting gets the bound started."""
56
+ super().__init__(window)
57
+ self.setObjectName(ANSWER_WATCH_OBJECT_NAME)
58
+ self.actions: list[Callable[[], None]] = []
59
+ self.answered = answered
60
+ self.activated = answered
61
+ self.configure_applied = answered
62
+ self.native_window: QWindow | None = None
63
+ self.bound_timer = QTimer(self)
64
+ self.bound_timer.setSingleShot(True)
65
+ self.bound_timer.setInterval(COMPOSITOR_ANSWER_TIMEOUT_MS)
66
+ self.bound_timer.timeout.connect(self.make_pending_changes)
67
+ if not answered:
68
+ self.bound_timer.start()
69
+ window.installEventFilter(self)
70
+ self.follow_native_window()
71
+
72
+ def follow_native_window(self) -> None:
73
+ """Watch the window's current native window, where Qt reports the configures it applies; nothing while it has none."""
74
+ native_window = self.parent().windowHandle()
75
+ if native_window is self.native_window:
76
+ return
77
+ if self.native_window is not None and isValid(self.native_window):
78
+ self.native_window.removeEventFilter(self)
79
+ self.native_window = native_window
80
+ if native_window is not None:
81
+ native_window.installEventFilter(self)
82
+
83
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
84
+ """Wait again at each show the application makes; note the activation and the configure applied that answer it.
85
+
86
+ A show the window system makes (a window restored from the dock) is not a new first show, and is not waited for.
87
+ A state change or a resize the application makes itself is no configure of the compositor.
88
+ """
89
+ event_type = event.type()
90
+ if watched is self.parent():
91
+ if event_type == QEvent.Type.Show and not event.spontaneous():
92
+ self.wait_for_new_answer()
93
+ elif event_type == QEvent.Type.WindowActivate:
94
+ self.activated = True
95
+ self.make_changes_if_answered()
96
+ elif event_type == QEvent.Type.Resize and event.spontaneous():
97
+ self.note_configure_applied()
98
+ elif event_type == QEvent.Type.WindowStateChange and event.spontaneous():
99
+ self.note_configure_applied()
100
+ return False
101
+
102
+ def wait_for_new_answer(self) -> None:
103
+ """Forget the last answer and start the bound again, for a show the compositor will answer anew."""
104
+ self.answered = False
105
+ self.activated = False
106
+ self.configure_applied = False
107
+ self.bound_timer.start()
108
+ self.follow_native_window()
109
+
110
+ def note_configure_applied(self) -> None:
111
+ """Count a configure Qt applied only while the window has the keyboard: the one before answers the first commit."""
112
+ if not self.parent().isActiveWindow():
113
+ return
114
+ self.configure_applied = True
115
+ self.make_changes_if_answered()
116
+
117
+ def make_changes_if_answered(self) -> None:
118
+ """Make the pending changes once both the activation and a configure applied with the keyboard have come."""
119
+ if self.activated and self.configure_applied:
120
+ self.make_pending_changes()
121
+
122
+ def make_pending_changes(self) -> None:
123
+ """Note that the compositor has answered, then make every pending change once, in the order asked."""
124
+ if self.answered:
125
+ return
126
+ self.answered = True
127
+ self.bound_timer.stop()
128
+ actions = self.actions
129
+ self.actions = []
130
+ for action in actions:
131
+ action()
132
+
133
+
134
+ def after_compositor_answer(window: QWidget, action: Callable[[], None], *, platform_name: str = "") -> None:
135
+ """Make a size change of a window now, or once the compositor has answered the window's show where that answer would overwrite it.
136
+
137
+ The change is made in the call when the platform's placement row does not wait, when the window is hidden (there is
138
+ no show to answer), or when the compositor already answered its last show. A window first seen here is taken as
139
+ answered only when it is already the application's active window, which Qt makes it once the activation is
140
+ delivered; otherwise it is taken as just shown, and only the events it then receives say the answer came.
141
+ ``platform_name`` is Qt's platform name, read from Qt when not given.
142
+ """
143
+ if not window_placement(platform_name).size_waits_for_compositor_answer or not window.isVisible():
144
+ action()
145
+ return
146
+ watch = window.findChild(QObject, ANSWER_WATCH_OBJECT_NAME, Qt.FindChildOption.FindDirectChildrenOnly)
147
+ if not isinstance(watch, _CompositorAnswerWatch):
148
+ watch = _CompositorAnswerWatch(window, QApplication.activeWindow() is window)
149
+ if watch.answered:
150
+ action()
151
+ return
152
+ watch.actions.append(action)
@@ -0,0 +1,73 @@
1
+ """Bound the frame of a window that grows below the top the compositor placed it at.
2
+
3
+ Under Wayland the compositor centres a window when it opens, then can keep its top when the window grows, and GitPane
4
+ cannot move the window back up there. Qt sees neither the desktop's panels (its usable area is the whole screen) nor a
5
+ title bar the compositor draws itself (the window reports no frame margin). Elsewhere GitPane moves a growing window back
6
+ up itself, and only the estimated usable height counts there.
7
+
8
+ Margin of the estimates: a frame of height h0 centred in the usable height A has its top at (A - h0) / 2, and grown
9
+ keeping that top it stays inside A as long as it is no taller than (A + h0) / 2. Centring halves an error on A or on the
10
+ title bar, so the frame stays above the real panels as long as the real panels and the real title bar together take at
11
+ most both estimates plus twice ``SCREEN_MARGIN``, whatever h0.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from gitpane.config import SCREEN_MARGIN
17
+
18
+ UNSEEN_PANEL_HEIGHT_ESTIMATE = 48
19
+ """Height taken off a usable area that Qt counts as the whole screen, for the panels it does not see there.
20
+
21
+ Qt reports the whole screen as usable when it does not know the desktop's panels, as under Wayland; the default Plasma
22
+ panel reserves 46 px.
23
+ """
24
+
25
+ UNSEEN_TITLE_BAR_HEIGHT_ESTIMATE = 32
26
+ """Height counted for a decorated window's title bar when Qt knows no margin of its frame (the compositor draws it).
27
+
28
+ The Breeze title bar KWin draws is 28 px with the default title font; the rest leaves room for a slightly larger font.
29
+ """
30
+
31
+ CENTRED_SIDE_COUNT = 2
32
+ """Sides a centred window leaves equal room on: the room left by its frame is split between them."""
33
+
34
+
35
+ def usable_height_estimate(qt_usable_height: int, qt_screen_height: int) -> int:
36
+ """Return the height a window can use on its screen: Qt's usable height, less an unseen panel when Qt counts the whole screen.
37
+
38
+ A usable height smaller than the screen means Qt already took the panels off (X11, Windows, macOS), so it is kept as
39
+ it is. Qt cannot tell a screen without panels from panels it does not know, so both lose the estimate.
40
+ """
41
+ if qt_usable_height < qt_screen_height:
42
+ return qt_usable_height
43
+ return qt_usable_height - UNSEEN_PANEL_HEIGHT_ESTIMATE
44
+
45
+
46
+ def decoration_height_estimate(known_decoration_height: int, decorated: bool) -> int:
47
+ """Return the height a window's title bar and borders take: what Qt knows, nothing for a frameless window, otherwise the estimated title bar."""
48
+ if known_decoration_height > 0:
49
+ return known_decoration_height
50
+ if not decorated:
51
+ return 0
52
+ return UNSEEN_TITLE_BAR_HEIGHT_ESTIMATE
53
+
54
+
55
+ def kept_top_frame_cap(usable_height: int, placed_frame_height: int) -> int:
56
+ """Return the tallest frame that, grown below the top it had when centred at ``placed_frame_height``, still ends ``SCREEN_MARGIN`` above the usable height's bottom.
57
+
58
+ A window placed with a frame taller than the usable height less both margins gets a cap no taller than that frame,
59
+ so it does not grow.
60
+ """
61
+ return (usable_height + placed_frame_height) // CENTRED_SIDE_COUNT - SCREEN_MARGIN
62
+
63
+
64
+ def growth_frame_cap(usable_height: int, share: float, placed_frame_height: int | None) -> int:
65
+ """Return the tallest frame a growing window reaches: ``share`` of the usable height, and no more than the room under its kept top when it has one.
66
+
67
+ ``placed_frame_height`` is the frame the window had when the compositor placed it, given only where the compositor
68
+ keeps that top; ``None`` where GitPane moves a growing window back up itself, so the share alone counts.
69
+ """
70
+ share_cap = int(usable_height * share)
71
+ if placed_frame_height is None:
72
+ return share_cap
73
+ return min(share_cap, kept_top_frame_cap(usable_height, placed_frame_height))
@@ -0,0 +1,203 @@
1
+ """Tell the desktop when the user last acted in GitPane, so a window opened for that action comes to the front.
2
+
3
+ What the desktop needs depends on the Qt platform, chosen in one table: under X11 a startup identifier carrying the
4
+ time of the user's last action, read from the window property Qt keeps for the window manager; under Wayland an
5
+ activation token the compositor hands out for that action; elsewhere nothing, the system deciding alone.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import ctypes.util
12
+ import functools
13
+ import logging
14
+ import os
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING
17
+
18
+ from PySide6.QtGui import QGuiApplication
19
+
20
+ from gitpane.config import WAYLAND_PLATFORM_NAME, X11_PLATFORM_NAME
21
+ from gitpane.system.startup_activation import StartupActivation, StartupHandoff
22
+ from gitpane.ui.window_control.wayland_activation import wayland_activation_token
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+ X11_LIBRARY_NAME = "X11"
28
+ STARTUP_ID_PREFIX = "gitpane-"
29
+ STARTUP_ID_TIME_SEPARATOR = "_TIME"
30
+ USER_TIME_ATOM_NAME = b"_NET_WM_USER_TIME"
31
+ USER_TIME_WINDOW_ATOM_NAME = b"_NET_WM_USER_TIME_WINDOW"
32
+ # Arguments of XInternAtom and XGetWindowProperty: read one 32-bit value of any type, never create an atom, never delete the property.
33
+ ONLY_IF_EXISTS = True
34
+ PROPERTY_OFFSET = 0
35
+ SINGLE_VALUE_LENGTH = 1
36
+ KEEP_PROPERTY = False
37
+ ANY_PROPERTY_TYPE = 0
38
+ X_SUCCESS = 0
39
+ VALUE_FORMAT_BITS = 32
40
+ FIRST_VALUE_INDEX = 0
41
+ # X server times are 32-bit milliseconds that wrap around; a time is later when it is less than half the range ahead.
42
+ X_TIME_RANGE = 1 << 32
43
+ X_TIME_HALF_RANGE = 1 << 31
44
+
45
+ # Xlib error handler signature: int handler(Display *, XErrorEvent *).
46
+ X_ERROR_HANDLER_TYPE = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
47
+
48
+
49
+ def _ignore_x_error(_display: int | None, _error: int | None) -> int:
50
+ """Let a window that closed during the read give no value instead of ending GitPane, as Xlib's default handler would."""
51
+ return 0
52
+
53
+
54
+ # Kept at module level: Xlib calls this pointer, so it must outlive every read.
55
+ IGNORE_X_ERROR_HANDLER = X_ERROR_HANDLER_TYPE(_ignore_x_error)
56
+
57
+
58
+ def startup_id_for_time(user_time: int | None) -> str:
59
+ """Build the startup identifier that hands an X server time to the program GitPane asks to show a window.
60
+
61
+ The window manager reads the time after the "_TIME" suffix and lets the new window come to the front when that
62
+ time is not older than the user's last action in the active window. The identifier holds only GitPane's process
63
+ number and that time, never a path or any text from the user. No time, or time zero (which means "do not
64
+ activate"), gives the empty identifier GitPane sent before.
65
+ """
66
+ if not user_time:
67
+ return ""
68
+ return f"{STARTUP_ID_PREFIX}{os.getpid()}{STARTUP_ID_TIME_SEPARATOR}{user_time}"
69
+
70
+
71
+ def desktop_startup_id() -> str:
72
+ """Return the X11 startup identifier for the user's last action in GitPane, or an empty one when no time is found.
73
+
74
+ Read on the GUI thread when the user asks for the action, before any worker starts, so the time is the one of
75
+ that request. Only the X11 row of the startup activation table calls it.
76
+ """
77
+ window_ids: list[int] = []
78
+ for window in QGuiApplication.topLevelWindows():
79
+ if window.isVisible():
80
+ window_ids.append(int(window.winId()))
81
+ if not window_ids:
82
+ return ""
83
+ try:
84
+ user_time = _latest_user_time(window_ids)
85
+ except (OSError, AttributeError, ctypes.ArgumentError) as error:
86
+ logging.getLogger(__name__).debug("Cannot read the time of the last user action: %s", error)
87
+ user_time = None
88
+ return startup_id_for_time(user_time)
89
+
90
+
91
+ # Workaround: Qt keeps the time of the user's last click or key press in a window property for the window manager but
92
+ # does not give it to Python. Without that time in the startup identifier, Cinnamon's focus stealing prevention keeps
93
+ # the file manager opened by the Show in folder entry behind GitPane. This reads the property through the X11 library that every
94
+ # X11 desktop already has, on a separate read-only connection: nothing is written to the server.
95
+ @functools.cache
96
+ def _x11_library() -> ctypes.CDLL:
97
+ """Load the X11 library once and declare the calls this module makes."""
98
+ library_name = ctypes.util.find_library(X11_LIBRARY_NAME)
99
+ if library_name is None:
100
+ raise OSError("The X11 library is not installed.")
101
+ library = ctypes.CDLL(library_name)
102
+ library.XOpenDisplay.argtypes = [ctypes.c_char_p]
103
+ library.XOpenDisplay.restype = ctypes.c_void_p
104
+ library.XCloseDisplay.argtypes = [ctypes.c_void_p]
105
+ library.XCloseDisplay.restype = ctypes.c_int
106
+ library.XSetErrorHandler.argtypes = [ctypes.c_void_p]
107
+ library.XSetErrorHandler.restype = ctypes.c_void_p
108
+ library.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
109
+ library.XInternAtom.restype = ctypes.c_ulong
110
+ library.XGetWindowProperty.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_long, ctypes.c_long, ctypes.c_int, ctypes.c_ulong,
111
+ ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_ulong),
112
+ ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.c_void_p)]
113
+ library.XGetWindowProperty.restype = ctypes.c_int
114
+ library.XFree.argtypes = [ctypes.c_void_p]
115
+ library.XFree.restype = ctypes.c_int
116
+ return library
117
+
118
+
119
+ def _latest_user_time(window_ids: list[int]) -> int | None:
120
+ """Return the latest user action time recorded on GitPane's visible windows, or None when none has one."""
121
+ library = _x11_library()
122
+ display = library.XOpenDisplay(None)
123
+ if not display:
124
+ raise OSError("The X server cannot be reached.")
125
+ previous_handler = library.XSetErrorHandler(ctypes.cast(IGNORE_X_ERROR_HANDLER, ctypes.c_void_p))
126
+ try:
127
+ user_time_atom = library.XInternAtom(display, USER_TIME_ATOM_NAME, ONLY_IF_EXISTS)
128
+ user_time_window_atom = library.XInternAtom(display, USER_TIME_WINDOW_ATOM_NAME, ONLY_IF_EXISTS)
129
+ if not user_time_atom:
130
+ return None
131
+ latest_time: int | None = None
132
+ for window_id in window_ids:
133
+ # Qt may keep the time on a small helper window that the main window names, as the desktop standard allows.
134
+ user_time_window_id = _window_property_value(library, display, window_id, user_time_window_atom) if user_time_window_atom else None
135
+ user_time = _window_property_value(library, display, user_time_window_id or window_id, user_time_atom)
136
+ if user_time and (latest_time is None or _is_later_x_time(user_time, latest_time)):
137
+ latest_time = user_time
138
+ return latest_time
139
+ finally:
140
+ library.XSetErrorHandler(previous_handler)
141
+ library.XCloseDisplay(display)
142
+
143
+
144
+ def _window_property_value(library: ctypes.CDLL, display: int, window_id: int, atom: int) -> int | None:
145
+ """Read the first 32-bit value of a window property, or None when the window or the property is gone."""
146
+ actual_type = ctypes.c_ulong()
147
+ actual_format = ctypes.c_int()
148
+ item_count = ctypes.c_ulong()
149
+ bytes_after = ctypes.c_ulong()
150
+ data = ctypes.c_void_p()
151
+ status = library.XGetWindowProperty(display, window_id, atom, PROPERTY_OFFSET, SINGLE_VALUE_LENGTH, KEEP_PROPERTY, ANY_PROPERTY_TYPE,
152
+ ctypes.byref(actual_type), ctypes.byref(actual_format), ctypes.byref(item_count), ctypes.byref(bytes_after), ctypes.byref(data))
153
+ try:
154
+ if status != X_SUCCESS or actual_format.value != VALUE_FORMAT_BITS or not item_count.value or not data.value:
155
+ return None
156
+ # Xlib hands 32-bit property values back as C longs.
157
+ return ctypes.cast(data, ctypes.POINTER(ctypes.c_ulong))[FIRST_VALUE_INDEX]
158
+ finally:
159
+ if data.value:
160
+ library.XFree(data)
161
+
162
+
163
+ def _is_later_x_time(candidate: int, reference: int) -> bool:
164
+ """Compare two X server times the way the window manager does, across the 32-bit wrap-around."""
165
+ return candidate != reference and (candidate - reference) % X_TIME_RANGE < X_TIME_HALF_RANGE
166
+
167
+
168
+ def _no_startup_value() -> str:
169
+ """Answer no startup value, on a Qt platform whose desktop takes none."""
170
+ return ""
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class StartupActivationSource:
175
+ """How a Qt platform's desktop learns that the user asked for a program: the handoff, and the reader of its value."""
176
+
177
+ handoff: StartupHandoff
178
+ read: Callable[[], str]
179
+
180
+
181
+ NO_STARTUP_ACTIVATION_SOURCE = StartupActivationSource(StartupHandoff.NONE, _no_startup_value)
182
+ # Where a platform is missing (Windows, macOS, the offscreen platform of the tests), the system decides alone whether a
183
+ # started program's window comes to the front.
184
+ STARTUP_ACTIVATION_SOURCE_BY_PLATFORM_NAME: dict[str, StartupActivationSource] = {
185
+ X11_PLATFORM_NAME: StartupActivationSource(StartupHandoff.X11_STARTUP_ID, desktop_startup_id),
186
+ WAYLAND_PLATFORM_NAME: StartupActivationSource(StartupHandoff.WAYLAND_ACTIVATION_TOKEN, wayland_activation_token),
187
+ }
188
+
189
+
190
+ def startup_activation_source(platform_name: str) -> StartupActivationSource:
191
+ """Return how a Qt platform, under the name Qt gives it, hands the user's action to a started program."""
192
+ return STARTUP_ACTIVATION_SOURCE_BY_PLATFORM_NAME.get(platform_name, NO_STARTUP_ACTIVATION_SOURCE)
193
+
194
+
195
+ def desktop_activation(platform_name: str = "") -> StartupActivation:
196
+ """Read the startup value of the user's click, with the way the started program receives it.
197
+
198
+ Called on the window's thread when the user asks for a program, before any worker starts, and never otherwise: a
199
+ Wayland token starts the compositor's startup notice. platform_name stands in for the session's platform, so a
200
+ headless test can check the row of any system.
201
+ """
202
+ source = startup_activation_source(platform_name or QGuiApplication.platformName())
203
+ return StartupActivation(source.handoff, source.read())