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,199 @@
1
+ """Reproduce the Git Extensions "Select multiple branches" window: the deletable branches with check boxes, then an OK button."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QMargins, QRect, QSize, Qt, Signal
8
+ from PySide6.QtWidgets import QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QProxyStyle, QPushButton, QStyle, QStyledItemDelegate, QStyleFactory, QVBoxLayout, QWidget
9
+
10
+ from gitpane.config import OK_BUTTON_TEXT, fit_window
11
+ from gitpane.reference.reference_snapshot import short_reference_name
12
+ from gitpane.ui.look.application_style import GitPaneFusionStyle
13
+ from gitpane.ui.look.theme_look import follow_theme
14
+ from gitpane.ui.look.windows_control_look import apply_windows_button_look, windows_list_style_sheet
15
+ from gitpane.ui.widget.dialog_layout import fit_capture_control, install_plain_form_palette
16
+
17
+ if TYPE_CHECKING:
18
+ from PySide6.QtCore import QModelIndex
19
+ from PySide6.QtWidgets import QStyleOption, QStyleOptionViewItem
20
+
21
+ SELECT_MULTIPLE_BRANCHES_TITLE = "Select multiple branches"
22
+ SELECT_BRANCHES_CAPTION = "Select branches"
23
+ # Client area of the Git Extensions capture, in pixels at 100 %.
24
+ SELECT_BRANCHES_CLIENT_SIZE = QSize(263, 252)
25
+ # Measured on the capture in client pixels: "Select branches" ink x 15-96, y 12-20; the check box list (12, 32, 239, 166)
26
+ # with a one-pixel Windows list border; rows every 18 px, squares from x 15 and texts from x 33; the OK button
27
+ # (177, 224, 73, 21), 13 px from the right edge and 7 px from the bottom.
28
+ SELECT_BRANCHES_LIST_RECT = QRect(12, 32, 239, 166)
29
+ BRANCH_ROW_HEIGHT = 18
30
+ OK_BUTTON_SIZE = QSize(73, 21)
31
+ SELECT_BRANCHES_MARGINS = QMargins(12, 8, 12, 7)
32
+ # The caption text starts 3 px right of the list, where the capture's ink starts, and its text box is as high as the
33
+ # space above the list, so the list stays at the capture's place whatever the caption's font.
34
+ SELECT_BRANCHES_CAPTION_INDENT = 3
35
+ CAPTION_HEIGHT = SELECT_BRANCHES_LIST_RECT.top() - SELECT_BRANCHES_MARGINS.top()
36
+ LIST_TO_OK_SPACE = 26
37
+ # The OK button ends one pixel further from the right edge than the list.
38
+ OK_BUTTON_RIGHT_INDENT = 1
39
+ # Horizontal focus margin that puts the Fusion check box square 2 px inside the list and the text 18 px after the
40
+ # square's left edge, where the capture has them; Fusion's own margin puts both about 3 px further right.
41
+ BRANCH_ROW_FOCUS_MARGIN = 1
42
+
43
+
44
+ class FixedRowHeightDelegate(QStyledItemDelegate):
45
+ """Draw list rows as the default delegate does, at a fixed height instead of the font's."""
46
+
47
+ def __init__(self, row_height: int, parent: QWidget | None = None) -> None:
48
+ """Remember the row height every item gets."""
49
+ super().__init__(parent)
50
+ self.row_height = row_height
51
+
52
+ def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
53
+ """Keep the default width and replace the height with the fixed one."""
54
+ size = super().sizeHint(option, index)
55
+ return QSize(size.width(), self.row_height)
56
+
57
+
58
+ class CompactRowMargin:
59
+ """Workaround: lay list rows out like the Windows check box list, the square and the text closer to the left edge than Fusion does.
60
+
61
+ Fusion derives the space before the square, and between the square and the text, from the focus frame margin, and
62
+ no style sheet property moves them without restyling the whole row. Answering a smaller margin for this list only
63
+ moves both to the capture's place. Mixed in front of a style class, so every other metric is that style's.
64
+ """
65
+
66
+ def pixelMetric(self, metric: QStyle.PixelMetric, option: QStyleOption | None = None, widget: QWidget | None = None) -> int:
67
+ """Answer the reduced focus frame margin, and the style's own value for every other metric."""
68
+ if metric == QStyle.PixelMetric.PM_FocusFrameHMargin:
69
+ return BRANCH_ROW_FOCUS_MARGIN
70
+ return super().pixelMetric(metric, option, widget)
71
+
72
+
73
+ class CompactCheckListStyle(CompactRowMargin, GitPaneFusionStyle):
74
+ """The row style when the application runs GitPane's Fusion style: that style's class with the compact row margin.
75
+
76
+ Everything else is drawn as the application style draws it, the contour of the squares in a dark theme included.
77
+ It derives from the application style's class instead of wrapping it: Fusion only asks its direct wrapper for
78
+ metrics, so a second wrapper around the first would lose the margin.
79
+ """
80
+
81
+
82
+ class CompactProxyRowStyle(CompactRowMargin, QProxyStyle):
83
+ """The row style when the application runs another style than GitPane's Fusion: a wrapper around a fresh copy of it, with the compact row margin.
84
+
85
+ The rows, their check boxes and the list's scroll bar then match the rest of the windows, drawn in that style.
86
+ """
87
+
88
+
89
+ class SelectBranchesDialog(QDialog):
90
+ """List the branches Delete branch may delete with check boxes, like the Git Extensions Select multiple branches window.
91
+
92
+ As in Git Extensions, OK, the title bar close button and Escape all close the window and apply the checks: the
93
+ window emits the checked branches, in the list order, however it was closed. It never starts Git; its branches
94
+ are the ones the Delete branch window already offers.
95
+ """
96
+
97
+ selection_applied = Signal(object)
98
+
99
+ def __init__(self, candidate_refs: tuple[str, ...], checked_ref_set: set[str], parent: QWidget | None = None) -> None:
100
+ """Build the caption, one check box row per offered branch with the known ones checked, and the OK button."""
101
+ super().__init__(parent)
102
+ self.setWindowTitle(SELECT_MULTIPLE_BRANCHES_TITLE)
103
+ self.setModal(True)
104
+ # Git Extensions shows this window as an ordinary one: minimize and maximize buttons, no help button, a size grip.
105
+ self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
106
+ self.setWindowFlag(Qt.WindowType.WindowMinMaxButtonsHint, True)
107
+ self.setSizeGripEnabled(True)
108
+ install_plain_form_palette(self)
109
+ self.applied = False
110
+ self.row_style: QProxyStyle | None = None
111
+ layout = QVBoxLayout(self)
112
+ layout.setContentsMargins(SELECT_BRANCHES_MARGINS)
113
+ layout.setSpacing(0)
114
+ self.caption_label = QLabel(SELECT_BRANCHES_CAPTION)
115
+ self.caption_label.setContentsMargins(SELECT_BRANCHES_CAPTION_INDENT, 0, 0, 0)
116
+ self.caption_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
117
+ self.caption_label.setMinimumHeight(CAPTION_HEIGHT)
118
+ self.branch_list = QListWidget()
119
+ self.branch_list.setAccessibleName(SELECT_BRANCHES_CAPTION)
120
+ self.branch_list.setUniformItemSizes(True)
121
+ self.branch_list.setItemDelegate(FixedRowHeightDelegate(BRANCH_ROW_HEIGHT, self.branch_list))
122
+ self._install_row_style()
123
+ self.caption_label.setBuddy(self.branch_list)
124
+ for full_ref in candidate_refs:
125
+ item = QListWidgetItem(short_reference_name(full_ref))
126
+ item.setData(Qt.ItemDataRole.UserRole, full_ref)
127
+ item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsUserCheckable)
128
+ item.setCheckState(Qt.CheckState.Checked if full_ref in checked_ref_set else Qt.CheckState.Unchecked)
129
+ self.branch_list.addItem(item)
130
+ self.ok_button = QPushButton(OK_BUTTON_TEXT)
131
+ # Git Extensions gives this window no default button: Enter does not close it.
132
+ self.ok_button.setAutoDefault(False)
133
+ self.ok_button.setDefault(False)
134
+ self.apply_theme_look()
135
+ follow_theme(self, self.apply_theme_look)
136
+ button_row = QHBoxLayout()
137
+ button_row.setContentsMargins(0, 0, OK_BUTTON_RIGHT_INDENT, 0)
138
+ button_row.addStretch(1)
139
+ button_row.addWidget(self.ok_button)
140
+ layout.addWidget(self.caption_label)
141
+ layout.addWidget(self.branch_list, 1)
142
+ layout.addSpacing(LIST_TO_OK_SPACE)
143
+ layout.addLayout(button_row)
144
+ self.ok_button.clicked.connect(self.accept)
145
+ self.finished.connect(self._apply)
146
+ self.resize(SELECT_BRANCHES_CLIENT_SIZE)
147
+ self.refresh_metrics()
148
+ self.branch_list.setFocus()
149
+
150
+ def apply_theme_look(self) -> None:
151
+ """Lay the light or dark look of the branch list's border and background and of OK; called by the constructor and again at each switch.
152
+
153
+ The list's sheet is worked out on the application palette, not the list's: macOS gives lists a fully opaque text
154
+ color, which would give their border a lighter grey than every other line of the window, and at a switch this
155
+ runs before Qt hands the new palette to the list. The rows need nothing: their style decides when it paints.
156
+ Only style sheets change: the checked branches stay as they are.
157
+ """
158
+ self.branch_list.setStyleSheet(windows_list_style_sheet(QApplication.palette(), "QListWidget", hide_focus_outline=True))
159
+ apply_windows_button_look(self.ok_button)
160
+
161
+ def _install_row_style(self) -> None:
162
+ """Draw the list with its own instance of the application style whose row margins match the capture.
163
+
164
+ With GitPane's Fusion style, the row style is that style's class with the reduced margin: one style layer only.
165
+ Any other application style is wrapped as it is, so the list keeps the look of the other windows; a proxy without
166
+ a base style would fall back to the desktop style, so it gets its own instance of the application style, and an
167
+ application style the style factory cannot build keeps its rows as they are.
168
+ """
169
+ application_style = QApplication.style()
170
+ if isinstance(application_style, GitPaneFusionStyle):
171
+ self.row_style = CompactCheckListStyle()
172
+ else:
173
+ base_style = QStyleFactory.create(application_style.objectName())
174
+ if base_style is None:
175
+ return
176
+ self.row_style = CompactProxyRowStyle(base_style)
177
+ self.row_style.setParent(self.branch_list)
178
+ self.branch_list.setStyle(self.row_style)
179
+
180
+ def checked_refs(self) -> tuple[str, ...]:
181
+ """Answer the full names of the checked branches, in the list order."""
182
+ full_refs: list[str] = []
183
+ for row in range(self.branch_list.count()):
184
+ item = self.branch_list.item(row)
185
+ if item.checkState() == Qt.CheckState.Checked:
186
+ full_refs.append(item.data(Qt.ItemDataRole.UserRole))
187
+ return tuple(full_refs)
188
+
189
+ def refresh_metrics(self, available: QRect | None = None) -> None:
190
+ """Give OK the capture's size, or more when its text needs it, then keep the window on the usable screen area without turning its starting size into a minimum."""
191
+ fit_capture_control(self.ok_button, OK_BUTTON_SIZE)
192
+ fit_window(self, 0, 0, available)
193
+
194
+ def _apply(self) -> None:
195
+ """Hand the checked branches over once, whether OK, the close button or Escape closed the window."""
196
+ if self.applied:
197
+ return
198
+ self.applied = True
199
+ self.selection_applied.emit(self.checked_refs())
@@ -0,0 +1,171 @@
1
+ """Offer explicit stash creation while keeping every unsuccessful draft visible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import Qt, Signal
8
+ from PySide6.QtWidgets import QCheckBox, QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout
9
+
10
+ from gitpane.operation.diagnostic import SensitiveTextFilter
11
+ from gitpane.operation.operation_status import OperationStatus
12
+ from gitpane.reference.stash import STASH_INCLUDE_UNTRACKED_KEY, STASH_KEEP_INDEX_KEY, StashIntent, StashOption
13
+ from gitpane.text.typed_text import typed_value
14
+ from gitpane.ui.widget.dialog_layout import fit_form
15
+
16
+ if TYPE_CHECKING:
17
+ from PySide6.QtCore import QRect
18
+ from PySide6.QtGui import QCloseEvent
19
+ from PySide6.QtWidgets import QWidget
20
+
21
+ from gitpane.operation.repository_operation import OperationOutcome
22
+ from gitpane.preference.preference_store import PreferenceStore
23
+ from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
24
+
25
+ STASH_WIDTH, STASH_HEIGHT = 600, 420
26
+
27
+
28
+ class StashDialog(QDialog):
29
+ """Keep message and options until creation and the final working tree are observed."""
30
+
31
+ submit_requested = Signal(object)
32
+
33
+ def __init__(self, session_id: str, preference: PreferenceStore, parent: QWidget | None = None) -> None:
34
+ """Build a modeless form before any repository write is authorized."""
35
+ super().__init__(parent)
36
+ self.session_id = session_id
37
+ self.preference = preference
38
+ self.snapshot: WorktreeSnapshot | None = None
39
+ self.intent: StashIntent | None = None
40
+ self.running = False
41
+ self.capture_pending = False
42
+ self.closed = False
43
+ self.status_message = "Reading working tree…"
44
+ self.preference_error_by_key: dict[str, str] = {}
45
+ self.setWindowTitle("Stash")
46
+ self.setWindowModality(Qt.WindowModality.NonModal)
47
+ self.resize(STASH_WIDTH, STASH_HEIGHT)
48
+ layout = QVBoxLayout(self)
49
+ label = QLabel("Message (optional)")
50
+ self.message_edit = QPlainTextEdit()
51
+ self.message_edit.setAccessibleName("Message")
52
+ self.message_edit.setTabChangesFocus(True)
53
+ label.setBuddy(self.message_edit)
54
+ layout.addWidget(label)
55
+ layout.addWidget(self.message_edit, 1)
56
+ self.include_untracked_check = QCheckBox("Include untracked files")
57
+ self.include_untracked_check.setChecked(bool(preference.read(STASH_INCLUDE_UNTRACKED_KEY)))
58
+ self.keep_index_check = QCheckBox("Keep index")
59
+ self.keep_index_check.setChecked(bool(preference.read(STASH_KEEP_INDEX_KEY)))
60
+ layout.addWidget(self.include_untracked_check)
61
+ layout.addWidget(self.keep_index_check)
62
+ self.diagnostic_edit = QPlainTextEdit(self.status_message)
63
+ self.diagnostic_edit.setReadOnly(True)
64
+ self.diagnostic_edit.setAccessibleName("Stash diagnostic")
65
+ self.diagnostic_edit.setTabChangesFocus(True)
66
+ layout.addWidget(self.diagnostic_edit, 1)
67
+ actions = QHBoxLayout()
68
+ actions.addStretch()
69
+ self.stash_button = QPushButton("Stash")
70
+ self.stash_button.setEnabled(False)
71
+ self.stash_button.clicked.connect(self.submit)
72
+ self.cancel_button = QPushButton("Cancel")
73
+ self.cancel_button.clicked.connect(self.reject)
74
+ actions.addWidget(self.stash_button)
75
+ actions.addWidget(self.cancel_button)
76
+ layout.addLayout(actions)
77
+ self.include_untracked_check.toggled.connect(lambda checked: self._save_option(STASH_INCLUDE_UNTRACKED_KEY, checked))
78
+ self.keep_index_check.toggled.connect(lambda checked: self._save_option(STASH_KEEP_INDEX_KEY, checked))
79
+ self.finished.connect(self._closed)
80
+ self.refresh_metrics()
81
+
82
+ def begin_capture(self) -> None:
83
+ """Invalidate the previous intention while reloading the retained message and choices."""
84
+ self.capture_pending = True
85
+ self.snapshot = None
86
+ self.intent = None
87
+ self.stash_button.setEnabled(False)
88
+ self.status_message = "Reading working tree…"
89
+ self._show_diagnostic()
90
+
91
+ def populate(self, snapshot: WorktreeSnapshot) -> None:
92
+ """Enable a new submission after a fresh read without hiding unsaved option errors."""
93
+ self.capture_pending = False
94
+ self.snapshot = snapshot
95
+ self.status_message = ""
96
+ self._show_diagnostic()
97
+ self.stash_button.setEnabled(True)
98
+
99
+ def submit(self) -> None:
100
+ """Freeze visible inputs once, leaving repeats disabled until reconciliation.
101
+
102
+ The message is taken without the blanks around it, its inner lines as typed; the field keeps what was typed.
103
+ """
104
+ if self.running or self.closed or self.snapshot is None or not self.stash_button.isEnabled():
105
+ return
106
+ option = StashOption(self.include_untracked_check.isChecked(), self.keep_index_check.isChecked())
107
+ self.intent = StashIntent.capture(self.snapshot, typed_value(self.message_edit.toPlainText()), option, session_id=self.session_id)
108
+ self.running = True
109
+ self.stash_button.setEnabled(False)
110
+ self.cancel_button.setEnabled(False)
111
+ self.status_message = "Creating stash…"
112
+ self._show_diagnostic()
113
+ self.submit_requested.emit(self.intent)
114
+
115
+ def show_result(self, outcome: OperationOutcome) -> None:
116
+ """Close only on verified success; never clear a failed or uncertain draft."""
117
+ self.running = False
118
+ self.cancel_button.setEnabled(True)
119
+ if outcome.status == OperationStatus.SUCCESS and any(step.effect == "created" for step in outcome.steps):
120
+ self.accept()
121
+ return
122
+ detail = outcome.diagnostic.detail if outcome.diagnostic else "Stash was not created."
123
+ self.show_error(detail + "\n\nYour draft is kept. After reviewing the repository, click Stash in the toolbar to reload it.")
124
+
125
+ def show_error(self, message: str) -> None:
126
+ """Keep the complete plain-text diagnostic reachable without growing the form."""
127
+ self.capture_pending = False
128
+ self.snapshot = None
129
+ self.stash_button.setEnabled(False)
130
+ self.status_message = message
131
+ self._show_diagnostic()
132
+
133
+ def _show_diagnostic(self) -> None:
134
+ """Show repository activity and still-unsaved choices without letting either erase the other."""
135
+ messages = [self.status_message] if self.status_message else []
136
+ for key in sorted(self.preference_error_by_key):
137
+ message = self.preference_error_by_key[key]
138
+ if message not in messages:
139
+ messages.append(message)
140
+ self.diagnostic_edit.setPlainText("\n\n".join(messages))
141
+
142
+ def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
143
+ """Follow system font metrics and fit within the current screen's usable area."""
144
+ fit_form(self, available_geometry)
145
+
146
+ def _save_option(self, key: str, checked: bool) -> None:
147
+ """Keep the user's visible choice when durable storage rejects it."""
148
+ try:
149
+ self.preference.update({key: checked})
150
+ except (OSError, TypeError, ValueError) as error:
151
+ self.preference_error_by_key[key] = ("Could not save stash options. Your choice applies to this stash but will not be remembered. "
152
+ "After fixing the settings file, change this option again to save it. " + SensitiveTextFilter().redact(str(error)))
153
+ else:
154
+ self.preference_error_by_key.pop(key, None)
155
+ self._show_diagnostic()
156
+
157
+ def reject(self) -> None:
158
+ """Prevent dismissal while the shared owner is still reconciling native work."""
159
+ if not self.running:
160
+ super().reject()
161
+
162
+ def closeEvent(self, event: QCloseEvent) -> None:
163
+ """Keep a running form attached to its eventual result."""
164
+ if self.running:
165
+ event.ignore()
166
+ return
167
+ super().closeEvent(event)
168
+
169
+ def _closed(self) -> None:
170
+ """Record dismissal so late reads cannot populate a closed form."""
171
+ self.closed = True
File without changes
@@ -0,0 +1,298 @@
1
+ """Draw the cells of the history list in Qt logical coordinates, from the graph lanes to the author photos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QPointF, QRectF, QSize, Qt
9
+ from PySide6.QtGui import QColor, QFontMetrics, QPalette, QPen, QPolygonF
10
+ from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem
11
+
12
+ from gitpane.config import (
13
+ GRAPH_LANE_WIDTH,
14
+ GRAPH_MESSAGE_MIN_CHARACTERS,
15
+ GRAPH_NODE_SIDE,
16
+ ICON_SIDE,
17
+ PARENT_MARKER_BACKGROUND,
18
+ PARENT_MARKER_BORDER,
19
+ PARENT_MARKER_BORDER_WIDTH,
20
+ PARENT_MARKER_FILL,
21
+ PARENT_MARKER_INSET,
22
+ PARENT_MARKER_SHAFT_RATIO,
23
+ PARENT_MARKER_SHOULDER_RATIO,
24
+ SPACING_XS,
25
+ )
26
+ from gitpane.history.avatar import author_color, author_initials, normalize_author_email
27
+ from gitpane.history.graph import ROW_CENTER
28
+ from gitpane.history.repository_value import ReferenceKind
29
+ from gitpane.ui.look.reference_font import reference_font
30
+ from gitpane.ui.look.theme_color import is_dark_palette
31
+ from gitpane.ui.repository.history_model import AUTHOR_IDENTITY_ROLE, COMMITTED_PARENT_ROLE, GRAPH_ROLE, HEAD_ROLE, MESSAGE_PREVIEW_ROLE, REFERENCE_ROLE, RELATIVE_OID_SET_ROLE, TIP_ROLE
32
+
33
+ if TYPE_CHECKING:
34
+ from PySide6.QtCore import QModelIndex
35
+ from PySide6.QtGui import QPainter
36
+
37
+ from gitpane.history.repository_value import Reference
38
+ from gitpane.ui.repository.history_avatar import HistoryAvatar
39
+
40
+ LANE_WIDTH = GRAPH_LANE_WIDTH
41
+ NODE_RADIUS = GRAPH_NODE_SIDE / 2
42
+ EDGE_WIDTH = 1.5
43
+ BADGE_PADDING = SPACING_XS
44
+ BADGE_GAP = SPACING_XS
45
+ BADGE_BORDER_WIDTH = 1
46
+ BADGE_POINTER_WIDTH = 8
47
+ # Local branches, remote branches and HEAD draw frame and text in one color; tags use the separate colors below.
48
+ BADGE_COLOR_BY_KIND: dict[ReferenceKind, str] = {ReferenceKind.HEADS: "#008000", ReferenceKind.REMOTES: "#c00000", ReferenceKind.HEAD: "#555555"}
49
+ BADGE_LIGHT_FILL_COLOR = "#ffffff"
50
+ # Git Extensions draws a tag from a dark blue base color: its text is that color mixed 25 % toward black and its frame
51
+ # the same color mixed 50 % toward white. Both values were read on a capture of its revision graph.
52
+ TAG_BADGE_TEXT_LIGHT_COLOR = "#000068"
53
+ TAG_BADGE_FRAME_LIGHT_COLOR = "#7f7fc5"
54
+ # Dark theme tag label, chosen on captures of the graph under the macOS dark appearance, on a chosen row as on the
55
+ # others: a blue inside keeps tags blue as in the light graph, the white text reads at 7.0:1 on it, and a lighter blue
56
+ # frame sets the label apart from the dark graph around it.
57
+ TAG_BADGE_FILL_DARK_COLOR = "#3d5a85"
58
+ TAG_BADGE_FRAME_DARK_COLOR = "#9db4e0"
59
+ TAG_BADGE_TEXT_DARK_COLOR = "#ffffff"
60
+ LANE_COLORS = ("#18b52b", "#ed3030", "#e84d9a", "#2677d9", "#a35bd5", "#ca8a04")
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class BadgeColors:
65
+ """Colors of one reference label in the graph: its frame line, its text and the inside of the frame."""
66
+
67
+ frame: QColor
68
+ text: QColor
69
+ fill: QColor
70
+
71
+
72
+ def badge_colors(kind: ReferenceKind, palette: QPalette) -> BadgeColors:
73
+ """Pick the label colors for a reference kind, the one place where the graph decides how a label looks.
74
+
75
+ Tags follow Git Extensions with a lighter frame than their text on a white label; in a dark theme they are filled
76
+ with blue and written in white instead, so a white label does not glare in a dark graph and tags stay blue. Every
77
+ other kind keeps a single color for frame and text on a white label, whatever the theme.
78
+ """
79
+ if kind == ReferenceKind.TAGS and is_dark_palette(palette):
80
+ return BadgeColors(QColor(TAG_BADGE_FRAME_DARK_COLOR), QColor(TAG_BADGE_TEXT_DARK_COLOR), QColor(TAG_BADGE_FILL_DARK_COLOR))
81
+ if kind == ReferenceKind.TAGS:
82
+ return BadgeColors(QColor(TAG_BADGE_FRAME_LIGHT_COLOR), QColor(TAG_BADGE_TEXT_LIGHT_COLOR), QColor(BADGE_LIGHT_FILL_COLOR))
83
+ color = QColor(BADGE_COLOR_BY_KIND.get(kind, BADGE_COLOR_BY_KIND[ReferenceKind.HEAD]))
84
+ return BadgeColors(color, color, QColor(BADGE_LIGHT_FILL_COLOR))
85
+
86
+
87
+ def reference_label(reference: Reference) -> str:
88
+ """Shorten only the namespace; remote and branch names remain distinguishable."""
89
+ return reference.full_name.removeprefix(reference.kind.prefix)
90
+
91
+
92
+ def visible_references(references: list[Reference]) -> list[Reference]:
93
+ """Hide redundant HEAD aliases and put the current branch and remote default first."""
94
+ preferred_name_set = set()
95
+ displayed_references = []
96
+ for reference in references:
97
+ if reference.symbolic_target:
98
+ preferred_name_set.add(reference.symbolic_target)
99
+ if reference.kind == ReferenceKind.HEAD or reference.full_name.endswith("/HEAD"):
100
+ continue
101
+ displayed_references.append(reference)
102
+ return sorted(displayed_references, key=lambda reference: (not reference.is_head, reference.full_name not in preferred_name_set, reference.kind, reference.full_name))
103
+
104
+
105
+ def graph_content_left(option: QStyleOptionViewItem, index: QModelIndex) -> int:
106
+ """Place decorations beyond the occupied graph lanes."""
107
+ row = index.data(GRAPH_ROLE)
108
+ left = option.rect.left() + BADGE_GAP
109
+ if row is not None:
110
+ left += (row.lane_count - ROW_CENTER) * LANE_WIDTH + NODE_RADIUS
111
+ return int(left)
112
+
113
+
114
+ def parent_marker_rectangle(option: QStyleOptionViewItem, index: QModelIndex) -> QRectF:
115
+ """Reserve a logical icon cell only for the known committed parent row."""
116
+ if not index.data(COMMITTED_PARENT_ROLE):
117
+ return QRectF()
118
+ return QRectF(graph_content_left(option, index), option.rect.center().y() - ICON_SIDE / 2, ICON_SIDE, ICON_SIDE)
119
+
120
+
121
+ def reference_content_left(option: QStyleOptionViewItem, index: QModelIndex) -> int:
122
+ """Apply the same marker spacing to painting and reference hit testing."""
123
+ rectangle = parent_marker_rectangle(option, index)
124
+ return int(rectangle.right() + BADGE_GAP) if not rectangle.isEmpty() else graph_content_left(option, index)
125
+
126
+
127
+ def reference_badges(option: QStyleOptionViewItem, index: QModelIndex) -> list[tuple[Reference, QRectF, str, int]]:
128
+ """Share the painted badge rectangles with exact-reference mouse hit testing."""
129
+ left = reference_content_left(option, index)
130
+ badge_height = min(option.rect.height() - BADGE_GAP, option.fontMetrics.height() + BADGE_BORDER_WIDTH * 2)
131
+ top = option.rect.center().y() - badge_height / 2
132
+ badges: list[tuple[Reference, QRectF, str, int]] = []
133
+ message_width = option.fontMetrics.averageCharWidth() * GRAPH_MESSAGE_MIN_CHARACTERS
134
+ for reference in visible_references(index.data(REFERENCE_ROLE) or []):
135
+ remaining = option.rect.right() - left - message_width - BADGE_GAP
136
+ if remaining <= BADGE_PADDING * 2:
137
+ break
138
+ pointer_width = BADGE_POINTER_WIDTH if reference.kind in (ReferenceKind.HEADS, ReferenceKind.REMOTES) else 0
139
+ metrics = QFontMetrics(reference_font(option.font, reference))
140
+ label = metrics.elidedText(reference_label(reference), Qt.TextElideMode.ElideRight, max(0, remaining - BADGE_PADDING * 2 - pointer_width))
141
+ width = metrics.horizontalAdvance(label) + BADGE_PADDING * 2 + pointer_width
142
+ badges.append((reference, QRectF(left, top, width, badge_height), label, pointer_width))
143
+ left += width + BADGE_GAP
144
+ return badges
145
+
146
+
147
+ class GraphDelegate(QStyledItemDelegate):
148
+ """Draw ancestry, references and the message together so text follows each row's graph."""
149
+
150
+ def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
151
+ """Keep the marker inside the row at normal and enlarged text sizes."""
152
+ size = super().sizeHint(option, index)
153
+ if index.data(COMMITTED_PARENT_ROLE):
154
+ size.setWidth(size.width() + ICON_SIDE + BADGE_GAP)
155
+ size.setHeight(max(size.height(), ICON_SIDE + BADGE_GAP))
156
+ return size
157
+
158
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
159
+ """Place labels next to the occupied lanes while retaining native row selection."""
160
+ preview = index.data(MESSAGE_PREVIEW_ROLE) or ""
161
+ row = index.data(GRAPH_ROLE)
162
+ item_option = QStyleOptionViewItem(option)
163
+ self.initStyleOption(item_option, index)
164
+ subject = item_option.text
165
+ item_option.text = ""
166
+ style = option.widget.style()
167
+ style.drawControl(style.ControlElement.CE_ItemViewItem, item_option, painter, option.widget)
168
+ painter.save()
169
+ painter.setClipRect(option.rect)
170
+ painter.setRenderHint(painter.RenderHint.Antialiasing)
171
+ painter.setFont(item_option.font)
172
+ if row is not None:
173
+ _paint_graph(painter, option, index)
174
+ marker = parent_marker_rectangle(option, index)
175
+ if not marker.isEmpty():
176
+ painter.fillRect(marker, QColor(PARENT_MARKER_BACKGROUND))
177
+ arrow = marker.adjusted(PARENT_MARKER_INSET, PARENT_MARKER_INSET, -PARENT_MARKER_INSET, -PARENT_MARKER_INSET)
178
+ shoulder = arrow.left() + arrow.width() * PARENT_MARKER_SHOULDER_RATIO
179
+ shaft = arrow.height() * PARENT_MARKER_SHAFT_RATIO
180
+ painter.setPen(QPen(QColor(PARENT_MARKER_BORDER), PARENT_MARKER_BORDER_WIDTH))
181
+ painter.setBrush(QColor(PARENT_MARKER_FILL))
182
+ painter.drawPolygon(QPolygonF([
183
+ QPointF(arrow.left(), arrow.center().y() - shaft), QPointF(shoulder, arrow.center().y() - shaft),
184
+ QPointF(shoulder, arrow.top()), QPointF(arrow.right(), arrow.center().y()), QPointF(shoulder, arrow.bottom()),
185
+ QPointF(shoulder, arrow.center().y() + shaft), QPointF(arrow.left(), arrow.center().y() + shaft),
186
+ ]))
187
+ left = reference_content_left(option, index)
188
+ badge_height = min(option.rect.height() - BADGE_GAP, option.fontMetrics.height() + BADGE_BORDER_WIDTH * 2)
189
+ top = option.rect.center().y() - badge_height / 2
190
+ for reference, rectangle, label, pointer_width in reference_badges(option, index):
191
+ painter.setFont(reference_font(option.font, reference))
192
+ left = rectangle.left()
193
+ colors = badge_colors(reference.kind, option.palette)
194
+ painter.setPen(QPen(colors.frame, BADGE_BORDER_WIDTH))
195
+ painter.setBrush(colors.fill)
196
+ # A pen is centered on the outline: drawn on the rectangle's own edges, a one-pixel frame straddles two
197
+ # device pixels and anti-aliasing paints both at half strength, lighter than the label's color. Moving the
198
+ # outline half a pen inward keeps the whole line inside the label and, at whole-number scales, on one pixel.
199
+ frame_inset = BADGE_BORDER_WIDTH / 2
200
+ painter.drawRect(rectangle.adjusted(frame_inset, frame_inset, -frame_inset, -frame_inset))
201
+ if pointer_width:
202
+ pointer_left = left + BADGE_PADDING
203
+ pointer_top = top + BADGE_GAP
204
+ pointer_bottom = top + badge_height - BADGE_GAP
205
+ pointer = QPolygonF([QPointF(pointer_left, pointer_top), QPointF(pointer_left + pointer_width - BADGE_GAP, rectangle.center().y()), QPointF(pointer_left, pointer_bottom)])
206
+ painter.setBrush(colors.frame if reference.is_head else colors.fill)
207
+ painter.drawPolygon(pointer)
208
+ painter.setPen(QPen(colors.text))
209
+ painter.drawText(rectangle.adjusted(pointer_width, 0, 0, 0), Qt.AlignmentFlag.AlignCenter, label)
210
+ left = int(rectangle.right() + BADGE_GAP)
211
+ painter.setFont(item_option.font)
212
+ selected = bool(option.state & style.StateFlag.State_Selected)
213
+ group = QPalette.ColorGroup.Active if option.state & style.StateFlag.State_Active else QPalette.ColorGroup.Inactive
214
+ painter.setPen(option.palette.color(group, QPalette.ColorRole.HighlightedText if selected else QPalette.ColorRole.Text))
215
+ text_rectangle = option.rect.adjusted(left - option.rect.left(), 0, 0, 0)
216
+ text = option.fontMetrics.elidedText(subject, Qt.TextElideMode.ElideRight, max(0, text_rectangle.width()))
217
+ painter.drawText(text_rectangle, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text)
218
+ preview_offset = option.fontMetrics.horizontalAdvance(subject) + BADGE_GAP
219
+ preview_rectangle = text_rectangle.adjusted(preview_offset, 0, 0, 0)
220
+ if preview and preview_rectangle.width() > 0:
221
+ preview_color = QPalette.ColorRole.HighlightedText if selected else QPalette.ColorRole.PlaceholderText
222
+ painter.setPen(option.palette.color(group, preview_color))
223
+ text = option.fontMetrics.elidedText(preview, Qt.TextElideMode.ElideRight, preview_rectangle.width())
224
+ painter.drawText(preview_rectangle, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text)
225
+ painter.restore()
226
+
227
+
228
+ class AuthorDelegate(QStyledItemDelegate):
229
+ """Show an available photo or the author's colored initials beside their name."""
230
+
231
+ def __init__(self, avatar: HistoryAvatar) -> None:
232
+ """Read viewport-owned images without requesting network work during painting."""
233
+ super().__init__(avatar.view)
234
+ self.avatar = avatar
235
+
236
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
237
+ """Use the same fallback identity as the detail panel until a cached photo is ready."""
238
+ item_option = QStyleOptionViewItem(option)
239
+ self.initStyleOption(item_option, index)
240
+ name, email = index.data(AUTHOR_IDENTITY_ROLE)
241
+ item_option.text = ""
242
+ style = option.widget.style()
243
+ style.drawControl(style.ControlElement.CE_ItemViewItem, item_option, painter, option.widget)
244
+ painter.save()
245
+ painter.setClipRect(option.rect)
246
+ painter.setFont(item_option.font)
247
+ side = option.rect.height() - BADGE_GAP
248
+ rectangle = QRectF(option.rect.left() + BADGE_GAP, option.rect.top() + BADGE_GAP / 2, side, side)
249
+ painter.fillRect(rectangle, QColor(author_color(name, email)))
250
+ photo = self.avatar.image_by_email.get(normalize_author_email(email))
251
+ if photo is not None:
252
+ painter.setRenderHint(painter.RenderHint.SmoothPixmapTransform)
253
+ painter.drawImage(rectangle, photo)
254
+ else:
255
+ painter.setPen(QColor("white"))
256
+ painter.drawText(rectangle, Qt.AlignmentFlag.AlignCenter, author_initials(name))
257
+ selected = bool(option.state & style.StateFlag.State_Selected)
258
+ group = QPalette.ColorGroup.Active if option.state & style.StateFlag.State_Active else QPalette.ColorGroup.Inactive
259
+ painter.setPen(option.palette.color(group, QPalette.ColorRole.HighlightedText if selected else QPalette.ColorRole.Text))
260
+ text_rectangle = option.rect.adjusted(side + BADGE_GAP * 2, 0, 0, 0)
261
+ text = option.fontMetrics.elidedText(name, Qt.TextElideMode.ElideRight, max(0, text_rectangle.width()))
262
+ painter.drawText(text_rectangle, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text)
263
+ painter.restore()
264
+
265
+
266
+ def _paint_graph(painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
267
+ """Keep real edges continuous and distinguish branch tips from intermediate commits."""
268
+ row = index.data(GRAPH_ROLE)
269
+ relative_oid_set = index.data(RELATIVE_OID_SET_ROLE)
270
+ color = graph_color(row.oid, row.color_index, relative_oid_set, option.palette)
271
+ pen = QPen(color, EDGE_WIDTH)
272
+ for segment in row.segments:
273
+ pen.setColor(graph_color(segment.child_oid, segment.color_index, relative_oid_set, option.palette))
274
+ pen.setStyle(Qt.PenStyle.DashLine if segment.kind == "unavailable" else Qt.PenStyle.SolidLine)
275
+ painter.setPen(pen)
276
+ start = QPointF(option.rect.left() + (segment.start_lane + ROW_CENTER) * LANE_WIDTH, option.rect.top() + segment.start_position * option.rect.height())
277
+ end = QPointF(option.rect.left() + (segment.end_lane + ROW_CENTER) * LANE_WIDTH, option.rect.top() + segment.end_position * option.rect.height())
278
+ painter.drawLine(start, end)
279
+ if segment.kind == "unavailable":
280
+ painter.drawLine(QPointF(end.x() - NODE_RADIUS, end.y() - NODE_RADIUS), QPointF(end.x() + NODE_RADIUS, end.y() - NODE_RADIUS))
281
+ pen.setStyle(Qt.PenStyle.SolidLine)
282
+ pen.setColor(color)
283
+ painter.setPen(pen)
284
+ painter.setBrush(color)
285
+ center = QPointF(option.rect.left() + (row.lane + ROW_CENTER) * LANE_WIDTH, option.rect.top() + ROW_CENTER * option.rect.height())
286
+ if index.data(HEAD_ROLE) or index.data(TIP_ROLE):
287
+ if index.data(HEAD_ROLE):
288
+ painter.setPen(QPen(QColor("black"), BADGE_BORDER_WIDTH))
289
+ painter.drawRect(QRectF(center.x() - NODE_RADIUS, center.y() - NODE_RADIUS, GRAPH_NODE_SIDE, GRAPH_NODE_SIDE))
290
+ else:
291
+ painter.drawEllipse(center, NODE_RADIUS, NODE_RADIUS)
292
+
293
+
294
+ def graph_color(oid: str, color_index: int, relative_oid_set: set[str] | None, palette: QPalette) -> QColor:
295
+ """Color shared ancestors normally and mute nodes and edges outside HEAD's history."""
296
+ if relative_oid_set is not None and oid not in relative_oid_set:
297
+ return palette.color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text)
298
+ return QColor(LANE_COLORS[color_index % len(LANE_COLORS)])