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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- 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)])
|