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
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Show that GitPane is still working while it waits on something outside of it, such as the user's browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer
|
|
8
|
+
from PySide6.QtGui import QColor, QPainter, QPalette, QPen
|
|
9
|
+
from PySide6.QtWidgets import QSizePolicy, QWidget
|
|
10
|
+
|
|
11
|
+
from gitpane.config import QT_ANGLE_UNITS_PER_DEGREE
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from PySide6.QtGui import QPaintEvent
|
|
15
|
+
|
|
16
|
+
# One step of the turning arc: a smooth turn without keeping the event loop busy.
|
|
17
|
+
FRAME_INTERVAL_MS = 50
|
|
18
|
+
ANGLE_STEP_DEGREES = 30
|
|
19
|
+
FULL_TURN_DEGREES = 360
|
|
20
|
+
# Length of the drawn arc, and its line width as a share of the indicator's side.
|
|
21
|
+
ARC_SPAN_DEGREES = 270
|
|
22
|
+
LINE_WIDTH_RATIO = 0.14
|
|
23
|
+
MINIMUM_LINE_WIDTH = 1.5
|
|
24
|
+
# Divides a length in two: half the line width keeps the arc inside the square, half the free space centres it.
|
|
25
|
+
HALVING_DIVISOR = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def paint_busy_arc(painter: QPainter, square: QRectF, angle: int, color: QColor) -> None:
|
|
29
|
+
"""Draw the turning arc at its angle inside the given square, its line as thick as a share of the square's side.
|
|
30
|
+
|
|
31
|
+
The busy indicator and the "checking" rows of the recent list both draw it, so the arc and its look are defined once.
|
|
32
|
+
The painter's state is left as it was.
|
|
33
|
+
"""
|
|
34
|
+
side = min(square.width(), square.height())
|
|
35
|
+
line_width = max(MINIMUM_LINE_WIDTH, side * LINE_WIDTH_RATIO)
|
|
36
|
+
inset = line_width / HALVING_DIVISOR
|
|
37
|
+
pen = QPen(color)
|
|
38
|
+
pen.setWidthF(line_width)
|
|
39
|
+
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
40
|
+
painter.save()
|
|
41
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
42
|
+
painter.setPen(pen)
|
|
43
|
+
painter.drawArc(square.adjusted(inset, inset, -inset, -inset), -angle * QT_ANGLE_UNITS_PER_DEGREE, ARC_SPAN_DEGREES * QT_ANGLE_UNITS_PER_DEGREE)
|
|
44
|
+
painter.restore()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def next_busy_angle(angle: int) -> int:
|
|
48
|
+
"""Return the arc's angle one step further clockwise."""
|
|
49
|
+
return (angle + ANGLE_STEP_DEGREES) % FULL_TURN_DEGREES
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BusyIndicator(QWidget):
|
|
53
|
+
"""A small turning arc, the size of a line of text and in the theme's text colour.
|
|
54
|
+
|
|
55
|
+
It is hidden while stopped, and its timer only runs while it turns, so a stopped indicator costs nothing.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
59
|
+
"""Build a stopped, hidden indicator."""
|
|
60
|
+
super().__init__(parent)
|
|
61
|
+
self.angle = 0
|
|
62
|
+
self.timer = QTimer(self)
|
|
63
|
+
self.timer.setInterval(FRAME_INTERVAL_MS)
|
|
64
|
+
self.timer.timeout.connect(self._advance)
|
|
65
|
+
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
66
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
|
67
|
+
self.hide()
|
|
68
|
+
|
|
69
|
+
def start(self) -> None:
|
|
70
|
+
"""Show the indicator and start turning it."""
|
|
71
|
+
self.show()
|
|
72
|
+
self.timer.start()
|
|
73
|
+
|
|
74
|
+
def stop(self) -> None:
|
|
75
|
+
"""Stop turning and hide the indicator."""
|
|
76
|
+
self.timer.stop()
|
|
77
|
+
self.hide()
|
|
78
|
+
|
|
79
|
+
def sizeHint(self) -> QSize:
|
|
80
|
+
"""Take the height of a line of the current font, so the indicator sits beside its text at any scale."""
|
|
81
|
+
side = self.fontMetrics().height()
|
|
82
|
+
return QSize(side, side)
|
|
83
|
+
|
|
84
|
+
def minimumSizeHint(self) -> QSize:
|
|
85
|
+
"""Never shrink below the size of a line of text."""
|
|
86
|
+
return self.sizeHint()
|
|
87
|
+
|
|
88
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
89
|
+
"""Follow a new font size, and repaint in the new text colour when the theme changes."""
|
|
90
|
+
super().changeEvent(event)
|
|
91
|
+
if event.type() == QEvent.Type.FontChange:
|
|
92
|
+
self.updateGeometry()
|
|
93
|
+
self.update()
|
|
94
|
+
|
|
95
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
96
|
+
"""Draw the arc at its current angle, inside a square centred in the widget."""
|
|
97
|
+
side = min(self.width(), self.height())
|
|
98
|
+
square = QRectF((self.width() - side) / HALVING_DIVISOR, (self.height() - side) / HALVING_DIVISOR, side, side)
|
|
99
|
+
painter = QPainter(self)
|
|
100
|
+
paint_busy_arc(painter, square, self.angle, self.palette().color(QPalette.ColorRole.WindowText))
|
|
101
|
+
painter.end()
|
|
102
|
+
|
|
103
|
+
def _advance(self) -> None:
|
|
104
|
+
"""Turn the arc one step clockwise."""
|
|
105
|
+
self.angle = next_busy_angle(self.angle)
|
|
106
|
+
self.update()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Write the dates of a commit the way the history rows show them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, tzinfo
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from gitpane.history.repository_value import Commit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MINUTE_SECONDS, HOUR_SECONDS, DAY_SECONDS = 60, 3600, 86400
|
|
13
|
+
MONTH_SECONDS, YEAR_SECONDS = 30 * DAY_SECONDS, 365 * DAY_SECONDS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_relative_date(timestamp: int, now: datetime) -> str:
|
|
17
|
+
"""Keep elapsed time compact for history rows and share it with commit details."""
|
|
18
|
+
difference = int(now.timestamp() - timestamp)
|
|
19
|
+
duration = abs(difference)
|
|
20
|
+
if duration < MINUTE_SECONDS:
|
|
21
|
+
return "just now" if difference >= 0 else "in a few seconds"
|
|
22
|
+
if duration >= MONTH_SECONDS:
|
|
23
|
+
divisor, unit = (YEAR_SECONDS, "year") if duration >= YEAR_SECONDS else (MONTH_SECONDS, "month")
|
|
24
|
+
whole_days = duration // DAY_SECONDS
|
|
25
|
+
days_per_unit = divisor // DAY_SECONDS
|
|
26
|
+
count = round(whole_days / days_per_unit)
|
|
27
|
+
unit += "s" if count != 1 else ""
|
|
28
|
+
return f"{count} {unit} ago" if difference >= 0 else f"in {count} {unit}"
|
|
29
|
+
divisor, unit = (DAY_SECONDS, "d") if duration >= DAY_SECONDS else (HOUR_SECONDS, "h") if duration >= HOUR_SECONDS else (MINUTE_SECONDS, "min")
|
|
30
|
+
return f"{duration // divisor} {unit} ago" if difference >= 0 else f"in {duration // divisor} {unit}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
DATE_UNAVAILABLE_TEXT = "Date unavailable"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_commit_time(commit: Commit, timestamp: int, now: datetime, *, relative_only: bool = False) -> str:
|
|
37
|
+
"""Write one of the dates of a commit, or say it is unavailable when Git gave no readable time or no details for it."""
|
|
38
|
+
if commit.time_unavailable or commit.detail_error:
|
|
39
|
+
return DATE_UNAVAILABLE_TEXT
|
|
40
|
+
return format_commit_date(timestamp, now, relative_only=relative_only)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_commit_date(timestamp: int, now: datetime, timezone: tzinfo | None = None, *, relative_only: bool = False) -> str:
|
|
44
|
+
"""Validate a date and retain its absolute form for details and table tooltips."""
|
|
45
|
+
try:
|
|
46
|
+
# Windows cannot reliably convert naive local dates near the Unix epoch; start with an aware UTC date.
|
|
47
|
+
date = datetime.fromtimestamp(timestamp, UTC).astimezone(timezone)
|
|
48
|
+
except (ValueError, OverflowError, OSError):
|
|
49
|
+
return "Date unavailable (timestamp out of range)"
|
|
50
|
+
relative = format_relative_date(timestamp, now)
|
|
51
|
+
if relative_only:
|
|
52
|
+
return relative
|
|
53
|
+
offset = date.strftime("%z")
|
|
54
|
+
offset_hour_end = 3
|
|
55
|
+
return f"{relative} ({date:%Y-%m-%d %H:%M:%S} {offset[:offset_hour_end]}:{offset[offset_hour_end:]})"
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Follow a window's content with its height after the window opened, never below the height it opened at or the user set.
|
|
2
|
+
|
|
3
|
+
The window opens at a starting height, fitted once to the rows it starts with after its first show is laid out. It then
|
|
4
|
+
grows each time its content needs more height than the window gives it (a long path, a message, a failure written after
|
|
5
|
+
the window opened would otherwise be cut under its bottom edge), and takes back a smaller height when that content goes,
|
|
6
|
+
never below its reference height: the height it opened at once fitted, then the last height the user set by hand. Its
|
|
7
|
+
whole frame stays inside the usable area of its screen and never grows past three quarters of its usable height, the
|
|
8
|
+
content scrolling beyond that. Where the compositor keeps the top it placed the window at (Wayland), the window does not
|
|
9
|
+
grow past the room under that top either, estimated when Qt sees neither the desktop's panels nor the title bar. Where
|
|
10
|
+
the compositor answers a window's show with a size of its own (Wayland), the opening fit and each change of size wait
|
|
11
|
+
for that answer, which would otherwise overwrite them for good.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import QCoreApplication, QEvent, Qt, QTimer
|
|
20
|
+
from shiboken6 import isValid
|
|
21
|
+
|
|
22
|
+
from gitpane.config import fit_window, window_placement
|
|
23
|
+
from gitpane.ui.widget.dialog_layout import capped_growth_height, keep_frame_inside_usable_area, resize_window_height_on_screen, window_height_on_screen
|
|
24
|
+
from gitpane.ui.window_control.compositor_answer import after_compositor_answer
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from PySide6.QtCore import QObject
|
|
28
|
+
from PySide6.QtGui import QResizeEvent, QShowEvent
|
|
29
|
+
|
|
30
|
+
from gitpane.ui.widget.content_measure import ContentMeasure
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpeningFit(Enum):
|
|
34
|
+
"""How a window fits its height, once, to the rows it opens with."""
|
|
35
|
+
|
|
36
|
+
GROW_ONLY = "grow-only"
|
|
37
|
+
EXACT = "exact"
|
|
38
|
+
|
|
39
|
+
def missing_height(self, measure: ContentMeasure) -> int:
|
|
40
|
+
"""Return the height the window lacks to show its starting rows whole, negative when it has height to give back.
|
|
41
|
+
|
|
42
|
+
``GROW_ONLY`` keeps the opening height as a floor and grows only by what the content hides: a form that opens with
|
|
43
|
+
room under its rows keeps that room for a message that comes later. For a form that scrolls, what is hidden is the
|
|
44
|
+
vertical scroll bar's range, what such a form has always grown by, to the pixel, at every text size. ``EXACT``
|
|
45
|
+
takes the exact height the content needs at the width it has, so a window opened taller than its rows gets
|
|
46
|
+
smaller, and one opened shorter grows until nothing is hidden.
|
|
47
|
+
"""
|
|
48
|
+
if self is OpeningFit.GROW_ONLY:
|
|
49
|
+
return measure.hidden_height()
|
|
50
|
+
return measure.missing_height()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ContentGrowthMixin:
|
|
54
|
+
"""Height rule of a window whose content changes after it opened, mixed in front of the window's ``QDialog`` class.
|
|
55
|
+
|
|
56
|
+
A mixin rather than an object the window owns, so the state of the rule stays on the window, where the window's own
|
|
57
|
+
code, its tests and the window gallery read it (``starting_height``, ``first_show_fitted``, ``reference_height``,
|
|
58
|
+
``growth_measure``) without a relay. The window gives the rule the measure of its content, by its scroll area or by
|
|
59
|
+
its own layout, when it calls ``install_content_growth`` once that content is in place; each layout request of the
|
|
60
|
+
content then measures what it needs and gives the window the height the rule aims at.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def install_content_growth(self, measure: ContentMeasure, opening_fit: OpeningFit, starting_height: int) -> None:
|
|
64
|
+
"""Start following the content ``measure`` measures, the window opening at ``starting_height`` then fitted as ``opening_fit`` says.
|
|
65
|
+
|
|
66
|
+
``starting_height`` only stands for the opening height until the first show: the opening fit then replaces it
|
|
67
|
+
with the height the window really has, which is smaller than the given one when the window was reduced to fit a
|
|
68
|
+
small screen, so a test or the window gallery that compares the window with it never reads a height it never had.
|
|
69
|
+
"""
|
|
70
|
+
self.growth_measure = measure
|
|
71
|
+
self.opening_fit = opening_fit
|
|
72
|
+
# The height the form opened with: the given one until the opening fit, then the window's height once that fit is made.
|
|
73
|
+
self.starting_height = starting_height
|
|
74
|
+
# The client height the window had when the window system placed it, which bounds a growth below the top the
|
|
75
|
+
# compositor keeps: the given starting height until the opening fit reads the height the window really has.
|
|
76
|
+
self.placed_height = starting_height
|
|
77
|
+
self.starting_height_fitted = False
|
|
78
|
+
# True only while the first show waits for the compositor's answer: where nothing waits, the fit is asked in that call.
|
|
79
|
+
self.showing_first_time = False
|
|
80
|
+
# True once the first show has fitted the form to its starting rows, when they needed it.
|
|
81
|
+
self.first_show_fitted = False
|
|
82
|
+
# The height content that leaves brings the window back to: the opening height once fitted, then the last height
|
|
83
|
+
# the user set by hand. The window height its content needed when that reference was taken tells a content that
|
|
84
|
+
# asks for no more than then (the reference stays, even under its content) from one that asks for more.
|
|
85
|
+
self.reference_height = starting_height
|
|
86
|
+
self.reference_needed_height = starting_height
|
|
87
|
+
# Sizes the application gave the window since the reference was taken (the rule's resizes, Qt growing the window
|
|
88
|
+
# to its layout's minimum, and where the compositor answers activations, the reference itself): a resize from the
|
|
89
|
+
# window system to one of them is the window system repeating a size it was given, not the user.
|
|
90
|
+
self.application_given_size_set: set[tuple[int, int]] = set()
|
|
91
|
+
# The window's size its last resize event brought: a look that finds the window at another size is made while
|
|
92
|
+
# that resize is still being laid out, before its event reaches the window, and comes from the resize rather
|
|
93
|
+
# than from a change of content.
|
|
94
|
+
self.resized_size: tuple[int, int] | None = None
|
|
95
|
+
# The window's height when the rule last looked at it, or when the user last resized it: a window found taller at
|
|
96
|
+
# the next look was grown by the application, mostly by Qt to its layout's new minimum, keeping its top where it
|
|
97
|
+
# was, and its frame is brought back inside the usable area as for a growth of the rule.
|
|
98
|
+
self.looked_height = starting_height
|
|
99
|
+
# True while the rule itself resizes the window: the looks that resize causes are not a change of content.
|
|
100
|
+
self.content_growing = False
|
|
101
|
+
measure.watched_widget().installEventFilter(self)
|
|
102
|
+
range_changed = measure.range_changed()
|
|
103
|
+
if range_changed is not None:
|
|
104
|
+
range_changed.connect(self._follow_content)
|
|
105
|
+
|
|
106
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
107
|
+
"""Fit the form to its starting rows once its first show is laid out, after the compositor's answer where that answer would overwrite the size.
|
|
108
|
+
|
|
109
|
+
Under Wayland the compositor answers the first show with the size it knew then, and Qt applies it: a height asked
|
|
110
|
+
before that answer would be lost for good. The wait starts here, at the show, so an activation the window gets
|
|
111
|
+
before the next turn of the event loop is seen and the fit still waits for the configure that follows it.
|
|
112
|
+
"""
|
|
113
|
+
super().showEvent(event)
|
|
114
|
+
if self.starting_height_fitted:
|
|
115
|
+
return
|
|
116
|
+
self.starting_height_fitted = True
|
|
117
|
+
self.showing_first_time = True
|
|
118
|
+
try:
|
|
119
|
+
after_compositor_answer(self, self._fit_starting_height)
|
|
120
|
+
finally:
|
|
121
|
+
self.showing_first_time = False
|
|
122
|
+
|
|
123
|
+
def _fit_starting_height(self) -> None:
|
|
124
|
+
"""Fit the form to its starting rows: a turn after the show where nothing waits, at once when the compositor's answer comes."""
|
|
125
|
+
if self.showing_first_time:
|
|
126
|
+
# The rows only take their final heights once the layout requests posted by the show are handled.
|
|
127
|
+
QTimer.singleShot(0, self, self._fit_starting_height_now)
|
|
128
|
+
return
|
|
129
|
+
self._fit_starting_height_now()
|
|
130
|
+
|
|
131
|
+
def _fit_starting_height_now(self) -> None:
|
|
132
|
+
"""Fit the form once by the height its starting rows lack, or have in excess for the exact fit, so it opens without scrolling.
|
|
133
|
+
|
|
134
|
+
The starting height shows the starting rows at the text size it was chosen for, so nothing changes there. A larger
|
|
135
|
+
system font (Ubuntu's 11 pt) makes the same rows taller: the form then grows by the height they lack. The window
|
|
136
|
+
is checked again because the wait for the compositor's answer can end, at its bound, on a window hidden meanwhile.
|
|
137
|
+
Like any growth of the rule, the fit stops at the growth cap, three quarters of the usable height, the rows
|
|
138
|
+
beyond it scrolling. Where the compositor keeps the window's top, the window's height at this point is the one the
|
|
139
|
+
compositor placed it with, since the fit waits for its answer: the fit and every later growth then also stop at
|
|
140
|
+
the room under that top, estimated when Qt sees neither panel nor title bar. The fitted height becomes the
|
|
141
|
+
reference content that leaves brings the window back to.
|
|
142
|
+
"""
|
|
143
|
+
if not isValid(self) or not self.isVisible():
|
|
144
|
+
return
|
|
145
|
+
# Where the compositor places the window, this is the size it placed, since the opening fit waits for its answer;
|
|
146
|
+
# elsewhere the rule does not use it.
|
|
147
|
+
self.placed_height = self.height()
|
|
148
|
+
self.first_show_fitted = True
|
|
149
|
+
missing_height = self.opening_fit.missing_height(self.growth_measure)
|
|
150
|
+
if missing_height != 0:
|
|
151
|
+
self.content_growing = True
|
|
152
|
+
try:
|
|
153
|
+
self.resize(self.width(), capped_growth_height(self, self.height() + missing_height, placed_height=self.placed_height))
|
|
154
|
+
fit_window(self, 0, 0)
|
|
155
|
+
finally:
|
|
156
|
+
self.content_growing = False
|
|
157
|
+
self.starting_height = self.height()
|
|
158
|
+
self._take_reference()
|
|
159
|
+
|
|
160
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
161
|
+
"""Take a resize made through the window system as the user's height, the one content that leaves brings the window back to.
|
|
162
|
+
|
|
163
|
+
A resize the window system reports is the user's: those of the rule, of Qt growing the window to its layout's
|
|
164
|
+
minimum and of the fit to the screen are asked by the application, and never spontaneous. A spontaneous resize
|
|
165
|
+
to a size the application gave since the reference was taken is the window system repeating it, not the user:
|
|
166
|
+
under Wayland, Mutter answers the activation of a window (a form closing over it) with a configure that carries
|
|
167
|
+
the size it knew before the window's last change, and Qt applies it. That older size can be another one than the
|
|
168
|
+
content calls for now, so the rule looks at the content again, at the next turn of the event loop, and gives the
|
|
169
|
+
window its height back. A maximised or full-screen window keeps the reference it had before, which restoring it
|
|
170
|
+
brings back.
|
|
171
|
+
"""
|
|
172
|
+
super().resizeEvent(event)
|
|
173
|
+
self.resized_size = (self.width(), self.height())
|
|
174
|
+
if event.spontaneous():
|
|
175
|
+
self.looked_height = self.height()
|
|
176
|
+
if not event.spontaneous() or self.content_growing or not self.first_show_fitted or self._maximized_or_full_screen():
|
|
177
|
+
return
|
|
178
|
+
if (event.size().width(), event.size().height()) in self.application_given_size_set:
|
|
179
|
+
# Posted rather than done here: the rule resizes the window, which is better not done inside its resize event.
|
|
180
|
+
QCoreApplication.postEvent(self.growth_measure.watched_widget(), QEvent(QEvent.Type.LayoutRequest))
|
|
181
|
+
return
|
|
182
|
+
self._take_reference()
|
|
183
|
+
|
|
184
|
+
def _take_reference(self) -> None:
|
|
185
|
+
"""Make the window's current height the reference, with the height its content needs now at this width.
|
|
186
|
+
|
|
187
|
+
Where the compositor answers an activation with a size it knew before (Wayland), the reference size counts as
|
|
188
|
+
one the application gave: Mutter brings it back on its own after the content grew the window, which is not the
|
|
189
|
+
user. Elsewhere only the user brings it back, by hand. Under Wayland a user who drags the window to exactly that
|
|
190
|
+
height while the content needs more sees the content's height come back: the two cannot be told apart.
|
|
191
|
+
"""
|
|
192
|
+
self.reference_height = self.height()
|
|
193
|
+
self.looked_height = self.height()
|
|
194
|
+
self.reference_needed_height = self.height() + self.growth_measure.missing_height()
|
|
195
|
+
self.application_given_size_set = set()
|
|
196
|
+
if window_placement().size_waits_for_compositor_answer:
|
|
197
|
+
self.application_given_size_set.add((self.width(), self.height()))
|
|
198
|
+
|
|
199
|
+
def _follow_content(self, *_arguments: object) -> None:
|
|
200
|
+
"""Ask for the height the content now calls for, once the compositor has answered the show where that answer would overwrite it.
|
|
201
|
+
|
|
202
|
+
A look made while a resize of the window is still being laid out comes from that resize, not from new content: the
|
|
203
|
+
window lays out its children, and the scroll area updates its scroll bar, before the resize event reaches the
|
|
204
|
+
window, so a user's resize would otherwise be undone before the window could take it as the user's height.
|
|
205
|
+
"""
|
|
206
|
+
if not isValid(self) or self.content_growing or not self.isVisible() or not self.first_show_fitted or self._maximized_or_full_screen():
|
|
207
|
+
return
|
|
208
|
+
if (self.width(), self.height()) != self.resized_size:
|
|
209
|
+
return
|
|
210
|
+
after_compositor_answer(self, self._follow_content_now)
|
|
211
|
+
|
|
212
|
+
def _follow_content_now(self) -> None:
|
|
213
|
+
"""Give the window the height its content calls for now: what it needs, never below the reference height.
|
|
214
|
+
|
|
215
|
+
The content's need is measured when the change is made, not when it was asked. While it asks for no more than
|
|
216
|
+
when the reference was taken, the window takes the reference height, even one the user set under its content;
|
|
217
|
+
otherwise it takes the larger of the reference and the need. Each look computes the target again: a second look
|
|
218
|
+
at the same content (the late layout pass of a row that has a layout of its own, a scroll bar that shows) gives
|
|
219
|
+
the same target or what is still missing, and a target already reached changes nothing. The flag that keeps the
|
|
220
|
+
rule's own resize from counting as a change of content is set here, in the change the compositor's answer may
|
|
221
|
+
defer, never around the request.
|
|
222
|
+
|
|
223
|
+
A window whose layout sets its minimum size is grown by Qt itself, keeping its top, as soon as the new content
|
|
224
|
+
shows, before the rule looks: the target is then already reached, and the frame, which can now end under the
|
|
225
|
+
usable area, comes back up into it the way the rule's own growth does, and its size counts as one the application
|
|
226
|
+
gave, so the window system repeating it is not taken for the user's. A height the user set through the window
|
|
227
|
+
system is never taken for such a growth, and where the compositor places windows nothing moves.
|
|
228
|
+
"""
|
|
229
|
+
if not isValid(self) or not self.isVisible() or self._maximized_or_full_screen():
|
|
230
|
+
return
|
|
231
|
+
grown_since_last_look = self.height() > self.looked_height
|
|
232
|
+
needed_height = self.height() + self.growth_measure.missing_height()
|
|
233
|
+
target_height = self.reference_height if needed_height <= self.reference_needed_height else max(self.reference_height, needed_height)
|
|
234
|
+
if grown_since_last_look:
|
|
235
|
+
self.application_given_size_set.add((self.width(), self.height()))
|
|
236
|
+
if window_height_on_screen(self, target_height, placed_height=self.placed_height) == self.height():
|
|
237
|
+
if grown_since_last_look:
|
|
238
|
+
keep_frame_inside_usable_area(self)
|
|
239
|
+
self.looked_height = self.height()
|
|
240
|
+
return
|
|
241
|
+
self.content_growing = True
|
|
242
|
+
try:
|
|
243
|
+
resize_window_height_on_screen(self, target_height, placed_height=self.placed_height)
|
|
244
|
+
finally:
|
|
245
|
+
self.content_growing = False
|
|
246
|
+
self.looked_height = self.height()
|
|
247
|
+
self.application_given_size_set.add((self.width(), self.height()))
|
|
248
|
+
|
|
249
|
+
def _maximized_or_full_screen(self) -> bool:
|
|
250
|
+
"""Tell whether the window is maximised or full screen, where the rule leaves its size to the window system."""
|
|
251
|
+
state = self.windowState()
|
|
252
|
+
return bool(state & Qt.WindowState.WindowMaximized) or bool(state & Qt.WindowState.WindowFullScreen)
|
|
253
|
+
|
|
254
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
255
|
+
"""Look at the content's layout requests, which follow each change of its texts, and follow the content with the window's height."""
|
|
256
|
+
if event.type() != QEvent.Type.LayoutRequest:
|
|
257
|
+
return super().eventFilter(watched, event)
|
|
258
|
+
watched_widget = self.growth_measure.watched_widget()
|
|
259
|
+
if watched_widget is None or not isValid(watched_widget) or watched is not watched_widget:
|
|
260
|
+
return super().eventFilter(watched, event)
|
|
261
|
+
self._follow_content()
|
|
262
|
+
return False
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Measure the height a window's content lacks, on its scroll area or on its own layout.
|
|
2
|
+
|
|
3
|
+
A window whose height follows its content hands the growth rule one of these measures: the content of a form that
|
|
4
|
+
scrolls is the scroll area's widget, measured at the width the viewport gives it; the content of a form without a
|
|
5
|
+
scroll area is the window's own layout, measured at the window's width, with each text that scrolls inside it counted at
|
|
6
|
+
its whole height. Each measure also names the widget whose layout requests tell that the content changed, and the
|
|
7
|
+
signal, if any, that tells an overflow changed without one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from gitpane.ui.widget.dialog_layout import scrolled_content_missing_height
|
|
15
|
+
from gitpane.ui.widget.scrolled_text import ScrolledText
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from PySide6.QtCore import SignalInstance
|
|
19
|
+
from PySide6.QtWidgets import QScrollArea, QWidget
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def layout_minimum_height(window: QWidget) -> int:
|
|
23
|
+
"""Return the smallest height the window's layout needs at the window's current width, wrapped labels included.
|
|
24
|
+
|
|
25
|
+
The layout is activated first, so a widget just shown or hidden counts. The minimum is measured and never the
|
|
26
|
+
preferred height: a table that stretches prefers more rows than it needs, and a window sized by that preference
|
|
27
|
+
would grow again at each look.
|
|
28
|
+
"""
|
|
29
|
+
layout = window.layout()
|
|
30
|
+
layout.activate()
|
|
31
|
+
if layout.hasHeightForWidth():
|
|
32
|
+
return layout.totalMinimumHeightForWidth(window.width())
|
|
33
|
+
return layout.totalMinimumSize().height()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ScrolledContentMeasure:
|
|
37
|
+
"""Measure the content of a window whose form scrolls: the scroll area's widget, at the width its viewport gives it."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, scroll_area: QScrollArea) -> None:
|
|
40
|
+
"""Measure the content of ``scroll_area``, which already holds it."""
|
|
41
|
+
self.scroll_area = scroll_area
|
|
42
|
+
|
|
43
|
+
def watched_widget(self) -> QWidget:
|
|
44
|
+
"""Return the scrolled content, whose layout requests follow each change of its texts."""
|
|
45
|
+
return self.scroll_area.widget()
|
|
46
|
+
|
|
47
|
+
def missing_height(self) -> int:
|
|
48
|
+
"""Return the height the viewport lacks to show the whole content without scrolling, negative when it has room."""
|
|
49
|
+
return scrolled_content_missing_height(self.scroll_area)
|
|
50
|
+
|
|
51
|
+
def hidden_height(self) -> int:
|
|
52
|
+
"""Return the height the vertical scroll bar hides, zero when nothing scrolls."""
|
|
53
|
+
return max(0, self.scroll_area.verticalScrollBar().maximum())
|
|
54
|
+
|
|
55
|
+
def range_changed(self) -> SignalInstance | None:
|
|
56
|
+
"""Return the scroll bar's range signal, which tells an overflow changed even without a layout request of the content."""
|
|
57
|
+
return self.scroll_area.verticalScrollBar().rangeChanged
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scrolled_text_extra_height(window: QWidget) -> int:
|
|
61
|
+
"""Return the height the window's shown scrolled texts need beyond what its layout counts for them.
|
|
62
|
+
|
|
63
|
+
The layout counts such a text at most at a few lines, so a long output never makes Qt grow the window past the
|
|
64
|
+
screen; the window's content still needs the whole text, which the growth rule gives it up to its cap.
|
|
65
|
+
"""
|
|
66
|
+
extra_height = 0
|
|
67
|
+
for scrolled_text in window.findChildren(ScrolledText):
|
|
68
|
+
if scrolled_text.window() is not window or not scrolled_text.isVisibleTo(window):
|
|
69
|
+
continue
|
|
70
|
+
extra_height += max(0, scrolled_text.needed_height() - scrolled_text.minimumSizeHint().height())
|
|
71
|
+
return extra_height
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LayoutContentMeasure:
|
|
75
|
+
"""Measure the content of a window without a scroll area for its whole form: its own layout at the window's width, each scrolled text at its whole height."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, window: QWidget) -> None:
|
|
78
|
+
"""Measure the layout of ``window``, which already has it."""
|
|
79
|
+
self.window = window
|
|
80
|
+
|
|
81
|
+
def watched_widget(self) -> QWidget:
|
|
82
|
+
"""Return the window itself, whose layout requests follow each change of its rows."""
|
|
83
|
+
return self.window
|
|
84
|
+
|
|
85
|
+
def missing_height(self) -> int:
|
|
86
|
+
"""Return the height the window lacks for its layout's minimum at its width and its scrolled texts' whole height, negative when it has room."""
|
|
87
|
+
return layout_minimum_height(self.window) + scrolled_text_extra_height(self.window) - self.window.height()
|
|
88
|
+
|
|
89
|
+
def hidden_height(self) -> int:
|
|
90
|
+
"""Return the height the window lacks for its whole content, zero when it has room: what its layout lacks is cut, what its scrolled texts lack scrolls."""
|
|
91
|
+
return max(0, self.missing_height())
|
|
92
|
+
|
|
93
|
+
def range_changed(self) -> SignalInstance | None:
|
|
94
|
+
"""Return nothing: without a scroll area, the window's layout requests are the only sign that its content changed."""
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
ContentMeasure = ScrolledContentMeasure | LayoutContentMeasure
|
|
99
|
+
"""The measure a window gives the growth rule: by its scroll area, or by its own layout."""
|