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,130 @@
1
+ """Load the photos of visible authors only, so painting a history row never waits on a photo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QEvent, QObject, QTimer
9
+ from PySide6.QtGui import QImage
10
+
11
+ from gitpane.config import SPACING_XS
12
+ from gitpane.history.avatar import AvatarService, normalize_author_email
13
+ from gitpane.ui.repository.history_model import AUTHOR_COLUMN, AUTHOR_IDENTITY_ROLE
14
+
15
+ if TYPE_CHECKING:
16
+ from PySide6.QtWidgets import QTableView
17
+
18
+
19
+ class HistoryAvatar(QObject):
20
+ """Bind visible author consumers to the shared photo cache and cancel them when they leave view."""
21
+
22
+ def __init__(self, view: QTableView) -> None:
23
+ """Coalesce viewport and model changes before scheduling optional network work."""
24
+ super().__init__(view)
25
+ self.view = view
26
+ self.service: AvatarService | None = None
27
+ self.image_by_email: dict[str, QImage] = {}
28
+ self.identity_by_email: dict[str, str] = {}
29
+ self.email_by_identity: dict[str, str] = {}
30
+ self.closed = False
31
+ self.size = (0, 0.0)
32
+ self.timer = QTimer(self)
33
+ self.timer.setSingleShot(True)
34
+ self.timer.timeout.connect(self.refresh)
35
+ view.viewport().installEventFilter(self)
36
+ view.verticalScrollBar().valueChanged.connect(self.schedule_refresh)
37
+ view.horizontalScrollBar().valueChanged.connect(self.schedule_refresh)
38
+ view.model().modelReset.connect(self.schedule_refresh)
39
+ view.model().rowsInserted.connect(self.schedule_refresh)
40
+
41
+ def set_service(self, service: AvatarService) -> None:
42
+ """Replace the photo provider without leaving subscribers on the previous service."""
43
+ if self.service is service:
44
+ return
45
+ self._clear_consumers()
46
+ if self.service is not None:
47
+ self.service.image_ready.disconnect(self._image_ready)
48
+ self.service = service
49
+ service.image_ready.connect(self._image_ready)
50
+ self.schedule_refresh()
51
+
52
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
53
+ """Adapt photo dimensions and visible authors when the native viewport changes."""
54
+ if event.type() in (QEvent.Type.Resize, QEvent.Type.Show, QEvent.Type.Hide, QEvent.Type.FontChange, QEvent.Type.DevicePixelRatioChange):
55
+ self.schedule_refresh()
56
+ return super().eventFilter(watched, event)
57
+
58
+ def schedule_refresh(self) -> None:
59
+ """Defer and combine repeated scroll or insertion notifications into one viewport scan."""
60
+ if not self.closed:
61
+ self.timer.start(0)
62
+
63
+ def refresh(self) -> None:
64
+ """Request each visible email once at the current row size and screen density."""
65
+ if self.closed or self.service is None:
66
+ return
67
+ view = self.view
68
+ if not view.isVisible() or not view.model().rowCount():
69
+ self._clear_consumers()
70
+ return
71
+ side = max(1, view.verticalHeader().defaultSectionSize() - SPACING_XS)
72
+ ratio = view.devicePixelRatioF()
73
+ size = (side, ratio)
74
+ if size != self.size:
75
+ self._clear_consumers()
76
+ self.size = size
77
+ first_row = max(0, view.rowAt(0))
78
+ last_row = view.rowAt(view.viewport().height() - 1)
79
+ if last_row < 0:
80
+ visible_row_count = view.viewport().height() // view.verticalHeader().defaultSectionSize() + 1
81
+ last_row = min(view.model().rowCount() - 1, first_row + visible_row_count)
82
+ email_set = set()
83
+ for row in range(first_row, last_row + 1):
84
+ index = view.model().index(row, AUTHOR_COLUMN)
85
+ if not view.visualRect(index).intersects(view.viewport().rect()):
86
+ continue
87
+ _name, author_email = index.data(AUTHOR_IDENTITY_ROLE)
88
+ email = normalize_author_email(author_email)
89
+ if email:
90
+ email_set.add(email)
91
+ for email in tuple(self.identity_by_email):
92
+ if email not in email_set:
93
+ identity = self.identity_by_email.pop(email)
94
+ self.email_by_identity.pop(identity)
95
+ self.image_by_email.pop(email, None)
96
+ self.service.cancel(identity)
97
+ for email in sorted(email_set):
98
+ if email in self.identity_by_email:
99
+ continue
100
+ identity = uuid.uuid4().hex
101
+ self.identity_by_email[email] = identity
102
+ self.email_by_identity[identity] = email
103
+ self.service.request(email, side, ratio, identity)
104
+
105
+ def close(self) -> None:
106
+ """Release only this view's requests while other windows continue using the shared service."""
107
+ self.closed = True
108
+ self.timer.stop()
109
+ self._clear_consumers()
110
+ if self.service is not None:
111
+ self.service.image_ready.disconnect(self._image_ready)
112
+ self.service = None
113
+
114
+ def _clear_consumers(self) -> None:
115
+ """Invalidate identities before cancellation can deliver a synchronous callback."""
116
+ identities = list(self.email_by_identity)
117
+ self.identity_by_email.clear()
118
+ self.email_by_identity.clear()
119
+ self.image_by_email.clear()
120
+ if self.service is not None:
121
+ for identity in identities:
122
+ self.service.cancel(identity)
123
+
124
+ def _image_ready(self, identity: object, image: QImage) -> None:
125
+ """Reject stale authors and density changes before repainting the current viewport."""
126
+ email = self.email_by_identity.get(identity)
127
+ if email is None or image.isNull() or self.closed:
128
+ return
129
+ self.image_by_email[email] = image
130
+ self.view.viewport().update()
@@ -0,0 +1,179 @@
1
+ """Expose ordered commit rows to the history view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
9
+ from PySide6.QtGui import QFont
10
+
11
+ from gitpane.history.graph import GraphLayout
12
+ from gitpane.history.revision import ancestor_oid_set
13
+ from gitpane.ui.widget.commit_date_text import format_commit_time
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+ from collections.abc import Set as AbstractSet
18
+
19
+ from gitpane.history.repository_value import Commit, HistorySnapshot, Reference
20
+
21
+ MESSAGE_COLUMN, AUTHOR_COLUMN, DATE_COLUMN, HASH_COLUMN = range(4)
22
+ COLUMN_LABELS = ("Commit", "Author", "Date", "Hash")
23
+ COLUMN_WIDTHS = (528, 176, 112, 112)
24
+ OID_ROLE = Qt.ItemDataRole.UserRole
25
+ GRAPH_ROLE = OID_ROLE + 1
26
+ REFERENCE_ROLE = GRAPH_ROLE + 1
27
+ HEAD_ROLE = REFERENCE_ROLE + 1
28
+ AUTHOR_IDENTITY_ROLE = HEAD_ROLE + 1
29
+ MESSAGE_PREVIEW_ROLE = AUTHOR_IDENTITY_ROLE + 1
30
+ TIP_ROLE = MESSAGE_PREVIEW_ROLE + 1
31
+ RELATIVE_OID_SET_ROLE = TIP_ROLE + 1
32
+ COMMITTED_PARENT_ROLE = RELATIVE_OID_SET_ROLE + 1
33
+
34
+
35
+ class HistoryModel(QAbstractTableModel):
36
+ """Keep stable OID-to-row positions while metadata pages are appended."""
37
+
38
+ def __init__(self, clock: Callable[[], datetime] | None = None) -> None:
39
+ """Inject the clock for reproducible relative date presentation."""
40
+ super().__init__()
41
+ self.clock = clock or (lambda: datetime.now().astimezone())
42
+ self.commits: list[Commit] = []
43
+ self.row_by_oid: dict[str, int] = {}
44
+ self.snapshot: HistorySnapshot | None = None
45
+ self.graph_layout = GraphLayout()
46
+ self.visible_oid_set: set[str] | None = None
47
+ self.visible_parent_oids_by_oid: dict[str, list[str]] | None = None
48
+ self.references_by_oid: dict[str, list[Reference]] = {}
49
+ self.relative_oid_set: set[str] | None = None
50
+ self.committed_parent_oid: str | None = None
51
+
52
+ def set_committed_parent(self, oid: str | None) -> None:
53
+ """Update the parent marker independently of HEAD, references and selection."""
54
+ self.committed_parent_oid = oid
55
+ if self.commits:
56
+ first_row = 0
57
+ self.dataChanged.emit(self.index(first_row, MESSAGE_COLUMN), self.index(len(self.commits) - 1, MESSAGE_COLUMN))
58
+
59
+ def reset_snapshot(self, snapshot: HistorySnapshot) -> None:
60
+ """Replace references and rows together when a new capture is accepted."""
61
+ self.beginResetModel()
62
+ self.snapshot = snapshot
63
+ self.references_by_oid.clear()
64
+ for reference in snapshot.references:
65
+ if reference.target_oid:
66
+ self.references_by_oid.setdefault(reference.target_oid, []).append(reference)
67
+ self.commits.clear()
68
+ self.row_by_oid.clear()
69
+ self.graph_layout = GraphLayout()
70
+ self.visible_oid_set = None
71
+ self.visible_parent_oids_by_oid = None
72
+ self.refresh_ancestry()
73
+ self.endResetModel()
74
+
75
+ def refresh_ancestry(self) -> None:
76
+ """Cache HEAD and all its ancestors so unrelated branches can be painted gray.
77
+
78
+ The same set answers the commit counter of the revision windows, so it uses the one shared ancestor walk and
79
+ stays None whenever the graph is incomplete or HEAD has no commit, which sends the counter to Git.
80
+ """
81
+ self.relative_oid_set = None
82
+ if self.snapshot is None or not self.snapshot.complete or not self.snapshot.head_oid:
83
+ return
84
+ self.relative_oid_set = ancestor_oid_set(self.snapshot.head_oid, self.snapshot.parent_oids_by_oid)
85
+
86
+ def refresh_date(self) -> None:
87
+ """Repaint visible date values without resetting rows, selection or scrolling."""
88
+ if self.commits:
89
+ first_row = 0
90
+ self.dataChanged.emit(self.index(first_row, DATE_COLUMN), self.index(len(self.commits) - 1, DATE_COLUMN), [Qt.ItemDataRole.DisplayRole])
91
+
92
+ def append_batch(self, commits: list[Commit]) -> None:
93
+ """Insert each OID once without changing the position of existing rows.
94
+
95
+ A commit is skipped when it already has a row or appeared earlier in the same page; only the page's own OIDs
96
+ are collected. The known hashes handed to the graph are a live key view of the snapshot index, not a copy: the
97
+ graph only tests membership in it, and copying every known hash on each page made a long history load several
98
+ times slower. Without a snapshot the graph never reads that set, so the row index view is passed as is.
99
+ """
100
+ new_commits = []
101
+ batch_oid_set: set[str] = set()
102
+ for commit in commits:
103
+ if commit.oid in self.row_by_oid or commit.oid in batch_oid_set:
104
+ continue
105
+ new_commits.append(commit)
106
+ batch_oid_set.add(commit.oid)
107
+ if not new_commits:
108
+ return
109
+ first_row = len(self.commits)
110
+ known_oid_set: AbstractSet[str] = self.snapshot.parent_oids_by_oid.keys() if self.snapshot is not None else self.row_by_oid.keys()
111
+ if self.visible_oid_set is not None:
112
+ known_oid_set = self.visible_oid_set
113
+ self.beginInsertRows(QModelIndex(), first_row, first_row + len(new_commits) - 1)
114
+ for commit in new_commits:
115
+ self.row_by_oid[commit.oid] = len(self.commits)
116
+ self.commits.append(commit)
117
+ parent_oids = commit.parent_oids if self.visible_parent_oids_by_oid is None else self.visible_parent_oids_by_oid.get(commit.oid, [])
118
+ self.graph_layout.append(commit.oid, parent_oids, known_oid_set, self.snapshot.complete if self.snapshot is not None else False)
119
+ self.endInsertRows()
120
+
121
+ def rowCount(self, parent: QModelIndex | None = None) -> int:
122
+ """Return root rows only because the history is a flat table."""
123
+ return 0 if parent is not None and parent.isValid() else len(self.commits)
124
+
125
+ def columnCount(self, parent: QModelIndex | None = None) -> int:
126
+ """Expose one fixed set of metadata columns."""
127
+ return len(COLUMN_LABELS)
128
+
129
+ def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> object:
130
+ """Name metadata columns for both visual and accessible navigation."""
131
+ if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
132
+ return COLUMN_LABELS[section]
133
+ return None
134
+
135
+ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> object:
136
+ """Return complete tooltip text alongside elidable display values."""
137
+ if not index.isValid() or index.row() >= len(self.commits):
138
+ return None
139
+ commit = self.commits[index.row()]
140
+ if role == OID_ROLE:
141
+ return commit.oid
142
+ if role == GRAPH_ROLE:
143
+ return self.graph_layout.row_by_oid.get(commit.oid)
144
+ if role == RELATIVE_OID_SET_ROLE:
145
+ return self.relative_oid_set
146
+ if role == REFERENCE_ROLE:
147
+ return self.references_by_oid.get(commit.oid, [])
148
+ if role == HEAD_ROLE:
149
+ return self.snapshot is not None and commit.oid == self.snapshot.head_oid
150
+ if role == COMMITTED_PARENT_ROLE:
151
+ return commit.oid == self.committed_parent_oid
152
+ if role == TIP_ROLE:
153
+ row = self.graph_layout.row_by_oid.get(commit.oid)
154
+ has_hidden_child = self.snapshot is not None and bool(self.snapshot.child_oids_by_oid.get(commit.oid))
155
+ return row is not None and row.is_tip and not has_hidden_child
156
+ if role == AUTHOR_IDENTITY_ROLE:
157
+ return commit.author_name, commit.author_email
158
+ if role == MESSAGE_PREVIEW_ROLE:
159
+ return commit.body_preview
160
+ if role == Qt.ItemDataRole.FontRole and self.snapshot is not None and commit.oid == self.snapshot.head_oid:
161
+ font = QFont()
162
+ font.setWeight(QFont.Weight.DemiBold)
163
+ return font
164
+ if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole, Qt.ItemDataRole.AccessibleTextRole):
165
+ return None
166
+ if index.column() == MESSAGE_COLUMN:
167
+ if role in (Qt.ItemDataRole.ToolTipRole, Qt.ItemDataRole.AccessibleTextRole):
168
+ names = []
169
+ if commit.oid == self.committed_parent_oid:
170
+ names.append(f"Committed in parent: {commit.oid}")
171
+ for reference in self.references_by_oid.get(commit.oid, []):
172
+ names.append(reference.full_name)
173
+ return "\n".join(names) + "\n\n" + commit.message if names else commit.message
174
+ return commit.message.partition("\n")[0]
175
+ if index.column() == AUTHOR_COLUMN:
176
+ return commit.author_name
177
+ if index.column() == DATE_COLUMN:
178
+ return format_commit_time(commit, commit.author_timestamp, self.clock(), relative_only=role == Qt.ItemDataRole.DisplayRole)
179
+ return commit.oid if role == Qt.ItemDataRole.ToolTipRole else commit.short_oid
@@ -0,0 +1,319 @@
1
+ """Dropdown list of the repository switch button: opened under its button like the list of a combo box, without reading the disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QEvent, QModelIndex, QObject, QPoint, QRect, QSize, Qt, Signal
9
+ from PySide6.QtGui import QGuiApplication, QKeyEvent, QMouseEvent, QStandardItem, QStandardItemModel
10
+ from PySide6.QtWidgets import QAbstractItemView, QFrame, QLayout, QTreeView, QVBoxLayout, QWidget
11
+
12
+ from gitpane.config import REPOSITORY_LIST_COLUMN_GAP, REPOSITORY_LIST_MAX_VISIBLE_ROW_COUNT, SCREEN_MARGIN, SharedText
13
+ from gitpane.recent.recent_name import repository_display_name
14
+ from gitpane.ui.widget.mnemonic import MNEMONIC_MARKER
15
+ from gitpane.ui.widget.plain_tooltip import plain_text_tooltip
16
+
17
+ if TYPE_CHECKING:
18
+ from PySide6.QtGui import QScreen
19
+
20
+ # Numbering of the recent repositories as in Git Extensions: the first ten entries get a keyboard mnemonic (the digit
21
+ # after "&"), the tenth on its "0"; entries beyond ten show their number alone, without a mnemonic.
22
+ REPOSITORY_MENU_MNEMONIC_BY_NUMBER: dict[int, str] = {1: "&1", 2: "&2", 3: "&3", 4: "&4", 5: "&5", 6: "&6", 7: "&7", 8: "&8", 9: "&9", 10: "1&0"}
23
+ # Columns of a row: its number, the repository name, then the full path that tells apart two repositories of the same name.
24
+ NUMBER_COLUMN, NAME_COLUMN, PATH_COLUMN = 0, 1, 2
25
+ COLUMN_COUNT = 3
26
+ # The folder a row opens, carried by its number cell.
27
+ REPOSITORY_DIR_ROLE = Qt.ItemDataRole.UserRole
28
+ # The current repository is always the first row, where the list starts.
29
+ FIRST_ENTRY_ROW = 0
30
+ # The previous repository, the current one being first: the list opens on it, so Enter goes straight back to it.
31
+ SECOND_ENTRY_ROW = 1
32
+ # Width of the line drawn around the list, which sets it apart from the window under it.
33
+ POPUP_FRAME_WIDTH = 1
34
+ # The list numbers its rows from 1, as the user counts them.
35
+ FIRST_ROW_NUMBER = 1
36
+ # A key typed to jump to a name gives one character; longer text comes from an input method composing several.
37
+ TYPED_CHARACTER_LENGTH = 1
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RepositoryListRow:
42
+ """Texts of one row of the list and the folder it opens."""
43
+
44
+ number_text: str
45
+ name: str
46
+ repository_dir: str
47
+
48
+
49
+ def repository_list_rows(repository_dirs: list[str]) -> list[RepositoryListRow]:
50
+ """Return the rows the list shows for folders already known, numbered from 1.
51
+
52
+ The texts are shown as written: unlike a menu, the list never turns an "&" into a keyboard mnemonic.
53
+ """
54
+ rows = []
55
+ for number, repository_dir in enumerate(repository_dirs, start=FIRST_ROW_NUMBER):
56
+ rows.append(RepositoryListRow(str(number), repository_display_name(repository_dir), repository_dir))
57
+ return rows
58
+
59
+
60
+ def repository_menu_number_by_key() -> dict[str, int]:
61
+ """Return the entry number each typed key opens, read from the mnemonic table so that it stays the only source."""
62
+ number_by_key = {}
63
+ for number, mnemonic_text in REPOSITORY_MENU_MNEMONIC_BY_NUMBER.items():
64
+ key_position = mnemonic_text.index(MNEMONIC_MARKER) + len(MNEMONIC_MARKER)
65
+ number_by_key[mnemonic_text[key_position]] = number
66
+ return number_by_key
67
+
68
+
69
+ def rows_whose_name_starts_with(names: list[str], typed_text: str) -> list[int]:
70
+ """Return the rows whose repository name starts with the typed text, whatever the case, in the order of the list.
71
+
72
+ The letters are looked up in the names because the mnemonic table only holds digits.
73
+ """
74
+ wanted_start = typed_text.casefold()
75
+ rows = []
76
+ for row, name in enumerate(names):
77
+ if name.casefold().startswith(wanted_start):
78
+ rows.append(row)
79
+ return rows
80
+
81
+
82
+ def next_row_after(rows: list[int], current_row: int) -> int:
83
+ """Return the first of rows after the current row, or the first of them past the last one.
84
+
85
+ A letter that starts several names thus moves from one to the next and around, like duplicate mnemonics in a menu.
86
+ rows is never empty and already in the order of the list.
87
+ """
88
+ for row in rows:
89
+ if row > current_row:
90
+ return row
91
+ return min(rows)
92
+
93
+
94
+ def repository_list_geometry(anchor_rect: QRect, wanted_size: QSize, available: QRect, minimum_height: int) -> QRect:
95
+ """Return where the list goes on screen, given its button in screen coordinates and the usable area of the screen.
96
+
97
+ The list always opens right under the button, never above it. Its width is at least the width of the button and at
98
+ most the usable width of the screen less a margin on each side. Its height is the wanted one, shortened to end at
99
+ the bottom of the usable area, but never under the minimum height. Its left edge is the left edge of the button,
100
+ moved left when the list would pass the right edge of the screen, and never left of the screen.
101
+ """
102
+ width = min(max(wanted_size.width(), anchor_rect.width()), available.width() - 2 * SCREEN_MARGIN)
103
+ top = anchor_rect.bottom() + 1
104
+ room_below = available.bottom() + 1 - top
105
+ height = max(min(wanted_size.height(), room_below), minimum_height)
106
+ left = min(anchor_rect.left(), available.right() + 1 - width)
107
+ left = max(left, available.left())
108
+ return QRect(left, top, width, height)
109
+
110
+
111
+ class RepositoryListPopup(QFrame):
112
+ """List of the recent repositories in three columns (number, name, full path), shown under the repository button.
113
+
114
+ A click on a row chooses it. The rows are rebuilt from folders the window already knows each time the list opens,
115
+ so opening it never waits for a disk or a network drive.
116
+ """
117
+
118
+ repository_chosen = Signal(str)
119
+
120
+ def __init__(self, parent: QWidget) -> None:
121
+ """Build the list once; each opening only rebuilds its rows.
122
+
123
+ The parent window is kept as the owner of the popup: under Wayland a popup without one is placed wrongly.
124
+ """
125
+ super().__init__(parent, Qt.WindowType.Popup)
126
+ self.setFrameShape(QFrame.Shape.Box)
127
+ self.setLineWidth(POPUP_FRAME_WIDTH)
128
+ layout = QVBoxLayout(self)
129
+ layout.setContentsMargins(0, 0, 0, 0)
130
+ layout.setSpacing(0)
131
+ # The size of the list is decided at each opening from its rows; left to the layout, the smallest size Qt suggests
132
+ # for a tree view, about four rows, would make a list of one or two entries taller than its rows.
133
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
134
+ self.model = QStandardItemModel(0, COLUMN_COUNT, self)
135
+ self.view = QTreeView(self)
136
+ self.view.setAccessibleName(SharedText.RECENT_REPOSITORY.value)
137
+ self.view.setModel(self.model)
138
+ self.view.setHeaderHidden(True)
139
+ self.view.setRootIsDecorated(False)
140
+ self.view.setUniformRowHeights(True)
141
+ self.view.setItemsExpandable(False)
142
+ self.view.setAllColumnsShowFocus(True)
143
+ self.view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
144
+ self.view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
145
+ self.view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
146
+ self.view.setTextElideMode(Qt.TextElideMode.ElideMiddle)
147
+ self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
148
+ self.view.setFrameShape(QFrame.Shape.NoFrame)
149
+ # The row under the mouse becomes the chosen row, as in a menu; a single click opens it.
150
+ self.view.setMouseTracking(True)
151
+ # The path column takes whatever width the list has left after the number and the name.
152
+ self.view.header().setStretchLastSection(True)
153
+ layout.addWidget(self.view)
154
+ self.view.clicked.connect(self._choose_index)
155
+ self.view.entered.connect(self._select_hovered_index)
156
+ self.view.installEventFilter(self)
157
+
158
+ def show_entries(self, repository_dirs: list[str], anchor: QWidget) -> None:
159
+ """Rebuild the rows from folders already known, without touching the disk, then open the list under anchor.
160
+
161
+ The list shows at most a fixed number of rows and scrolls past them; its columns and size are set before it
162
+ shows, since rows laid out at another width keep it.
163
+ """
164
+ self.model.removeRows(0, self.model.rowCount())
165
+ for list_row in repository_list_rows(repository_dirs):
166
+ self.model.appendRow(self._row_cells(list_row))
167
+ column_widths = self._measure_columns()
168
+ row_count = self.model.rowCount()
169
+ row_height = self.view.sizeHintForIndex(self.model.index(FIRST_ENTRY_ROW, NAME_COLUMN)).height()
170
+ frame_height = 2 * POPUP_FRAME_WIDTH
171
+ anchor_rect = QRect(anchor.mapToGlobal(QPoint(0, 0)), anchor.size())
172
+ available = self._screen_at(QPoint(anchor_rect.left(), anchor_rect.bottom() + 1), anchor).availableGeometry()
173
+ minimum_height = row_height + frame_height
174
+ visible_row_count = min(row_count, REPOSITORY_LIST_MAX_VISIBLE_ROW_COUNT)
175
+ wanted_size = QSize(sum(column_widths) + frame_height, visible_row_count * row_height + frame_height)
176
+ geometry = repository_list_geometry(anchor_rect, wanted_size, available, minimum_height)
177
+ scroll_bar_width = 0
178
+ if geometry.height() - frame_height < row_count * row_height:
179
+ # Not every row fits, whether the history is long or the list was shortened at the bottom of the screen: the
180
+ # vertical scroll bar takes its width from the columns unless the list grows by as much.
181
+ scroll_bar_width = self.view.verticalScrollBar().sizeHint().width()
182
+ geometry = repository_list_geometry(anchor_rect, QSize(wanted_size.width() + scroll_bar_width, wanted_size.height()), available, minimum_height)
183
+ self.setGeometry(geometry)
184
+ # Workaround: with a scroll bar shown "as needed", Qt decides to show or hide it only after the list has shown,
185
+ # through a queued call, so the list would first show with the bar of its previous opening and columns that no
186
+ # longer fit. The list already knows whether every row fits, so it says so before it shows.
187
+ scroll_bar_policy = Qt.ScrollBarPolicy.ScrollBarAlwaysOn if scroll_bar_width else Qt.ScrollBarPolicy.ScrollBarAlwaysOff
188
+ self.view.setVerticalScrollBarPolicy(scroll_bar_policy)
189
+ self._fit_columns(column_widths, geometry.width(), scroll_bar_width)
190
+ opening_row = SECOND_ENTRY_ROW if row_count > SECOND_ENTRY_ROW else FIRST_ENTRY_ROW
191
+ self.view.setCurrentIndex(self.model.index(opening_row, NUMBER_COLUMN))
192
+ self.view.scrollToTop()
193
+ self.show()
194
+ self.view.setFocus()
195
+
196
+ def _row_cells(self, list_row: RepositoryListRow) -> list[QStandardItem]:
197
+ """Return the cells of one row, in column order: plain text, read only, each with the full path as tooltip."""
198
+ tooltip = plain_text_tooltip([list_row.repository_dir])
199
+ text_by_column = {NUMBER_COLUMN: list_row.number_text, NAME_COLUMN: list_row.name, PATH_COLUMN: list_row.repository_dir}
200
+ cells = []
201
+ for column in range(COLUMN_COUNT):
202
+ cell = QStandardItem(text_by_column[column])
203
+ cell.setEditable(False)
204
+ cell.setToolTip(tooltip)
205
+ cells.append(cell)
206
+ cells[NUMBER_COLUMN].setData(list_row.repository_dir, REPOSITORY_DIR_ROLE)
207
+ return cells
208
+
209
+ def _measure_columns(self) -> list[int]:
210
+ """Return the width each column wants: its content as the view measures it, style margins included, plus a small
211
+ gap after the number and after the name so that they do not touch the next column.
212
+
213
+ The number and the name never get less than the smallest section the header allows, since it would widen them
214
+ anyway and the path would then pass the edge of the list.
215
+ """
216
+ minimum_section_width = self.view.header().minimumSectionSize()
217
+ column_widths = []
218
+ for column in range(COLUMN_COUNT):
219
+ column_width = self.view.sizeHintForColumn(column)
220
+ if column != PATH_COLUMN:
221
+ column_width = max(column_width + REPOSITORY_LIST_COLUMN_GAP, minimum_section_width)
222
+ column_widths.append(column_width)
223
+ return column_widths
224
+
225
+ def _fit_columns(self, column_widths: list[int], list_width: int, scroll_bar_width: int) -> None:
226
+ """Give each column its width before the list shows: the number and the name their measured width, the path what
227
+ the visible part of the list has left.
228
+
229
+ When that is less than its content, the view cuts the paths in the middle. A width given after the list shows can
230
+ leave rows laid out at a width that no longer fits.
231
+ """
232
+ header = self.view.header()
233
+ header.resizeSection(NUMBER_COLUMN, column_widths[NUMBER_COLUMN])
234
+ header.resizeSection(NAME_COLUMN, column_widths[NAME_COLUMN])
235
+ viewport_width = list_width - 2 * POPUP_FRAME_WIDTH - scroll_bar_width
236
+ path_width = viewport_width - header.sectionSize(NUMBER_COLUMN) - header.sectionSize(NAME_COLUMN)
237
+ header.resizeSection(PATH_COLUMN, max(path_width, header.minimumSectionSize()))
238
+
239
+ def _screen_at(self, point: QPoint, anchor: QWidget) -> QScreen:
240
+ """Return the screen under the top left corner of the list, or the screen of its button when no screen is there."""
241
+ screen = QGuiApplication.screenAt(point)
242
+ return screen if screen is not None else anchor.screen()
243
+
244
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
245
+ """Answer the keys of the list as the list of a combo box does: Enter opens the chosen row, Escape closes, a digit
246
+ or a letter opens or chooses an entry. Arrows, Page Up/Down, Home and End are left to the view."""
247
+ if watched is not self.view or event.type() != QEvent.Type.KeyPress:
248
+ return super().eventFilter(watched, event)
249
+ return self._handle_key(event)
250
+
251
+ def _handle_key(self, event: QKeyEvent) -> bool:
252
+ """Act on a key pressed in the list; return whether the list used it, so the view does not act on it as well."""
253
+ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
254
+ current_index = self.view.currentIndex()
255
+ if current_index.isValid():
256
+ self._choose_row(current_index.row())
257
+ return True
258
+ if event.key() == Qt.Key.Key_Escape:
259
+ self.close()
260
+ return True
261
+ typed_text = event.text()
262
+ number = repository_menu_number_by_key().get(typed_text)
263
+ if number is not None:
264
+ if number <= self.model.rowCount():
265
+ self._choose_row(number - FIRST_ROW_NUMBER)
266
+ return True
267
+ if len(typed_text) != TYPED_CHARACTER_LENGTH or not typed_text.isprintable() or typed_text.isspace():
268
+ return False
269
+ # No letter has a mnemonic, since the names show as written, so a letter is looked up in the repository names.
270
+ # The built-in keyboard search of the view is not used: it searches the number column and only ever chooses a
271
+ # row, never opens one. The key is used even when no name matches, so that search does not move the row either.
272
+ self._choose_by_name(typed_text)
273
+ return True
274
+
275
+ def _choose_by_name(self, typed_text: str) -> None:
276
+ """Open the only entry whose name starts with the typed letter, or choose the next of several without opening it."""
277
+ names = []
278
+ for row in range(self.model.rowCount()):
279
+ names.append(self.model.index(row, NAME_COLUMN).data())
280
+ matching_rows = rows_whose_name_starts_with(names, typed_text)
281
+ if not matching_rows:
282
+ return
283
+ first_row, *other_rows = matching_rows
284
+ if not other_rows:
285
+ self._choose_row(first_row)
286
+ return
287
+ next_index = self.model.index(next_row_after(matching_rows, self.view.currentIndex().row()), NUMBER_COLUMN)
288
+ self.view.setCurrentIndex(next_index)
289
+ self.view.scrollTo(next_index)
290
+
291
+ def mousePressEvent(self, event: QMouseEvent) -> None:
292
+ """Close on any press outside the list without letting that press reach the widget under the mouse."""
293
+ if self.rect().contains(event.position().toPoint()):
294
+ super().mousePressEvent(event)
295
+ return
296
+ # Workaround: Qt replays the press that closed a popup to the widget under the mouse on Windows, not on X11.
297
+ # Refusing the replay everywhere makes a click on the repository button close the list instead of opening it
298
+ # again, and keeps a click on any other control, such as Push or Commit, from acting while it closes the list.
299
+ # Qt clears this attribute at each new mouse event, so it is set right before closing.
300
+ self.setAttribute(Qt.WidgetAttribute.WA_NoMouseReplay)
301
+ self.close()
302
+
303
+ def _select_hovered_index(self, index: QModelIndex) -> None:
304
+ """Make the row under the mouse the chosen row, as a menu highlights the entry under the mouse."""
305
+ self.view.setCurrentIndex(index)
306
+
307
+ def _choose_index(self, index: QModelIndex) -> None:
308
+ """Choose the row that was clicked."""
309
+ self._choose_row(index.row())
310
+
311
+ def _choose_row(self, row: int) -> None:
312
+ """Close the list, then ask for the folder of a row.
313
+
314
+ The list closes first, as a menu closes before its action runs, so a window opened by the change, such as the
315
+ message about a missing folder, never shows under the list.
316
+ """
317
+ repository_dir = self.model.index(row, NUMBER_COLUMN).data(REPOSITORY_DIR_ROLE)
318
+ self.hide()
319
+ self.repository_chosen.emit(repository_dir)