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,668 @@
|
|
|
1
|
+
"""Lay out the dialogs of GitPane with native form metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt
|
|
10
|
+
from PySide6.QtGui import QColor, QPainter, QPalette
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QAbstractScrollArea,
|
|
13
|
+
QApplication,
|
|
14
|
+
QComboBox,
|
|
15
|
+
QDialog,
|
|
16
|
+
QFormLayout,
|
|
17
|
+
QGridLayout,
|
|
18
|
+
QHBoxLayout,
|
|
19
|
+
QLabel,
|
|
20
|
+
QLayout,
|
|
21
|
+
QLineEdit,
|
|
22
|
+
QPushButton,
|
|
23
|
+
QScrollArea,
|
|
24
|
+
QSizePolicy,
|
|
25
|
+
QStyle,
|
|
26
|
+
QStyleOptionButton,
|
|
27
|
+
QTabWidget,
|
|
28
|
+
QVBoxLayout,
|
|
29
|
+
QWidget,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from gitpane.config import SCREEN_MARGIN, SPACING_MD, SPACING_SM, control_height, fit_window, frame_position_inside, known_frame_margins, uncounted_decoration_estimate, unknown_decoration_estimate, window_placement
|
|
33
|
+
from gitpane.ui.look.theme_color import is_dark_palette, theme_band_color, theme_line_color
|
|
34
|
+
from gitpane.ui.look.theme_look import follow_theme
|
|
35
|
+
from gitpane.ui.look.theme_palette import BODY_LIGHT_COLOR, WINDOWS_CONTROL_LIGHT_COLOR
|
|
36
|
+
from gitpane.ui.look.windows_control_look import BUTTON_TEXT_SIDE_PADDING, WINDOWS_CAPTURE_TEXT_HEIGHT, apply_windows_button_look, plain_form_color, windows_field_height
|
|
37
|
+
from gitpane.ui.widget.long_text import SelectionSummaryRow, WrappingLabel, largest_client_size
|
|
38
|
+
from gitpane.ui.window_control.compositor_growth_cap import decoration_height_estimate, growth_frame_cap, usable_height_estimate
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
|
|
43
|
+
from PySide6.QtGui import QPaintEvent
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Git Extensions draws the buttons of its dialogs on a light grey strip under the body, the strip starting with a
|
|
47
|
+
# slightly darker line. These light theme values were sampled from its Cherry pick commit capture.
|
|
48
|
+
BUTTON_BAND_LIGHT_COLOR = "#eeeeee"
|
|
49
|
+
BUTTON_BAND_SEPARATOR_LIGHT_COLOR = "#d9d9d9"
|
|
50
|
+
BUTTON_BAND_SEPARATOR_WIDTH = 1
|
|
51
|
+
# Space Qt's push buttons leave between their icon and their text.
|
|
52
|
+
PUSH_BUTTON_ICON_TEXT_SPACING = 4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ButtonBandStyle:
|
|
57
|
+
"""Where a button band puts its buttons and how it looks, measured on the Windows window it reproduces."""
|
|
58
|
+
|
|
59
|
+
space_above: int
|
|
60
|
+
space_below: int
|
|
61
|
+
right_margin: int
|
|
62
|
+
spacing: int
|
|
63
|
+
separator: bool
|
|
64
|
+
light_band_color: str
|
|
65
|
+
# The smallest size of a button the band sizes itself: the standard button of the reproduced window.
|
|
66
|
+
button_size: QSize
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BandButtonSizing(Enum):
|
|
70
|
+
"""Who sizes the buttons of a button band: the band itself, or the window that copies their sizes from its capture."""
|
|
71
|
+
|
|
72
|
+
BAND = "band"
|
|
73
|
+
WINDOW = "window"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def text_side_padding(self) -> int:
|
|
77
|
+
"""Return the room the light Windows look keeps between a button's text and each side of its frame.
|
|
78
|
+
|
|
79
|
+
A button the band sizes is at least the standard button of its band and grows with its text, keeping this room
|
|
80
|
+
so a long caption never touches the frame. A window that sizes its buttons from its capture keeps the
|
|
81
|
+
capture's look, which has none, so its buttons do not change.
|
|
82
|
+
"""
|
|
83
|
+
if self == BandButtonSizing.BAND:
|
|
84
|
+
return BUTTON_TEXT_SIDE_PADDING
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Size of the Clone button of the capture; the other buttons of the band never get smaller.
|
|
89
|
+
FORM_BAND_BUTTON_MIN_SIZE = QSize(75, 23)
|
|
90
|
+
# Git Extensions forms: a one-pixel line, then 8 px above the buttons and 9 px below, 14 px from the right edge, 8 px apart.
|
|
91
|
+
FORM_BUTTON_BAND = ButtonBandStyle(space_above=8, space_below=9, right_margin=14, spacing=8, separator=True, light_band_color=BUTTON_BAND_LIGHT_COLOR,
|
|
92
|
+
button_size=FORM_BAND_BUTTON_MIN_SIZE)
|
|
93
|
+
# The Windows message box of the Git Extensions capture: a 32 px icon 19 px from the left and 23 px from the top and
|
|
94
|
+
# bottom of the white area, the question 8 px after the icon and centred on it, 29 px free on its right, 73 x 21 buttons.
|
|
95
|
+
MESSAGE_BOX_BODY_MARGINS = QMargins(19, 23, 29, 23)
|
|
96
|
+
MESSAGE_BOX_ICON_SIDE = 32
|
|
97
|
+
MESSAGE_BOX_ICON_TO_TEXT_SPACE = 8
|
|
98
|
+
MESSAGE_BOX_BUTTON_SIZE = QSize(73, 21)
|
|
99
|
+
# Windows message box: no line, 11 px above the buttons and 10 px below, 16 px from the right edge, 9 px apart.
|
|
100
|
+
MESSAGE_BOX_BUTTON_BAND = ButtonBandStyle(space_above=11, space_below=10, right_margin=16, spacing=9, separator=False, light_band_color=WINDOWS_CONTROL_LIGHT_COLOR,
|
|
101
|
+
button_size=MESSAGE_BOX_BUTTON_SIZE)
|
|
102
|
+
# Grid of the Git Extensions forms that put a caption, a field and a button on each row, measured on the Clone window
|
|
103
|
+
# capture in client pixels at 100 %: captions from x 19, fields from x 149 to 532, Browse buttons (540, y, 92, 23), one
|
|
104
|
+
# row every 31 px, and the block under the rows 17 px below the last one. Every window built on this grid shares these
|
|
105
|
+
# values, so a change here moves all of them together.
|
|
106
|
+
# The bottom margin is small because a form of fixed height leaves the space under its content from the window height:
|
|
107
|
+
# a message added to the form uses that space rather than making the body scroll.
|
|
108
|
+
FORM_GRID_BODY_MARGINS = QMargins(14, 16, 16, 2)
|
|
109
|
+
FORM_GRID_CAPTION_INDENT = 5
|
|
110
|
+
FORM_GRID_CAPTION_WIDTH = 127
|
|
111
|
+
FORM_GRID_COLUMN_SPACING = 8
|
|
112
|
+
FORM_GRID_ROW_SPACING = 8
|
|
113
|
+
FORM_GRID_SIDE_BUTTON_MIN_WIDTH = 92
|
|
114
|
+
FORM_GRID_ROWS_TO_BLOCK_SPACE = 17
|
|
115
|
+
CAPTION_COLUMN, FIELD_COLUMN, BUTTON_COLUMN = 0, 1, 2
|
|
116
|
+
# The narrowest space the Git Extensions captures leave between the end of a caption's text and the control after it:
|
|
117
|
+
# 7 empty pixel columns after "Select branches" in Delete branch, where Qt's text box at Segoe UI 9 pt ends one column
|
|
118
|
+
# after the ink (the other captions leave 11 to 14). A caption column that a wider system text pushes past its capture's
|
|
119
|
+
# width keeps this space after its widest caption; with the captures' font every caption already leaves more, so the
|
|
120
|
+
# column keeps the capture's width.
|
|
121
|
+
CAPTION_TO_FIELD_MIN_SPACE = 6
|
|
122
|
+
BROWSE_TEXT = "Browse…"
|
|
123
|
+
# What QLayout.indexOf answers for a widget the layout does not hold.
|
|
124
|
+
WIDGET_NOT_IN_LAYOUT_INDEX = -1
|
|
125
|
+
|
|
126
|
+
def apply_form_metrics(widget: QWidget) -> None:
|
|
127
|
+
"""Size ordinary fields and text buttons, preserving native and zero-margin sublayouts."""
|
|
128
|
+
if isinstance(widget, QLabel):
|
|
129
|
+
if isinstance(widget, WrappingLabel) and widget.wordWrap() and widget.buddy() is None and not isinstance(widget.parentWidget(), SelectionSummaryRow):
|
|
130
|
+
policy = widget.sizePolicy()
|
|
131
|
+
policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
|
|
132
|
+
widget.setSizePolicy(policy)
|
|
133
|
+
return
|
|
134
|
+
if isinstance(widget, (QComboBox, QPushButton, QLineEdit)):
|
|
135
|
+
if isinstance(widget, QLineEdit) and isinstance(widget.parentWidget(), QComboBox):
|
|
136
|
+
return
|
|
137
|
+
if isinstance(widget, QPushButton) and not widget.text():
|
|
138
|
+
return
|
|
139
|
+
widget.setMinimumHeight(control_height(widget.fontMetrics().height()))
|
|
140
|
+
return
|
|
141
|
+
if isinstance(widget, QScrollArea):
|
|
142
|
+
if widget.widget() is not None:
|
|
143
|
+
apply_form_metrics(widget.widget())
|
|
144
|
+
return
|
|
145
|
+
if isinstance(widget, QTabWidget):
|
|
146
|
+
for index in range(widget.count()):
|
|
147
|
+
apply_form_metrics(widget.widget(index))
|
|
148
|
+
return
|
|
149
|
+
if isinstance(widget, QAbstractScrollArea):
|
|
150
|
+
return
|
|
151
|
+
layout = widget.layout()
|
|
152
|
+
if layout is None:
|
|
153
|
+
return
|
|
154
|
+
# Workaround: PySide keeps the Python wrapper of every item QLayout.itemAt returns, as a child of the layout's
|
|
155
|
+
# wrapper, until the layout itself is deleted. A layout item is not a QObject, so Python is never told when Qt
|
|
156
|
+
# deletes one, which Qt does when a widget moves to another cell of its grid or leaves its layout: the wrapper stays
|
|
157
|
+
# registered at the freed address, and the next object Qt creates there and hands to Python (the copy of its text
|
|
158
|
+
# document a wrapping label measures) arrives as that dead layout item, whose missing methods then raise. The walk
|
|
159
|
+
# therefore never asks a layout for its items: it finds the nested layouts and the widgets as QObject children,
|
|
160
|
+
# whose deletion Python follows, and asks each layout whether it holds a widget.
|
|
161
|
+
layouts = _nested_layouts(layout)
|
|
162
|
+
for current in layouts:
|
|
163
|
+
current.setSpacing(SPACING_SM)
|
|
164
|
+
if any(current.getContentsMargins()):
|
|
165
|
+
current.setContentsMargins(SPACING_MD, SPACING_MD, SPACING_MD, SPACING_MD)
|
|
166
|
+
# Qt makes the widget that holds a layout the parent of every widget added to that layout or to a layout nested in it.
|
|
167
|
+
for child in widget.findChildren(QWidget, options=Qt.FindChildOption.FindDirectChildrenOnly):
|
|
168
|
+
if _is_held_by_one_of(child, layouts):
|
|
169
|
+
apply_form_metrics(child)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _nested_layouts(layout: QLayout) -> list[QLayout]:
|
|
173
|
+
"""Return a layout with every layout nested in it, found as QObject children: Qt makes a layout the parent of each layout added to it."""
|
|
174
|
+
layouts = []
|
|
175
|
+
pending_layouts = [layout]
|
|
176
|
+
while pending_layouts:
|
|
177
|
+
current = pending_layouts.pop()
|
|
178
|
+
layouts.append(current)
|
|
179
|
+
pending_layouts.extend(current.findChildren(QLayout, options=Qt.FindChildOption.FindDirectChildrenOnly))
|
|
180
|
+
return layouts
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_held_by_one_of(widget: QWidget, layouts: list[QLayout]) -> bool:
|
|
184
|
+
"""Tell whether one of the layouts holds the widget; asked by widget, so no layout item reaches Python."""
|
|
185
|
+
for layout in layouts:
|
|
186
|
+
if layout.indexOf(widget) != WIDGET_NOT_IN_LAYOUT_INDEX:
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def apply_left_aligned_form(form: QFormLayout) -> None:
|
|
192
|
+
"""Keep a form's captions and fields on the left and let its fields grow, whatever the style would choose.
|
|
193
|
+
|
|
194
|
+
These are Fusion's own values, written into the form so that it reads the same under every style: Qt's macOS style
|
|
195
|
+
centres a form, right-aligns its captions and keeps its fields at their own size.
|
|
196
|
+
"""
|
|
197
|
+
form.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
|
198
|
+
form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
199
|
+
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def fit_form(dialog: QDialog, available: QRect | None = None) -> None:
|
|
203
|
+
"""Apply shared spacing without turning the initial dialog size into a resize minimum."""
|
|
204
|
+
apply_form_metrics(dialog)
|
|
205
|
+
fit_window(dialog, 0, 0, available)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class BandedDialogColors:
|
|
210
|
+
"""Colors of a dialog drawn as a body above a button band."""
|
|
211
|
+
|
|
212
|
+
body: QColor
|
|
213
|
+
band: QColor
|
|
214
|
+
separator: QColor
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def banded_dialog_colors(palette: QPalette, band_style: ButtonBandStyle = FORM_BUTTON_BAND) -> BandedDialogColors:
|
|
218
|
+
"""Use the Git Extensions colors in a light theme and greys worked out from the palette in a dark one.
|
|
219
|
+
|
|
220
|
+
The captures only exist in a light theme; copying their white body into a dark desktop would glare. A dark body takes
|
|
221
|
+
the window color, so fields, lists and check boxes, drawn on the darker input background, stand out from it; the band
|
|
222
|
+
is a grey a little lighter than the body, and its line the theme's line grey.
|
|
223
|
+
"""
|
|
224
|
+
if is_dark_palette(palette):
|
|
225
|
+
return BandedDialogColors(palette.color(QPalette.ColorRole.Window), theme_band_color(palette), theme_line_color(palette))
|
|
226
|
+
return BandedDialogColors(QColor(BODY_LIGHT_COLOR), QColor(band_style.light_band_color), QColor(BUTTON_BAND_SEPARATOR_LIGHT_COLOR))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def paint_plain_form(dialog: QDialog) -> None:
|
|
230
|
+
"""Paint a dialog's background with the plain-window color of the current application palette."""
|
|
231
|
+
palette = dialog.palette()
|
|
232
|
+
palette.setColor(QPalette.ColorRole.Window, plain_form_color(QApplication.palette()))
|
|
233
|
+
dialog.setPalette(palette)
|
|
234
|
+
dialog.setAutoFillBackground(True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def install_plain_form_palette(dialog: QDialog) -> None:
|
|
238
|
+
"""Paint a dialog as one plain surface of the plain-window color, like the Git Extensions windows without a button band.
|
|
239
|
+
|
|
240
|
+
The color is laid again whenever the application switches between light and dark while the dialog is open.
|
|
241
|
+
"""
|
|
242
|
+
paint_plain_form(dialog)
|
|
243
|
+
follow_theme(dialog, lambda: paint_plain_form(dialog))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def refresh_style_sheet_fonts(window: QWidget) -> None:
|
|
247
|
+
"""Make every child of the window that carries a style sheet take the font it inherits now.
|
|
248
|
+
|
|
249
|
+
Workaround: Qt keeps the font a child with a style sheet resolved when the sheet was polished, so an application
|
|
250
|
+
font change (Windows' "Make text bigger") reaches every other control but leaves these ones at the old size.
|
|
251
|
+
Polishing them again makes them resolve the inherited font again; it changes nothing else.
|
|
252
|
+
"""
|
|
253
|
+
for widget in window.findChildren(QWidget):
|
|
254
|
+
if not widget.styleSheet():
|
|
255
|
+
continue
|
|
256
|
+
style = widget.style()
|
|
257
|
+
style.unpolish(widget)
|
|
258
|
+
style.polish(widget)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class FormCaption(QLabel):
|
|
262
|
+
"""Caption of a form's left column: the capture's column width, or its own text's width when that is wider.
|
|
263
|
+
|
|
264
|
+
The width is reported as both the size hint and the minimum size hint rather than set as an explicit minimum
|
|
265
|
+
width: an explicit minimum is what a layout short of room squeezes the caption down to, which cut "Repository to
|
|
266
|
+
clone:" to the capture's width under a larger system font. Reported this way, the caption follows each font change
|
|
267
|
+
on its own and a layout can never make it narrower than its text.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def sizeHint(self) -> QSize:
|
|
271
|
+
"""Return the text's own size, widened to the capture's caption column."""
|
|
272
|
+
hint = super().sizeHint()
|
|
273
|
+
return QSize(max(FORM_GRID_CAPTION_WIDTH, hint.width()), hint.height())
|
|
274
|
+
|
|
275
|
+
def minimumSizeHint(self) -> QSize:
|
|
276
|
+
"""Refuse any width below the size hint, so the text is never cut."""
|
|
277
|
+
return self.sizeHint()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def add_form_caption(grid: QGridLayout, row: int, text: str, field: QWidget) -> QLabel:
|
|
281
|
+
"""Put a caption of the left column of a Git Extensions form on its row, naming its field, and return it.
|
|
282
|
+
|
|
283
|
+
The caption has the indent and width measured on the Git Extensions Clone window capture, so every form built on
|
|
284
|
+
this grid lines its fields up at the same x. That width is a minimum: the caption keeps exactly its text's width
|
|
285
|
+
when a larger system text size needs more, following each font change on its own, so the text is never cut.
|
|
286
|
+
"""
|
|
287
|
+
caption = FormCaption(text)
|
|
288
|
+
caption_policy = caption.sizePolicy()
|
|
289
|
+
caption_policy.setHorizontalPolicy(QSizePolicy.Policy.Fixed)
|
|
290
|
+
caption.setSizePolicy(caption_policy)
|
|
291
|
+
caption.setContentsMargins(FORM_GRID_CAPTION_INDENT, 0, 0, 0)
|
|
292
|
+
caption.setBuddy(field)
|
|
293
|
+
grid.addWidget(caption, row, CAPTION_COLUMN)
|
|
294
|
+
return caption
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def fit_caption_to_other_form(caption: QLabel, other_captions: Sequence[str]) -> None:
|
|
298
|
+
"""Give a form caption the width the widest caption of another form needs in the same font, so both put their fields at one x.
|
|
299
|
+
|
|
300
|
+
Each caption of ``add_form_caption`` keeps the capture's width, or exactly its text's width when a larger system
|
|
301
|
+
text needs more. The other form's captions are measured the same way, so the column stays at the capture's width
|
|
302
|
+
where every caption fits in it (the Windows default text size) and follows the widest one otherwise.
|
|
303
|
+
"""
|
|
304
|
+
width = FORM_GRID_CAPTION_WIDTH
|
|
305
|
+
for text in (caption.text(), *other_captions):
|
|
306
|
+
probe = QLabel(text)
|
|
307
|
+
probe.setFont(caption.font())
|
|
308
|
+
probe.setContentsMargins(caption.contentsMargins())
|
|
309
|
+
width = max(width, probe.sizeHint().width())
|
|
310
|
+
caption.setMinimumWidth(width)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def fit_side_buttons(buttons: Sequence[QPushButton], other_form_texts: Sequence[str] = ()) -> None:
|
|
314
|
+
"""Give the buttons of a form's right-hand column the field height and one common width, for their current font.
|
|
315
|
+
|
|
316
|
+
The width is the capture's Browse… width, or more when a caption needs it; set any icon first, since it counts in
|
|
317
|
+
the width. Both sizes follow the text, so call it again after a font change. ``other_form_texts`` are the captions
|
|
318
|
+
of another form's plain side buttons that this column lines up with: they are measured with the first button's
|
|
319
|
+
font and look, so both columns take one width when a larger system text widens one of them.
|
|
320
|
+
"""
|
|
321
|
+
side_width = FORM_GRID_SIDE_BUTTON_MIN_WIDTH
|
|
322
|
+
for button in buttons:
|
|
323
|
+
side_width = max(side_width, button.sizeHint().width())
|
|
324
|
+
for text in other_form_texts:
|
|
325
|
+
probe = QPushButton(text)
|
|
326
|
+
probe.setFont(buttons[0].font())
|
|
327
|
+
probe.setStyleSheet(buttons[0].styleSheet())
|
|
328
|
+
side_width = max(side_width, probe.sizeHint().width())
|
|
329
|
+
for button in buttons:
|
|
330
|
+
button.setFixedSize(side_width, windows_field_height(button))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def push_button_content_size(button: QPushButton) -> QSize:
|
|
334
|
+
"""Return the room a push button's icon and text take, without the frame and padding its style draws around them."""
|
|
335
|
+
text_size = button.fontMetrics().size(Qt.TextFlag.TextShowMnemonic, button.text())
|
|
336
|
+
if button.icon().isNull():
|
|
337
|
+
return text_size
|
|
338
|
+
icon_size = button.iconSize()
|
|
339
|
+
return QSize(icon_size.width() + PUSH_BUTTON_ICON_TEXT_SPACING + text_size.width(), max(icon_size.height(), text_size.height()))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def push_button_shows_whole_content(button: QPushButton, size: QSize) -> bool:
|
|
343
|
+
"""Tell whether a push button of this size holds its whole icon and text inside the area its style draws them in."""
|
|
344
|
+
option = QStyleOptionButton()
|
|
345
|
+
button.initStyleOption(option)
|
|
346
|
+
option.rect = QRect(QPoint(0, 0), size)
|
|
347
|
+
contents = button.style().subElementRect(QStyle.SubElement.SE_PushButtonContents, option, button)
|
|
348
|
+
content = push_button_content_size(button)
|
|
349
|
+
return content.width() <= contents.width() and content.height() <= contents.height()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def capture_control_size(widget: QWidget, capture_size: QSize) -> QSize:
|
|
353
|
+
"""Return the size a control copied from a capture needs: at least the capture's size, at least its text's size.
|
|
354
|
+
|
|
355
|
+
The captures were taken on Windows with Segoe UI 9 pt, where every text fits its control. A wider font (Ubuntu
|
|
356
|
+
11 pt, or a text size the user enlarged) would cut the text at the capture's size, so the control gets the room its
|
|
357
|
+
current text, icon and look ask for; it never becomes smaller than the capture.
|
|
358
|
+
|
|
359
|
+
A push button at the captures' text height keeps exactly the capture's size whenever its icon and text fit inside
|
|
360
|
+
the frame its style draws. Its size hint is not used there: the dark Fusion button asks for 80 px at least and for
|
|
361
|
+
more padding than the Windows button of the capture, which would move every control of the captured layout while
|
|
362
|
+
the text already fits. A taller text gets at least the size hint, as any other control.
|
|
363
|
+
"""
|
|
364
|
+
if isinstance(widget, QPushButton) and widget.fontMetrics().height() <= WINDOWS_CAPTURE_TEXT_HEIGHT and push_button_shows_whole_content(widget, capture_size):
|
|
365
|
+
return QSize(capture_size)
|
|
366
|
+
return capture_size.expandedTo(widget.sizeHint())
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def fit_capture_control(widget: QWidget, capture_size: QSize) -> None:
|
|
370
|
+
"""Fix a control at its capture's size, or larger when its text needs it; call it again after a font or look change.
|
|
371
|
+
|
|
372
|
+
With the captures' font the control keeps exactly the capture's size, so the Windows windows do not change.
|
|
373
|
+
"""
|
|
374
|
+
widget.setFixedSize(capture_control_size(widget, capture_size))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def caption_column_need(caption: QLabel) -> int:
|
|
378
|
+
"""Return the width a caption of a form's left column needs: its text, then the space every caption keeps before its control.
|
|
379
|
+
|
|
380
|
+
The space is part of the caption's own width because the captured forms put their controls right after the caption
|
|
381
|
+
column, with no layout spacing between them.
|
|
382
|
+
"""
|
|
383
|
+
return caption.sizeHint().width() + CAPTION_TO_FIELD_MIN_SPACE
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def fit_caption_column(captions: Sequence[QLabel], capture_width: int) -> int:
|
|
387
|
+
"""Give the captions of a form's left column one width, the capture's or what the widest caption needs when wider, and return it.
|
|
388
|
+
|
|
389
|
+
The Git Extensions captures were taken with Segoe UI 9 pt, where every caption fits its column with room to spare.
|
|
390
|
+
A wider font (Ubuntu 11 pt, macOS 13 pt, or a text size the user enlarged) would cut a caption held at the capture's
|
|
391
|
+
width, so the column takes the width its widest text needs plus the space kept before the controls; the fields
|
|
392
|
+
after it move right and the window grows when it is shown. With the captures' font the column keeps exactly the
|
|
393
|
+
capture's width. Call it again after a font change, and move anything lined up on the column (a row indented by
|
|
394
|
+
the column's width) by the width returned.
|
|
395
|
+
"""
|
|
396
|
+
width = capture_width
|
|
397
|
+
for caption in captions:
|
|
398
|
+
width = max(width, caption_column_need(caption))
|
|
399
|
+
for caption in captions:
|
|
400
|
+
caption.setFixedWidth(width)
|
|
401
|
+
return width
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def apply_side_button_look(buttons: Sequence[QPushButton], other_form_texts: Sequence[str] = ()) -> None:
|
|
405
|
+
"""Give the buttons of a form's right-hand column the Windows 10 look, then the field height and one common width.
|
|
406
|
+
|
|
407
|
+
The look comes first: its style sheet changes the width a caption needs. ``other_form_texts`` is passed on to
|
|
408
|
+
``fit_side_buttons``.
|
|
409
|
+
"""
|
|
410
|
+
for button in buttons:
|
|
411
|
+
apply_windows_button_look(button)
|
|
412
|
+
fit_side_buttons(buttons, other_form_texts)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class ButtonBand(QWidget):
|
|
416
|
+
"""Full-width strip under a window body that carries its buttons on the right, as in Git Extensions.
|
|
417
|
+
|
|
418
|
+
A button that stands apart from the window's actions, like Settings… on the Git Extensions start page, can sit
|
|
419
|
+
on the left of the band instead.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
def __init__(self, buttons: list[QPushButton], colors: BandedDialogColors, band_style: ButtonBandStyle = FORM_BUTTON_BAND,
|
|
423
|
+
leading_buttons: list[QPushButton] | None = None) -> None:
|
|
424
|
+
"""Right-align the buttons with the spacing and margins measured on the reproduced window, leading buttons on the left.
|
|
425
|
+
|
|
426
|
+
Without leading buttons the band has no left margin, exactly as every band measured on a capture; with them,
|
|
427
|
+
the left margin mirrors the right one so the leading buttons sit as far from the left edge as the others from
|
|
428
|
+
the right edge.
|
|
429
|
+
"""
|
|
430
|
+
super().__init__()
|
|
431
|
+
self.colors = colors
|
|
432
|
+
self.band_style = band_style
|
|
433
|
+
leading_buttons = leading_buttons or []
|
|
434
|
+
separator_width = BUTTON_BAND_SEPARATOR_WIDTH if band_style.separator else 0
|
|
435
|
+
left_margin = band_style.right_margin if leading_buttons else 0
|
|
436
|
+
self.setContentsMargins(left_margin, separator_width + band_style.space_above, band_style.right_margin, band_style.space_below)
|
|
437
|
+
self.row = QHBoxLayout(self)
|
|
438
|
+
self.row.setContentsMargins(0, 0, 0, 0)
|
|
439
|
+
self.row.setSpacing(band_style.spacing)
|
|
440
|
+
for button in leading_buttons:
|
|
441
|
+
self.row.addWidget(button)
|
|
442
|
+
self.row.addStretch()
|
|
443
|
+
for button in buttons:
|
|
444
|
+
self.row.addWidget(button)
|
|
445
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
446
|
+
|
|
447
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
448
|
+
"""Fill the band and, for the Git Extensions forms, draw its separator line along the top edge."""
|
|
449
|
+
painter = QPainter(self)
|
|
450
|
+
painter.fillRect(self.rect(), self.colors.band)
|
|
451
|
+
if self.band_style.separator:
|
|
452
|
+
painter.fillRect(QRect(0, 0, self.width(), BUTTON_BAND_SEPARATOR_WIDTH), self.colors.separator)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
DEFAULT_BODY_MARGINS = QMargins(SPACING_MD, SPACING_MD, SPACING_MD, SPACING_MD)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def paint_banded_window(window: QWidget, band_style: ButtonBandStyle, source: QPalette) -> BandedDialogColors:
|
|
459
|
+
"""Paint a banded window's background with its body color and return the colors its band has to use.
|
|
460
|
+
|
|
461
|
+
``source`` is the palette the colors are worked out from. It is passed in rather than read from the window because
|
|
462
|
+
a window painted before already carries the body color set that time: a window that follows a theme change takes
|
|
463
|
+
the application palette instead.
|
|
464
|
+
"""
|
|
465
|
+
colors = banded_dialog_colors(source, band_style)
|
|
466
|
+
palette = window.palette()
|
|
467
|
+
palette.setColor(QPalette.ColorRole.Window, colors.body)
|
|
468
|
+
window.setPalette(palette)
|
|
469
|
+
window.setAutoFillBackground(True)
|
|
470
|
+
return colors
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def install_banded_layout(window: QWidget, body: QLayout, buttons: list[QPushButton], band_style: ButtonBandStyle = FORM_BUTTON_BAND,
|
|
474
|
+
body_margins: QMargins = DEFAULT_BODY_MARGINS, *, leading_buttons: list[QPushButton] | None = None,
|
|
475
|
+
button_sizing: BandButtonSizing = BandButtonSizing.BAND) -> ButtonBand:
|
|
476
|
+
"""Lay a dialog or a plain window out as its body above a full-width button band, and paint it with the body color.
|
|
477
|
+
|
|
478
|
+
The window's own background is the body color, so any spacing between body and band never shows another color.
|
|
479
|
+
``buttons`` sit on the right of the band and ``leading_buttons`` on its left. All of them get the Windows 10 look
|
|
480
|
+
of the reproduced window in a light theme. The body, the band and the buttons' look are laid again whenever the
|
|
481
|
+
application switches between light and dark while the window is open.
|
|
482
|
+
|
|
483
|
+
By default the band sizes its buttons: at least the standard button of ``band_style``, wider with room on both
|
|
484
|
+
sides when the text needs it. A window that copies its buttons' sizes from a capture passes
|
|
485
|
+
``BandButtonSizing.WINDOW`` and keeps them.
|
|
486
|
+
"""
|
|
487
|
+
colors = paint_banded_window(window, band_style, QApplication.palette())
|
|
488
|
+
outer = QVBoxLayout(window)
|
|
489
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
490
|
+
outer.setSpacing(0)
|
|
491
|
+
body.setContentsMargins(body_margins)
|
|
492
|
+
outer.addLayout(body, 1)
|
|
493
|
+
band = ButtonBand(buttons, colors, band_style, leading_buttons)
|
|
494
|
+
band_buttons = [*(leading_buttons or []), *buttons]
|
|
495
|
+
for button in band_buttons:
|
|
496
|
+
if button_sizing == BandButtonSizing.BAND:
|
|
497
|
+
button.setMinimumSize(band_style.button_size)
|
|
498
|
+
apply_windows_button_look(button, button_sizing.text_side_padding)
|
|
499
|
+
outer.addWidget(band)
|
|
500
|
+
|
|
501
|
+
def apply_band_theme_look() -> None:
|
|
502
|
+
"""Paint the body and the band with the new theme's colors and give the band's buttons that theme's look."""
|
|
503
|
+
band.colors = paint_banded_window(window, band.band_style, QApplication.palette())
|
|
504
|
+
band.update()
|
|
505
|
+
for band_button in band_buttons:
|
|
506
|
+
apply_windows_button_look(band_button, button_sizing.text_side_padding)
|
|
507
|
+
|
|
508
|
+
follow_theme(window, apply_band_theme_look)
|
|
509
|
+
return band
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def content_height(dialog: QDialog) -> int:
|
|
513
|
+
"""Return the height the dialog layout needs at the dialog's current width, wrapped labels included."""
|
|
514
|
+
layout = dialog.layout()
|
|
515
|
+
layout.activate()
|
|
516
|
+
if layout.hasHeightForWidth():
|
|
517
|
+
return max(layout.totalHeightForWidth(dialog.width()), layout.totalMinimumSize().height())
|
|
518
|
+
return layout.totalSizeHint().height()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _content_height_at(content: QWidget, width: int) -> int:
|
|
522
|
+
"""Return the height a scroll area's content needs at ``width``, wrapped labels included."""
|
|
523
|
+
return content.heightForWidth(width) if content.hasHeightForWidth() else content.minimumSizeHint().height()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def scrolled_content_needed_size(scroll_area: QScrollArea) -> QSize:
|
|
527
|
+
"""Return the width a scroll area's content gets without a vertical scroll bar, and the height it needs at that width.
|
|
528
|
+
|
|
529
|
+
The content of a resizable scroll area wraps to the viewport's width, and the vertical scroll bar goes away once the
|
|
530
|
+
content fits, so the content is measured at the width it has without that scroll bar.
|
|
531
|
+
"""
|
|
532
|
+
content = scroll_area.widget()
|
|
533
|
+
viewport = scroll_area.viewport()
|
|
534
|
+
if content is None:
|
|
535
|
+
return viewport.size()
|
|
536
|
+
width = viewport.width()
|
|
537
|
+
scroll_bar = scroll_area.verticalScrollBar()
|
|
538
|
+
if scroll_bar.isVisible():
|
|
539
|
+
width += scroll_bar.width()
|
|
540
|
+
return QSize(width, _content_height_at(content, width))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def scrolled_content_missing_height(scroll_area: QScrollArea) -> int:
|
|
544
|
+
"""Return how many pixels a scroll area's viewport lacks to show its whole content without scrolling, or a negative number when it has room.
|
|
545
|
+
|
|
546
|
+
The content is measured at the width it gets without the vertical scroll bar. Workaround: while that scroll bar
|
|
547
|
+
shows, Qt only hides it once the content fits at the narrower width the scroll bar leaves, since it measures the
|
|
548
|
+
content at the viewport's current width; content that wraps on one more line there (a long branch name cut at its
|
|
549
|
+
dashes) would keep the scroll bar, with a few pixels left to scroll, in a viewport tall enough for it at the full
|
|
550
|
+
width. The lack is then at least what the content needs at that narrower width beyond the viewport, Qt's own
|
|
551
|
+
measure. It is measured on the content rather than read from the scroll bar's range: the scroll area only updates
|
|
552
|
+
that range on a later layout pass, so right after a change of content or of size the range still says what the
|
|
553
|
+
content scrolled before, and a window sized by it would grow again by what scrolled at its previous height.
|
|
554
|
+
"""
|
|
555
|
+
missing_height = scrolled_content_needed_size(scroll_area).height() - scroll_area.viewport().height()
|
|
556
|
+
content = scroll_area.widget()
|
|
557
|
+
if content is None or not scroll_area.verticalScrollBar().isVisible():
|
|
558
|
+
return missing_height
|
|
559
|
+
viewport = scroll_area.viewport()
|
|
560
|
+
return max(missing_height, _content_height_at(content, viewport.width()) - viewport.height())
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
GROWTH_USABLE_HEIGHT_SHARE = 0.75
|
|
564
|
+
"""Largest share of its screen's usable height that a window's frame, title bar included, reaches by growing to its content.
|
|
565
|
+
|
|
566
|
+
Beyond it the content scrolls: a grown window takes no more than three quarters of the usable height, so it never hides
|
|
567
|
+
everything behind it. That usable height is estimated where Qt counts the whole screen as usable (Wayland), panels it
|
|
568
|
+
does not see taken off.
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _decoration_reference(window: QWidget) -> QWidget | None:
|
|
573
|
+
"""Return the window whose title bar a window not decorated yet borrows: its parent's window, or none for a window without a parent."""
|
|
574
|
+
parent = window.parentWidget()
|
|
575
|
+
return parent.window() if parent is not None else None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def growth_height_cap(window: QWidget, platform_name: str = "", *, placed_height: int | None = None) -> int:
|
|
579
|
+
"""Return the tallest client height the growth rule gives a window: GROWTH_USABLE_HEIGHT_SHARE of its usable height, frame included, and below a kept top no more than the room under it.
|
|
580
|
+
|
|
581
|
+
The frame is what is bounded, title bar included: the margins the window system gave count, and so does the title
|
|
582
|
+
bar borrowed from the parent's window while the window is not decorated yet; a decorated window of which Qt knows no
|
|
583
|
+
margin, its title bar drawn by the compositor, counts an estimated title bar. The usable height is Qt's, less an
|
|
584
|
+
estimated panel where Qt counts the whole screen. Where GitPane moves a growing window back up itself, the share is
|
|
585
|
+
the whole cap. Where the compositor places windows and keeps a growing window's top, the frame also stays within the
|
|
586
|
+
room under the top the window was centred at with ``placed_height``, the client height it had when the window system
|
|
587
|
+
placed it; ``None`` for a window the rule does not follow, which only gets the share. ``platform_name`` is Qt's
|
|
588
|
+
platform name, read from Qt when not given.
|
|
589
|
+
"""
|
|
590
|
+
known = known_frame_margins(window, platform_name)
|
|
591
|
+
uncounted = uncounted_decoration_estimate(window, _decoration_reference(window), platform_name)
|
|
592
|
+
decorated = not window.windowFlags() & Qt.WindowType.FramelessWindowHint
|
|
593
|
+
decoration_height = decoration_height_estimate(known.top() + known.bottom() + uncounted.top() + uncounted.bottom(), decorated)
|
|
594
|
+
screen = window.screen()
|
|
595
|
+
usable_height = usable_height_estimate(screen.availableGeometry().height(), screen.geometry().height())
|
|
596
|
+
keeps_top = placed_height is not None and not window_placement(platform_name).requests_position
|
|
597
|
+
placed_frame_height = placed_height + decoration_height if keeps_top else None
|
|
598
|
+
return growth_frame_cap(usable_height, GROWTH_USABLE_HEIGHT_SHARE, placed_frame_height) - decoration_height
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def capped_growth_height(window: QWidget, wanted_height: int, platform_name: str = "", *, placed_height: int | None = None) -> int:
|
|
602
|
+
"""Return the height a window gets when its content asks for ``wanted_height``: never past the growth cap, never made smaller by it.
|
|
603
|
+
|
|
604
|
+
The cap only stops a growth: a window its own code opened taller than the cap keeps its height, and a fit that
|
|
605
|
+
gives height back still does. ``placed_height`` is the client height the window had when the window system placed
|
|
606
|
+
it, ``None`` for a window the rule does not follow. ``platform_name`` is Qt's platform name, read from Qt when not
|
|
607
|
+
given.
|
|
608
|
+
"""
|
|
609
|
+
return min(wanted_height, max(window.height(), growth_height_cap(window, platform_name, placed_height=placed_height)))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def window_height_on_screen(window: QWidget, wanted_height: int, platform_name: str = "", *, placed_height: int | None = None) -> int:
|
|
613
|
+
"""Return the height a shown window gets when asked for ``wanted_height``, capped by the growth share of its screen and by its usable area.
|
|
614
|
+
|
|
615
|
+
A window its own code opened taller than the growth share keeps its height when asked for more, and a smaller
|
|
616
|
+
height is always granted. ``placed_height`` is the client height the window had when the window system placed it,
|
|
617
|
+
``None`` for a window the rule does not follow. ``platform_name`` is Qt's platform name, read from Qt when not given.
|
|
618
|
+
"""
|
|
619
|
+
decoration_reference = _decoration_reference(window)
|
|
620
|
+
available = window.screen().availableGeometry()
|
|
621
|
+
largest_height = largest_client_size(window, available.marginsRemoved(uncounted_decoration_estimate(window, decoration_reference, platform_name)), platform_name).height()
|
|
622
|
+
return capped_growth_height(window, min(wanted_height, largest_height), platform_name, placed_height=placed_height)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def resize_window_height_on_screen(window: QWidget, wanted_height: int, *, platform_name: str = "", placed_height: int | None = None) -> None:
|
|
626
|
+
"""Give a shown window the wanted height, taller or shorter, capped by the growth share of its screen and by its usable area.
|
|
627
|
+
|
|
628
|
+
Only the height changes; the width stays the one the window has. A height above the cap stops at it, while a
|
|
629
|
+
smaller height is always applied, so a window gives back the room its content no longer needs. The frame, title bar
|
|
630
|
+
included, stays inside the usable area with its margin where GitPane places its windows: a window the window system
|
|
631
|
+
has not decorated yet borrows the title bar of its parent's window, and a window sitting low on the screen comes
|
|
632
|
+
back up just enough. Where the compositor places windows itself, the window is not moved: the compositor ignores
|
|
633
|
+
the position, and the cap keeps the frame within the room under the top it was placed at. ``placed_height`` is the
|
|
634
|
+
client height the window had when the window system placed it, ``None`` for a window the rule does not follow.
|
|
635
|
+
``platform_name`` is Qt's platform name, read from Qt when not given.
|
|
636
|
+
"""
|
|
637
|
+
height = window_height_on_screen(window, wanted_height, platform_name, placed_height=placed_height)
|
|
638
|
+
if height == window.height():
|
|
639
|
+
return
|
|
640
|
+
window.resize(window.width(), height)
|
|
641
|
+
keep_frame_inside_usable_area(window, platform_name=platform_name)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def keep_frame_inside_usable_area(window: QWidget, *, platform_name: str = "") -> None:
|
|
645
|
+
"""Move a shown window just enough for its whole frame, title bar included, to sit inside the usable area of its screen with its margin.
|
|
646
|
+
|
|
647
|
+
A window the window system has not decorated yet borrows the title bar of its parent's window. Where the compositor
|
|
648
|
+
places windows itself, nothing moves: the compositor ignores the position. ``platform_name`` is Qt's platform name,
|
|
649
|
+
read from Qt when not given.
|
|
650
|
+
"""
|
|
651
|
+
if not window_placement(platform_name).requests_position:
|
|
652
|
+
return
|
|
653
|
+
decoration = unknown_decoration_estimate(window, _decoration_reference(window), platform_name)
|
|
654
|
+
available = window.screen().availableGeometry()
|
|
655
|
+
frame = window.frameGeometry().marginsAdded(decoration)
|
|
656
|
+
usable = available.adjusted(SCREEN_MARGIN, SCREEN_MARGIN, -SCREEN_MARGIN, -SCREEN_MARGIN)
|
|
657
|
+
window.move(frame_position_inside(frame, usable) + QPoint(decoration.left(), decoration.top()))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def fit_banded_form(dialog: QDialog, available: QRect | None = None) -> None:
|
|
661
|
+
"""Give a banded dialog exactly the height of its content, then keep it on screen.
|
|
662
|
+
|
|
663
|
+
The shared form metrics are not applied: banded dialogs reproduce a Git Extensions window and set their own
|
|
664
|
+
measured spacing and control sizes.
|
|
665
|
+
"""
|
|
666
|
+
dialog.layout().activate()
|
|
667
|
+
dialog.resize(dialog.width(), content_height(dialog))
|
|
668
|
+
fit_window(dialog, 0, 0, available)
|