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,542 @@
|
|
|
1
|
+
"""Keep long texts readable in narrow windows: labels that wrap or elide, windows grown to show them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import ceil
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QEvent, QPoint, QRect, QSize, Qt
|
|
9
|
+
from PySide6.QtGui import QFontMetricsF, QPainter, QPalette, QTextDocument, QTextLayout, QTextOption
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QCheckBox,
|
|
12
|
+
QComboBox,
|
|
13
|
+
QGridLayout,
|
|
14
|
+
QLabel,
|
|
15
|
+
QRadioButton,
|
|
16
|
+
QScrollArea,
|
|
17
|
+
QSizePolicy,
|
|
18
|
+
QStyle,
|
|
19
|
+
QStyleOptionButton,
|
|
20
|
+
QStylePainter,
|
|
21
|
+
QWidget,
|
|
22
|
+
)
|
|
23
|
+
from shiboken6 import Shiboken
|
|
24
|
+
|
|
25
|
+
from gitpane.config import SCREEN_MARGIN, SPACING_MD, SPACING_SM, control_height, fit_window, known_frame_margins
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from PySide6.QtGui import QPaintEvent, QResizeEvent
|
|
29
|
+
|
|
30
|
+
# A widget frame is drawn on both sides, so its width counts twice across a widget.
|
|
31
|
+
FRAME_SIDE_COUNT = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fit_window_to_content(window: QWidget, available: QRect | None = None) -> None:
|
|
35
|
+
"""Grow a window just enough to show all its content, never making it smaller and never past the usable screen area.
|
|
36
|
+
|
|
37
|
+
Many window sizes are copied from Git Extensions captures, taken on Windows with Segoe UI 9 pt. A larger system font
|
|
38
|
+
(Ubuntu 11 pt, or a font the user enlarged) needs more room, and at the copied size texts are cut, wrapped texts lose
|
|
39
|
+
their last lines and controls overlap. The size the window's code gave stays the minimum, so with the captures' font
|
|
40
|
+
nothing changes. The width grows to the layout's minimum, to what the form inside a scroll area needs and to what the
|
|
41
|
+
one-line texts that must open whole need, then the height to what the content needs at that width, wrapped texts included. ``available`` is the usable area of the screen the window opens on; without it, the
|
|
42
|
+
window's own screen is used.
|
|
43
|
+
"""
|
|
44
|
+
layout = window.layout()
|
|
45
|
+
if layout is None:
|
|
46
|
+
return
|
|
47
|
+
# Activating the layout may already grow the window to the layout's minimum size.
|
|
48
|
+
layout.activate()
|
|
49
|
+
minimum = layout.totalMinimumSize()
|
|
50
|
+
width = max(window.width(), minimum.width(), window.width() + scroll_areas_missing_width(window), window.width() + elided_texts_missing_width(window))
|
|
51
|
+
height = max(window.height(), minimum.height())
|
|
52
|
+
if layout.hasHeightForWidth():
|
|
53
|
+
height = max(height, layout.totalMinimumHeightForWidth(width))
|
|
54
|
+
screen_area = available if available is not None else window.screen().availableGeometry()
|
|
55
|
+
largest = largest_client_size(window, screen_area)
|
|
56
|
+
width = min(width, largest.width())
|
|
57
|
+
height = min(height, largest.height())
|
|
58
|
+
if width != window.width() or height != window.height():
|
|
59
|
+
window.resize(width, height)
|
|
60
|
+
# A layout minimum larger than the screen keeps the window from shrinking to the size asked: the minimum is relaxed.
|
|
61
|
+
if window.width() > largest.width() or window.height() > largest.height():
|
|
62
|
+
fit_window(window, window.minimumWidth(), window.minimumHeight(), screen_area)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scroll_area_missing_width(scroll_area: QScrollArea) -> int:
|
|
66
|
+
"""Return how many pixels a resizable scroll area lacks to show its content's minimum width, or a negative number when it has room.
|
|
67
|
+
|
|
68
|
+
A scroll area reports almost no minimum size of its own, whatever it holds, so a window measured on its layout alone
|
|
69
|
+
never sees that the form inside needs more width: the form is then squeezed or scrolled sideways.
|
|
70
|
+
"""
|
|
71
|
+
content = scroll_area.widget()
|
|
72
|
+
if content is None or not scroll_area.widgetResizable():
|
|
73
|
+
return 0
|
|
74
|
+
needed = content.minimumSizeHint().expandedTo(content.minimumSize()).width()
|
|
75
|
+
available = scroll_area.width() - FRAME_SIDE_COUNT * scroll_area.frameWidth()
|
|
76
|
+
if scroll_area.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOn:
|
|
77
|
+
available -= scroll_area.verticalScrollBar().sizeHint().width()
|
|
78
|
+
return needed - available
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def scroll_areas_missing_width(window: QWidget) -> int:
|
|
82
|
+
"""Return the largest width any shown resizable scroll area of the window lacks for its content, 0 when they all have room.
|
|
83
|
+
|
|
84
|
+
Scroll areas on a page that is not shown (another tab, another page of a stack) are left out: their size is not the
|
|
85
|
+
one they will get once shown.
|
|
86
|
+
"""
|
|
87
|
+
missing_width = 0
|
|
88
|
+
for scroll_area in window.findChildren(QScrollArea):
|
|
89
|
+
if not scroll_area.isVisibleTo(window):
|
|
90
|
+
continue
|
|
91
|
+
missing_width = max(missing_width, scroll_area_missing_width(scroll_area))
|
|
92
|
+
return missing_width
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def elided_texts_missing_width(window: QWidget) -> int:
|
|
96
|
+
"""Return the largest width a shown text that must open whole lacks to be shown without its ellipsis, 0 when they all fit.
|
|
97
|
+
|
|
98
|
+
``ElidedLabel`` and ``ElidedCheckBox`` ask for no width of their own, so a layout never reports that one of them cuts
|
|
99
|
+
its text. Those created with ``open_whole`` hold a fixed sentence of the window rather than a value of any length,
|
|
100
|
+
and the window grows at its first show until each of them is whole; a window the user narrows later cuts them again.
|
|
101
|
+
"""
|
|
102
|
+
missing_width = 0
|
|
103
|
+
for widget in window.findChildren(QWidget):
|
|
104
|
+
if not isinstance(widget, (ElidedLabel, ElidedCheckBox)) or not widget.open_whole or not widget.isVisibleTo(window):
|
|
105
|
+
continue
|
|
106
|
+
missing_width = max(missing_width, widget.whole_text_width() - widget.width())
|
|
107
|
+
return missing_width
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def largest_client_size(window: QWidget, available: QRect, platform_name: str = "") -> QSize:
|
|
111
|
+
"""Return the largest client size whose frame, title bar and borders included, fits the usable area with its margin.
|
|
112
|
+
|
|
113
|
+
``platform_name`` is Qt's platform name, read from Qt when not given.
|
|
114
|
+
"""
|
|
115
|
+
usable = available.adjusted(SCREEN_MARGIN, SCREEN_MARGIN, -SCREEN_MARGIN, -SCREEN_MARGIN)
|
|
116
|
+
decoration = known_frame_margins(window, platform_name)
|
|
117
|
+
return QSize(usable.width() - decoration.left() - decoration.right(), usable.height() - decoration.top() - decoration.bottom())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
LONG_WORD_WRAP_MODE = QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def long_word_text_option(document: QTextDocument) -> QTextOption:
|
|
124
|
+
"""Return the document's own text option with a fallback that breaks words too long for one line."""
|
|
125
|
+
option = document.defaultTextOption()
|
|
126
|
+
option.setWrapMode(LONG_WORD_WRAP_MODE)
|
|
127
|
+
return option
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class WrappingLabel(QLabel):
|
|
131
|
+
"""Wrap long literal Git values while retaining native text selection and copying."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
|
|
134
|
+
"""Use Qt's text document so hashes and URLs need no inserted display characters."""
|
|
135
|
+
super().__init__(text, parent)
|
|
136
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
137
|
+
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
138
|
+
|
|
139
|
+
def heightForWidth(self, width: int) -> int:
|
|
140
|
+
"""Measure the same complete wrapped document that the native label will paint."""
|
|
141
|
+
height = super().heightForWidth(width)
|
|
142
|
+
if not self.wordWrap():
|
|
143
|
+
return height
|
|
144
|
+
# QLabel exposes only word wrapping and resets that choice during layout. Its native text document
|
|
145
|
+
# supplies the missing long-word fallback without changing literal text or implementing selection ourselves.
|
|
146
|
+
# The measurement runs on a detached copy: every option or width change on the painted document asks the
|
|
147
|
+
# label to repaint, so measuring on it from a paint would keep the label repainting forever.
|
|
148
|
+
document = self.findChild(QTextDocument)
|
|
149
|
+
if document is None:
|
|
150
|
+
return height
|
|
151
|
+
# Workaround: PySide records the copy returned by clone() as a Python-side child of the native document, so it
|
|
152
|
+
# is never destroyed and every measure would leave one copy alive. setParent(None) drops that record (Qt itself
|
|
153
|
+
# gave the copy no parent), then the copy is deleted explicitly; deleting it while still recorded as a child
|
|
154
|
+
# crashes the process once the label is destroyed. The copy must not become a child of the label either:
|
|
155
|
+
# findChild would then return it instead of the native document, here and in paintEvent.
|
|
156
|
+
measured_document = document.clone()
|
|
157
|
+
measured_document.setParent(None)
|
|
158
|
+
try:
|
|
159
|
+
measured_document.setDefaultFont(document.defaultFont())
|
|
160
|
+
measured_document.setDocumentMargin(document.documentMargin())
|
|
161
|
+
measured_document.setDefaultTextOption(long_word_text_option(document))
|
|
162
|
+
measured_document.setTextWidth(max(1, width - self.margin() * 2 - self.contentsMargins().left() - self.contentsMargins().right()))
|
|
163
|
+
return ceil(measured_document.size().height()) + self.margin() * 2 + self.contentsMargins().top() + self.contentsMargins().bottom()
|
|
164
|
+
finally:
|
|
165
|
+
Shiboken.delete(measured_document)
|
|
166
|
+
|
|
167
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
168
|
+
"""Restore long-word wrapping and the current width after native font or geometry changes before painting."""
|
|
169
|
+
document = self.findChild(QTextDocument)
|
|
170
|
+
if not self.wordWrap() or document is None:
|
|
171
|
+
super().paintEvent(event)
|
|
172
|
+
return
|
|
173
|
+
# Workaround: QLabel applies its own wrap mode lazily, the next time it lays out changed text, font or
|
|
174
|
+
# wrapping, and a pending one would overwrite the long-word fallback during the native paint. Only QLabel's
|
|
175
|
+
# own height measurement runs that pending layout, and it briefly changes the document width, which would ask
|
|
176
|
+
# for another paint and repaint the label forever. The document layout's signals are held for that call only.
|
|
177
|
+
document_layout = document.documentLayout()
|
|
178
|
+
signals_were_blocked = document_layout.blockSignals(True)
|
|
179
|
+
try:
|
|
180
|
+
super().heightForWidth(self.width())
|
|
181
|
+
finally:
|
|
182
|
+
document_layout.blockSignals(signals_were_blocked)
|
|
183
|
+
# Each document change schedules one more paint, so only a real difference is written; an unchanged label then stays idle.
|
|
184
|
+
if document.defaultTextOption().wrapMode() != LONG_WORD_WRAP_MODE:
|
|
185
|
+
document.setDefaultTextOption(long_word_text_option(document))
|
|
186
|
+
paint_width = max(1, self.contentsRect().width() - self.margin() * 2)
|
|
187
|
+
if document.textWidth() != paint_width:
|
|
188
|
+
document.setTextWidth(paint_width)
|
|
189
|
+
super().paintEvent(event)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def exact_text_width(widget: QWidget, text: str) -> int:
|
|
193
|
+
"""Return the smallest whole number of pixels that holds a text in the widget's font, so eliding at that width keeps it whole.
|
|
194
|
+
|
|
195
|
+
``QFontMetrics.horizontalAdvance`` rounds the text's fractional width to the nearest pixel, which can be a fraction
|
|
196
|
+
short of it; ``elidedText`` compares the fractional width and would then cut the text at that rounded width.
|
|
197
|
+
"""
|
|
198
|
+
return ceil(QFontMetricsF(widget.font(), widget).horizontalAdvance(text))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ElidedLabel(QLabel):
|
|
202
|
+
"""One-line label that cuts a text too long for its width with an ellipsis and shows the whole text as its tooltip.
|
|
203
|
+
|
|
204
|
+
Qt has no label that elides: a QLabel either wraps its text or asks for the whole text's width, which widens the
|
|
205
|
+
window. This label keeps the whole text (``text()`` returns it), paints only what fits, never asks for more width
|
|
206
|
+
than it is given, and offers the whole text as a tooltip only while it is cut.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, text: str = "", parent: QWidget | None = None, *, open_whole: bool = False) -> None:
|
|
210
|
+
"""Take any width the layout offers and keep a one-line height.
|
|
211
|
+
|
|
212
|
+
``open_whole`` is for a fixed sentence of the window rather than a value of any length: the window then opens
|
|
213
|
+
wide enough to show it whole (see ``fit_window_to_content``), and only a window the user narrows cuts it.
|
|
214
|
+
"""
|
|
215
|
+
super().__init__(parent)
|
|
216
|
+
self.open_whole = open_whole
|
|
217
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
218
|
+
policy = self.sizePolicy()
|
|
219
|
+
policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
|
|
220
|
+
self.setSizePolicy(policy)
|
|
221
|
+
self.setText(text)
|
|
222
|
+
|
|
223
|
+
def setText(self, text: str) -> None:
|
|
224
|
+
"""Keep the whole text and recompute whether it has to be cut."""
|
|
225
|
+
super().setText(text)
|
|
226
|
+
self._update_tooltip()
|
|
227
|
+
|
|
228
|
+
def _text_width(self) -> int:
|
|
229
|
+
"""Return the width available to the text inside the label's margins."""
|
|
230
|
+
return max(0, self.contentsRect().width() - self.margin() * 2 - max(0, self.indent()))
|
|
231
|
+
|
|
232
|
+
def whole_text_width(self) -> int:
|
|
233
|
+
"""Return the label width that shows the whole text, margins included."""
|
|
234
|
+
margins = self.contentsMargins()
|
|
235
|
+
return exact_text_width(self, self.text()) + self.margin() * 2 + max(0, self.indent()) + margins.left() + margins.right()
|
|
236
|
+
|
|
237
|
+
def displayed_text(self) -> str:
|
|
238
|
+
"""Return the text as painted: whole when it fits, cut with an ellipsis otherwise."""
|
|
239
|
+
return self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, self._text_width())
|
|
240
|
+
|
|
241
|
+
def _update_tooltip(self) -> None:
|
|
242
|
+
"""Offer the whole text as a tooltip only while the label cuts it."""
|
|
243
|
+
self.setToolTip(self.text() if self.displayed_text() != self.text() else "")
|
|
244
|
+
|
|
245
|
+
def sizeHint(self) -> QSize:
|
|
246
|
+
"""Ask for one line of text, and no width of its own."""
|
|
247
|
+
margins = self.contentsMargins()
|
|
248
|
+
return QSize(0, self.fontMetrics().height() + self.margin() * 2 + margins.top() + margins.bottom())
|
|
249
|
+
|
|
250
|
+
def minimumSizeHint(self) -> QSize:
|
|
251
|
+
"""Allow any width down to nothing, at one line of height."""
|
|
252
|
+
return self.sizeHint()
|
|
253
|
+
|
|
254
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
255
|
+
"""Recompute the tooltip, since a new width may cut the text or show it whole."""
|
|
256
|
+
super().resizeEvent(event)
|
|
257
|
+
self._update_tooltip()
|
|
258
|
+
|
|
259
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
260
|
+
"""Recompute the tooltip when a new font changes the text's width."""
|
|
261
|
+
super().changeEvent(event)
|
|
262
|
+
if event.type() == QEvent.Type.FontChange:
|
|
263
|
+
self._update_tooltip()
|
|
264
|
+
|
|
265
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
266
|
+
"""Paint the text as it fits, with the label's alignment and the palette's text color."""
|
|
267
|
+
painter = QPainter(self)
|
|
268
|
+
rectangle = self.contentsRect().adjusted(self.margin() + max(0, self.indent()), self.margin(), -self.margin(), -self.margin())
|
|
269
|
+
self.style().drawItemText(painter, rectangle, int(self.alignment()), self.palette(), self.isEnabled(), self.displayed_text(), QPalette.ColorRole.WindowText)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# The character Qt ends an elided text with.
|
|
273
|
+
ELLIPSIS = "\u2026"
|
|
274
|
+
PARAGRAPH_SEPARATOR = "\n"
|
|
275
|
+
WORD_SEPARATOR = " "
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class LineLimitedLabel(QLabel):
|
|
279
|
+
"""Label that wraps its text over at most a fixed number of lines and ends the last one with an ellipsis when cut.
|
|
280
|
+
|
|
281
|
+
``text()`` answers the whole text, as ``ElidedLabel`` does, so every reader keeps seeing what was set; what the
|
|
282
|
+
native label holds and paints is the fitted text, which keeps native selection and painting. The label never asks
|
|
283
|
+
for a width of its own, so a long text cannot widen its window.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def __init__(self, text: str = "", line_count: int = 1, parent: QWidget | None = None) -> None:
|
|
287
|
+
"""Keep the number of visible lines and take any width the layout offers."""
|
|
288
|
+
super().__init__(parent)
|
|
289
|
+
self.line_count = line_count
|
|
290
|
+
self.whole_text = ""
|
|
291
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
292
|
+
self.setWordWrap(True)
|
|
293
|
+
policy = self.sizePolicy()
|
|
294
|
+
policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
|
|
295
|
+
self.setSizePolicy(policy)
|
|
296
|
+
self.setText(text)
|
|
297
|
+
|
|
298
|
+
def setText(self, text: str) -> None:
|
|
299
|
+
"""Keep the whole text and show what fits in the allowed lines at the current width."""
|
|
300
|
+
self.whole_text = text
|
|
301
|
+
self._fit()
|
|
302
|
+
|
|
303
|
+
def text(self) -> str:
|
|
304
|
+
"""Answer the whole text, not the fitted one the native label paints.
|
|
305
|
+
|
|
306
|
+
Workaround: QLabel.text() is not virtual, so this override only answers Python callers; Qt itself keeps working
|
|
307
|
+
on the fitted text, which is what it must select and paint.
|
|
308
|
+
"""
|
|
309
|
+
return self.whole_text
|
|
310
|
+
|
|
311
|
+
def displayed_text(self) -> str:
|
|
312
|
+
"""Answer the text as painted: one line per visible line, the last one ending with an ellipsis when cut."""
|
|
313
|
+
return super().text()
|
|
314
|
+
|
|
315
|
+
def _text_width(self) -> int:
|
|
316
|
+
"""Return the width available to the text inside the label's margins."""
|
|
317
|
+
return max(0, self.contentsRect().width() - self.margin() * 2 - max(0, self.indent()))
|
|
318
|
+
|
|
319
|
+
def _wrapped_lines(self, width: int) -> list[str]:
|
|
320
|
+
"""Break the whole text into the lines it takes at this width, stopping one line past the allowed number.
|
|
321
|
+
|
|
322
|
+
Words too long for one line are broken anywhere, so an object ID or a path cannot run past the edge.
|
|
323
|
+
"""
|
|
324
|
+
option = QTextOption()
|
|
325
|
+
option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
|
326
|
+
lines: list[str] = []
|
|
327
|
+
for paragraph in self.whole_text.split(PARAGRAPH_SEPARATOR):
|
|
328
|
+
layout = QTextLayout(paragraph, self.font())
|
|
329
|
+
layout.setTextOption(option)
|
|
330
|
+
layout.beginLayout()
|
|
331
|
+
paragraph_line_count = 0
|
|
332
|
+
while len(lines) <= self.line_count:
|
|
333
|
+
line = layout.createLine()
|
|
334
|
+
if not line.isValid():
|
|
335
|
+
break
|
|
336
|
+
line.setLineWidth(width)
|
|
337
|
+
lines.append(paragraph[line.textStart():line.textStart() + line.textLength()].rstrip())
|
|
338
|
+
paragraph_line_count += 1
|
|
339
|
+
layout.endLayout()
|
|
340
|
+
if not paragraph_line_count:
|
|
341
|
+
lines.append("")
|
|
342
|
+
if len(lines) > self.line_count:
|
|
343
|
+
break
|
|
344
|
+
return lines
|
|
345
|
+
|
|
346
|
+
def _fitted_text(self) -> str:
|
|
347
|
+
"""Return the whole text when it fits in the allowed lines, otherwise those lines with an ellipsis at the end."""
|
|
348
|
+
width = self._text_width()
|
|
349
|
+
if width <= 0 or not self.whole_text:
|
|
350
|
+
return self.whole_text
|
|
351
|
+
lines = self._wrapped_lines(width)
|
|
352
|
+
if len(lines) <= self.line_count:
|
|
353
|
+
return PARAGRAPH_SEPARATOR.join(lines)
|
|
354
|
+
last_index = self.line_count - 1
|
|
355
|
+
# The line after the last visible one makes the last line too long, so Qt cuts it and adds its ellipsis.
|
|
356
|
+
last_line = self.fontMetrics().elidedText(lines[last_index] + WORD_SEPARATOR + lines[last_index + 1], Qt.TextElideMode.ElideRight, width)
|
|
357
|
+
if not last_line.endswith(ELLIPSIS):
|
|
358
|
+
last_line = self.fontMetrics().elidedText(lines[last_index] + ELLIPSIS, Qt.TextElideMode.ElideRight, width)
|
|
359
|
+
return PARAGRAPH_SEPARATOR.join([*lines[:last_index], last_line])
|
|
360
|
+
|
|
361
|
+
def _fit(self) -> None:
|
|
362
|
+
"""Give the native label the fitted text, only when it differs, so an unchanged width never starts a new layout."""
|
|
363
|
+
fitted_text = self._fitted_text()
|
|
364
|
+
if super().text() != fitted_text:
|
|
365
|
+
super().setText(fitted_text)
|
|
366
|
+
|
|
367
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
368
|
+
"""Fit the text again, since a new width changes where its lines break."""
|
|
369
|
+
super().resizeEvent(event)
|
|
370
|
+
self._fit()
|
|
371
|
+
|
|
372
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
373
|
+
"""Fit the text again when a new font changes the width of its characters."""
|
|
374
|
+
super().changeEvent(event)
|
|
375
|
+
if event.type() == QEvent.Type.FontChange:
|
|
376
|
+
self._fit()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ElidedCheckBox(QCheckBox):
|
|
380
|
+
"""Check box that cuts a text too long for its width with an ellipsis and shows the whole text as its tooltip.
|
|
381
|
+
|
|
382
|
+
Like ``ElidedLabel`` for a check box: ``text()`` keeps the whole text, only what fits after the square is painted,
|
|
383
|
+
the box never asks for more width than it is given, and the tooltip carries the whole text only while it is cut.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def __init__(self, text: str = "", parent: QWidget | None = None, *, open_whole: bool = False) -> None:
|
|
387
|
+
"""Take any width the layout offers and keep the check box's own height.
|
|
388
|
+
|
|
389
|
+
``open_whole`` is for a fixed caption rather than one holding a value of any length: the window then opens wide
|
|
390
|
+
enough to show it whole (see ``fit_window_to_content``), and only a window the user narrows cuts it.
|
|
391
|
+
"""
|
|
392
|
+
super().__init__(parent)
|
|
393
|
+
self.open_whole = open_whole
|
|
394
|
+
policy = self.sizePolicy()
|
|
395
|
+
policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
|
|
396
|
+
self.setSizePolicy(policy)
|
|
397
|
+
self.setText(text)
|
|
398
|
+
|
|
399
|
+
def setText(self, text: str) -> None:
|
|
400
|
+
"""Keep the whole text and recompute whether it has to be cut."""
|
|
401
|
+
super().setText(text)
|
|
402
|
+
self._update_tooltip()
|
|
403
|
+
|
|
404
|
+
def _indicator_space(self) -> int:
|
|
405
|
+
"""Return the width the style gives the square and the space between it and the text."""
|
|
406
|
+
style = self.style()
|
|
407
|
+
return style.pixelMetric(QStyle.PixelMetric.PM_IndicatorWidth, None, self) + style.pixelMetric(QStyle.PixelMetric.PM_CheckBoxLabelSpacing, None, self)
|
|
408
|
+
|
|
409
|
+
def whole_text_width(self) -> int:
|
|
410
|
+
"""Return the check box width that shows the square and the whole text."""
|
|
411
|
+
return self._indicator_space() + exact_text_width(self, self.text())
|
|
412
|
+
|
|
413
|
+
def displayed_text(self) -> str:
|
|
414
|
+
"""Return the text as painted: whole when it fits after the square, cut with an ellipsis otherwise."""
|
|
415
|
+
return self.fontMetrics().elidedText(self.text(), Qt.TextElideMode.ElideRight, max(0, self.width() - self._indicator_space()))
|
|
416
|
+
|
|
417
|
+
def _update_tooltip(self) -> None:
|
|
418
|
+
"""Offer the whole text as a tooltip only while the check box cuts it."""
|
|
419
|
+
self.setToolTip(self.text() if self.displayed_text() != self.text() else "")
|
|
420
|
+
|
|
421
|
+
def sizeHint(self) -> QSize:
|
|
422
|
+
"""Ask for the square and the check box's height, and no width for the text."""
|
|
423
|
+
return QSize(self._indicator_space(), super().sizeHint().height())
|
|
424
|
+
|
|
425
|
+
def minimumSizeHint(self) -> QSize:
|
|
426
|
+
"""Allow any width down to the square, at the check box's height."""
|
|
427
|
+
return self.sizeHint()
|
|
428
|
+
|
|
429
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
430
|
+
"""Recompute the tooltip, since a new width may cut the text or show it whole."""
|
|
431
|
+
super().resizeEvent(event)
|
|
432
|
+
self._update_tooltip()
|
|
433
|
+
|
|
434
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
435
|
+
"""Recompute the tooltip when a new font or style changes the text's width."""
|
|
436
|
+
super().changeEvent(event)
|
|
437
|
+
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange):
|
|
438
|
+
self._update_tooltip()
|
|
439
|
+
|
|
440
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
441
|
+
"""Paint the native square, focus and state with the text as it fits."""
|
|
442
|
+
option = QStyleOptionButton()
|
|
443
|
+
self.initStyleOption(option)
|
|
444
|
+
option.text = self.displayed_text()
|
|
445
|
+
painter = QStylePainter(self)
|
|
446
|
+
painter.drawControl(QStyle.ControlElement.CE_CheckBox, option)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class SelectionSummaryRow(QWidget):
|
|
450
|
+
"""Keep a short counter beside a selector and reflow long explanations below it."""
|
|
451
|
+
|
|
452
|
+
def __init__(self, caption: QLabel, selector: QComboBox, summary: QLabel) -> None:
|
|
453
|
+
"""Own one stable grid so reflow never reparents the live controls."""
|
|
454
|
+
super().__init__()
|
|
455
|
+
self.caption = caption
|
|
456
|
+
self.selector = selector
|
|
457
|
+
self.summary = summary
|
|
458
|
+
self.summary.setWordWrap(True)
|
|
459
|
+
self.summary_below = False
|
|
460
|
+
self.grid = QGridLayout(self)
|
|
461
|
+
self.grid.setContentsMargins(0, 0, 0, 0)
|
|
462
|
+
self.grid.setSpacing(SPACING_SM)
|
|
463
|
+
caption_column, selector_column, summary_column, selection_row = 0, 1, 2, 0
|
|
464
|
+
self.grid.addWidget(caption, selection_row, caption_column)
|
|
465
|
+
self.grid.addWidget(selector, selection_row, selector_column)
|
|
466
|
+
self.grid.addWidget(summary, selection_row, summary_column)
|
|
467
|
+
self.grid.setColumnStretch(selector_column, 1)
|
|
468
|
+
|
|
469
|
+
def event(self, event: QEvent) -> bool:
|
|
470
|
+
"""Reconsider placement when text, font or available row width changes."""
|
|
471
|
+
result = super().event(event)
|
|
472
|
+
if event.type() in (QEvent.Type.Resize, QEvent.Type.LayoutRequest) and hasattr(self, "grid"):
|
|
473
|
+
self.place_summary()
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
def place_summary(self) -> None:
|
|
477
|
+
"""Put the explanation beside the selector while the row's width holds all three, below it otherwise.
|
|
478
|
+
|
|
479
|
+
The row decides on its own at its next layout pass; a window that measures its height right after the
|
|
480
|
+
explanation changed calls it first, so the measure sees the explanation where it will be shown.
|
|
481
|
+
"""
|
|
482
|
+
required_width = self.caption.sizeHint().width() + self.selector.minimumSizeHint().width() + self.summary.sizeHint().width() + SPACING_SM * 2
|
|
483
|
+
below = required_width > self.width()
|
|
484
|
+
if below == self.summary_below:
|
|
485
|
+
return
|
|
486
|
+
self.summary_below = below
|
|
487
|
+
selection_row, explanation_row, first_column, summary_column, column_count = 0, 1, 0, 2, 3
|
|
488
|
+
self.grid.addWidget(self.summary, explanation_row if below else selection_row, first_column if below else summary_column, 1, column_count if below else 1)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class WrappingRadioButton(QRadioButton):
|
|
492
|
+
"""Keep native radio interaction while allowing its full description to wrap."""
|
|
493
|
+
|
|
494
|
+
def __init__(self, text: str, parent: QWidget | None = None) -> None:
|
|
495
|
+
"""Delegate text layout to a label without intercepting mouse or keyboard input."""
|
|
496
|
+
super().__init__(text, parent)
|
|
497
|
+
self.description_label = WrappingLabel(text, self)
|
|
498
|
+
self.description_label.setWordWrap(True)
|
|
499
|
+
self.description_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
500
|
+
self.description_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
|
501
|
+
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
502
|
+
policy.setHeightForWidth(True)
|
|
503
|
+
self.setSizePolicy(policy)
|
|
504
|
+
|
|
505
|
+
def _text_rect(self, width: int, height: int) -> QRect:
|
|
506
|
+
"""Use the current native indicator and stylesheet padding to position the label."""
|
|
507
|
+
option = QStyleOptionButton()
|
|
508
|
+
self.initStyleOption(option)
|
|
509
|
+
option.rect = QRect(0, 0, width, height)
|
|
510
|
+
return self.style().subElementRect(QStyle.SubElement.SE_RadioButtonContents, option, self)
|
|
511
|
+
|
|
512
|
+
def heightForWidth(self, width: int) -> int:
|
|
513
|
+
"""Reserve the full wrapped text height plus the band's shared vertical padding."""
|
|
514
|
+
text_rect = self._text_rect(width, self.fontMetrics().height() + SPACING_MD)
|
|
515
|
+
text_height = self.description_label.heightForWidth(max(1, text_rect.width()))
|
|
516
|
+
return control_height(max(self.fontMetrics().height(), text_height) + SPACING_SM)
|
|
517
|
+
|
|
518
|
+
def minimumSizeHint(self) -> QSize:
|
|
519
|
+
"""Allow a narrow band to grow vertically instead of forcing horizontal scrolling."""
|
|
520
|
+
return QSize(SPACING_MD * 2, control_height(self.fontMetrics().height() + SPACING_SM))
|
|
521
|
+
|
|
522
|
+
def sizeHint(self) -> QSize:
|
|
523
|
+
"""Keep the natural multiline width when space is available."""
|
|
524
|
+
width = super().sizeHint().width()
|
|
525
|
+
return QSize(width, self.heightForWidth(width))
|
|
526
|
+
|
|
527
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
528
|
+
"""Place the passive description inside the native radio's text area."""
|
|
529
|
+
super().resizeEvent(event)
|
|
530
|
+
self.description_label.setGeometry(self._text_rect(self.width(), self.height()))
|
|
531
|
+
|
|
532
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
533
|
+
"""Paint the styled native indicator and focus while the child paints wrapped text."""
|
|
534
|
+
option = QStyleOptionButton()
|
|
535
|
+
self.initStyleOption(option)
|
|
536
|
+
option.text = ""
|
|
537
|
+
painter = QStylePainter(self)
|
|
538
|
+
painter.drawControl(QStyle.ControlElement.CE_RadioButton, option)
|
|
539
|
+
|
|
540
|
+
def hitButton(self, position: QPoint) -> bool:
|
|
541
|
+
"""Keep every visible wrapped line clickable as part of the same radio button."""
|
|
542
|
+
return self.rect().contains(position)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Qt keyboard mnemonics: a single ampersand in the text of a button or menu becomes a keyboard shortcut instead of being shown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Qt turns a single "&" of a button or menu text into a keyboard mnemonic: the character after it is the key.
|
|
6
|
+
MNEMONIC_MARKER = "&"
|
|
7
|
+
# A doubled mnemonic marker is shown by Qt as a single "&" instead of a keyboard mnemonic.
|
|
8
|
+
ESCAPED_MNEMONIC_MARKER = MNEMONIC_MARKER + MNEMONIC_MARKER
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def escape_mnemonic(text: str) -> str:
|
|
12
|
+
"""Return text that a Qt button or menu shows exactly as given, instead of hiding each "&" as a mnemonic."""
|
|
13
|
+
return text.replace(MNEMONIC_MARKER, ESCAPED_MNEMONIC_MARKER)
|