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,1137 @@
|
|
|
1
|
+
"""Show the welcome window, where the user opens a recent repository, a folder or a clone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import partial
|
|
8
|
+
from itertools import pairwise
|
|
9
|
+
from math import ceil
|
|
10
|
+
from typing import TYPE_CHECKING, Self
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QEvent, QMargins, QModelIndex, QPointF, QRect, QRectF, QSize, Qt, QTimer, Signal
|
|
13
|
+
from PySide6.QtGui import QAction, QColor, QFont, QFontMetrics, QPainter, QPalette, QTextLayout, QTextOption
|
|
14
|
+
from PySide6.QtWidgets import (
|
|
15
|
+
QAbstractItemView,
|
|
16
|
+
QApplication,
|
|
17
|
+
QFileDialog,
|
|
18
|
+
QFrame,
|
|
19
|
+
QGridLayout,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QLabel,
|
|
22
|
+
QLayout,
|
|
23
|
+
QLineEdit,
|
|
24
|
+
QListWidget,
|
|
25
|
+
QListWidgetItem,
|
|
26
|
+
QMessageBox,
|
|
27
|
+
QPushButton,
|
|
28
|
+
QScrollArea,
|
|
29
|
+
QSizePolicy,
|
|
30
|
+
QStyle,
|
|
31
|
+
QStyledItemDelegate,
|
|
32
|
+
QStyleOptionViewItem,
|
|
33
|
+
QVBoxLayout,
|
|
34
|
+
QWidget,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from gitpane.config import (
|
|
38
|
+
FIRST_ROW,
|
|
39
|
+
MILLISECONDS_PER_SECOND,
|
|
40
|
+
RECENT_CHECK_UNKNOWN_DELAY_MS,
|
|
41
|
+
RECENT_CHECKING_DISPLAY_DELAY_MS,
|
|
42
|
+
SCREEN_MARGIN,
|
|
43
|
+
SPACING_XS,
|
|
44
|
+
UNKNOWN_DARK_COLOR,
|
|
45
|
+
UNKNOWN_LIGHT_COLOR,
|
|
46
|
+
WELCOME_MAX_SCREEN_RATIO,
|
|
47
|
+
WELCOME_MIN_HEIGHT,
|
|
48
|
+
WELCOME_MIN_WIDTH,
|
|
49
|
+
WELCOME_WIDTH,
|
|
50
|
+
SharedText,
|
|
51
|
+
center_window_at,
|
|
52
|
+
fit_window,
|
|
53
|
+
)
|
|
54
|
+
from gitpane.operation.diagnostic import SensitiveTextFilter
|
|
55
|
+
from gitpane.recent.mount_table import PLATFORM_MOUNT_TABLE_READER
|
|
56
|
+
from gitpane.recent.recent_availability import RecentAvailabilityCheck, folder_is_reachable, ignore_log_line, unanswered_after_wait_log_line
|
|
57
|
+
from gitpane.recent.recent_name import repository_display_name
|
|
58
|
+
from gitpane.recent.recent_search import ranked_recent_dirs
|
|
59
|
+
from gitpane.text.typed_path import session_typed_path, typed_path_value
|
|
60
|
+
from gitpane.text.typed_text import typed_value
|
|
61
|
+
from gitpane.thread.weak_signal import weak_signal_emitter
|
|
62
|
+
from gitpane.ui.look.action_icon import action_icon
|
|
63
|
+
from gitpane.ui.look.theme_color import is_dark_palette
|
|
64
|
+
from gitpane.ui.look.theme_look import follow_theme
|
|
65
|
+
from gitpane.ui.look.windows_control_look import WINDOWS_LIST_BORDER_WIDTH, apply_windows_line_edit_look, fit_windows_field_height, secondary_text_color, windows_list_style_sheet
|
|
66
|
+
from gitpane.ui.transport.clone_dialog import CLONE_FORM_CAPTIONS, CLONE_PLAIN_SIDE_BUTTON_TEXTS
|
|
67
|
+
from gitpane.ui.widget.busy_indicator import FRAME_INTERVAL_MS, next_busy_angle, paint_busy_arc
|
|
68
|
+
from gitpane.ui.widget.dialog_layout import (
|
|
69
|
+
BROWSE_TEXT,
|
|
70
|
+
BUTTON_COLUMN,
|
|
71
|
+
FIELD_COLUMN,
|
|
72
|
+
FORM_BAND_BUTTON_MIN_SIZE,
|
|
73
|
+
FORM_GRID_BODY_MARGINS,
|
|
74
|
+
FORM_GRID_CAPTION_INDENT,
|
|
75
|
+
FORM_GRID_COLUMN_SPACING,
|
|
76
|
+
FORM_GRID_ROW_SPACING,
|
|
77
|
+
FORM_GRID_ROWS_TO_BLOCK_SPACE,
|
|
78
|
+
BandButtonSizing,
|
|
79
|
+
add_form_caption,
|
|
80
|
+
apply_side_button_look,
|
|
81
|
+
fit_caption_to_other_form,
|
|
82
|
+
fit_side_buttons,
|
|
83
|
+
install_banded_layout,
|
|
84
|
+
refresh_style_sheet_fonts,
|
|
85
|
+
)
|
|
86
|
+
from gitpane.ui.widget.long_text import LONG_WORD_WRAP_MODE, WrappingLabel
|
|
87
|
+
from gitpane.ui.widget.result_label import ResultTone, result_color
|
|
88
|
+
from gitpane.ui.window_control.dialog_modality import prepare_message_box
|
|
89
|
+
from gitpane.ui.window_control.item_activation import activate_current_row
|
|
90
|
+
from gitpane.ui.window_control.window_placement import WELCOME_ANCHOR_RANK, centre_on_placement_anchor, is_visible_window, mark_placement_anchor
|
|
91
|
+
|
|
92
|
+
if TYPE_CHECKING:
|
|
93
|
+
from collections.abc import Callable
|
|
94
|
+
|
|
95
|
+
from PySide6.QtGui import QCloseEvent, QHideEvent, QResizeEvent, QScreen, QShowEvent
|
|
96
|
+
|
|
97
|
+
from gitpane.operation.diagnostic import DiagnosticLog
|
|
98
|
+
from gitpane.recent.mount_table import MountEntry
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
RECENT_COLUMN = 0
|
|
102
|
+
# The Open repository form uses the margins of the shared form grid, as the Clone window does, except at the bottom: the Clone window has a fixed height
|
|
103
|
+
# that provides the room under its content, while this form's height follows its content, so the recent list keeps as
|
|
104
|
+
# much room above the button band as on its left.
|
|
105
|
+
WELCOME_BODY_MARGINS = QMargins(FORM_GRID_BODY_MARGINS.left(), FORM_GRID_BODY_MARGINS.top(), FORM_GRID_BODY_MARGINS.right(), FORM_GRID_BODY_MARGINS.left())
|
|
106
|
+
# The form's grid has a single row: the repository path.
|
|
107
|
+
PATH_ROW = 0
|
|
108
|
+
# The Open repository form adds its clone button in a column right of Browse…; the rest of the grid keeps the columns of
|
|
109
|
+
# the Clone form, so the caption, the field and Browse… stay lined up with that window.
|
|
110
|
+
CLONE_BUTTON_COLUMN = BUTTON_COLUMN + 1
|
|
111
|
+
# Read alone by a screen reader, "or Clone" does not say what the button does.
|
|
112
|
+
CLONE_BUTTON_ACCESSIBLE_NAME = "Clone a repository"
|
|
113
|
+
# Width of the Search field above the recent list, in logical pixels: wide enough for a few words, while the row of
|
|
114
|
+
# Recent repositories:, Search: and the field still fits in the narrowest form.
|
|
115
|
+
RECENT_SEARCH_FIELD_WIDTH = 200
|
|
116
|
+
RECENT_SEARCH_ACCESSIBLE_NAME = "Search recent repositories"
|
|
117
|
+
# The style only wraps an item's text inside a valid rectangle; the height given does not change the measured height.
|
|
118
|
+
MEASURE_RECT_MIN_HEIGHT = 1
|
|
119
|
+
RECENT_MIN_VISIBLE_ROW_COUNT = 1
|
|
120
|
+
"""Recent rows the list keeps room for however short the form gets, before the form itself has to scroll."""
|
|
121
|
+
UNWRAPPED_MEASURE_WIDTH = 100000
|
|
122
|
+
"""Width wide enough for any recent path to fit on one line, to measure a row as if its text did not wrap."""
|
|
123
|
+
# A little air above and below the lines of a recent repository.
|
|
124
|
+
RECENT_ROW_VERTICAL_PADDING = 3
|
|
125
|
+
# The text of the recents, and of the empty recent box, starts at the same x as the captions' text: the captions' indent
|
|
126
|
+
# minus the list border, which already moves the text right.
|
|
127
|
+
RECENT_ROW_PADDING = QMargins(FORM_GRID_CAPTION_INDENT - WINDOWS_LIST_BORDER_WIDTH, RECENT_ROW_VERTICAL_PADDING,
|
|
128
|
+
FORM_GRID_CAPTION_INDENT - WINDOWS_LIST_BORDER_WIDTH, RECENT_ROW_VERTICAL_PADDING)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def empty_recent_style_sheet(palette: QPalette) -> str:
|
|
132
|
+
"""Return the style sheet of the text shown in place of an empty recent list: the list's border and background, grey text.
|
|
133
|
+
|
|
134
|
+
The text sits as far from the border as the text of a recent row, so both start at the same place.
|
|
135
|
+
"""
|
|
136
|
+
padding = f"{RECENT_ROW_PADDING.top()}px {RECENT_ROW_PADDING.right()}px {RECENT_ROW_PADDING.bottom()}px {RECENT_ROW_PADDING.left()}px"
|
|
137
|
+
return windows_list_style_sheet(palette, "QLabel", hide_focus_outline=False) + f" QLabel {{ color: {secondary_text_color(palette).name()}; padding: {padding}; }}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class WelcomeText(enum.Enum):
|
|
141
|
+
"""Texts the Open repository window shows, one member per text."""
|
|
142
|
+
|
|
143
|
+
TITLE = "GitPane"
|
|
144
|
+
OPEN_REPOSITORY = "Open repository"
|
|
145
|
+
OPEN = "Open"
|
|
146
|
+
CLONE = "or Clone"
|
|
147
|
+
CANCEL_READING = "Cancel reading"
|
|
148
|
+
REPOSITORY_PATH = "Repository path"
|
|
149
|
+
EMPTY_PATH = "Enter a repository path or use Browse."
|
|
150
|
+
RECENT_SEARCH = "Search"
|
|
151
|
+
RECENT_EMPTY = "No recent repositories"
|
|
152
|
+
RECENT_NO_MATCH = "No matching repositories"
|
|
153
|
+
RECENT_HELP = "Enter a repository path or click Browse to get started."
|
|
154
|
+
OPENING = "Opening…"
|
|
155
|
+
INVALID_PATH_TITLE = "Invalid path"
|
|
156
|
+
INVALID_PATH = "This folder does not exist:\n{path}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RecentMark(enum.Enum):
|
|
160
|
+
"""What the third line of a recent's row says about the check of its folder: nothing, still checking, Unknown or Unavailable.
|
|
161
|
+
|
|
162
|
+
The value is what a listed row keeps in its item data; the text the line shows is the ``label`` attribute.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
NONE = ("", "")
|
|
166
|
+
CHECKING = ("checking", "checking")
|
|
167
|
+
UNKNOWN = ("unknown", "Unknown")
|
|
168
|
+
UNAVAILABLE = ("unavailable", "Unavailable")
|
|
169
|
+
|
|
170
|
+
def __new__(cls, value: str, label: str) -> Self:
|
|
171
|
+
"""Keep the value its row stores as the member's value, and the text its line shows beside it."""
|
|
172
|
+
member = object.__new__(cls)
|
|
173
|
+
member._value_ = value
|
|
174
|
+
member.label = label
|
|
175
|
+
return member
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Item data role that holds the value of a recent repository's mark; the path itself is in UserRole.
|
|
179
|
+
RECENT_MARK_ROLE = int(Qt.ItemDataRole.UserRole) + 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def index_recent_mark(index: QModelIndex) -> RecentMark:
|
|
183
|
+
"""Return the mark a listed row carries; a row without any mark data has none."""
|
|
184
|
+
mark_value = index.data(RECENT_MARK_ROLE)
|
|
185
|
+
if mark_value is None:
|
|
186
|
+
return RecentMark.NONE
|
|
187
|
+
return RecentMark(mark_value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def recent_item_text(recent_dir: str, mark: RecentMark) -> str:
|
|
191
|
+
"""Return the whole row of a recent as plain text, for screen readers: its name, its path, and its mark's label when it has one.
|
|
192
|
+
|
|
193
|
+
The name is the one the search ranks and the window title shows, the whole path for the root of a drive, which has
|
|
194
|
+
no folder name. The delegate draws the same lines in colors.
|
|
195
|
+
"""
|
|
196
|
+
text = f"{repository_display_name(recent_dir)}\n{recent_dir}"
|
|
197
|
+
if mark != RecentMark.NONE:
|
|
198
|
+
text += "\n" + mark.label
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class RecentRowLine:
|
|
204
|
+
"""One line of text of a recent repository, as it is measured and painted.
|
|
205
|
+
|
|
206
|
+
``indent`` moves the text right, in the measure as in the painting, so the measured height stays the painted one;
|
|
207
|
+
the "checking" line leaves that room for the turning arc drawn at its left when ``shows_busy_arc`` is set.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
text: str
|
|
211
|
+
font: QFont
|
|
212
|
+
color: QColor
|
|
213
|
+
indent: int = 0
|
|
214
|
+
shows_busy_arc: bool = False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Space between the turning arc and the "checking" text it stands before.
|
|
218
|
+
RECENT_BUSY_ARC_GAP = SPACING_XS
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class RecentRepositoryDelegate(QStyledItemDelegate):
|
|
222
|
+
"""Draw a recent repository as its folder name, its full path in the secondary text color, then its mark in italics.
|
|
223
|
+
|
|
224
|
+
The mark is "checking" while the folder's answer is late, in the secondary text color, Unknown when the check got no
|
|
225
|
+
answer, in yellow, or Unavailable in the warning color. The three take the same line, so going from one to another
|
|
226
|
+
never changes the row's height.
|
|
227
|
+
|
|
228
|
+
The measured height is exactly the painted height: one function lays the lines out for both, so the list asks for
|
|
229
|
+
the room its rows really take and the window grows with them. The text is laid out as plain text, never as rich
|
|
230
|
+
text, since folder names come from the disk.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
234
|
+
"""Start the arc of the "checking" rows at its first angle; the window's spinner timer turns it."""
|
|
235
|
+
super().__init__(parent)
|
|
236
|
+
self.busy_angle = 0
|
|
237
|
+
|
|
238
|
+
def row_lines(self, option: QStyleOptionViewItem, index: QModelIndex) -> list[RecentRowLine]:
|
|
239
|
+
"""Return the lines of a listed row: name, path, then its mark in italics when it has one."""
|
|
240
|
+
return self.recent_row_lines(option, index.data(Qt.ItemDataRole.UserRole) or "", index_recent_mark(index))
|
|
241
|
+
|
|
242
|
+
def recent_row_lines(self, option: QStyleOptionViewItem, recent_dir: str, mark: RecentMark) -> list[RecentRowLine]:
|
|
243
|
+
"""Return the lines of a recent's row, whether or not the list shows it right now.
|
|
244
|
+
|
|
245
|
+
Unavailable is written in the warning color. "checking" only says the answer is late, so it takes the path's
|
|
246
|
+
secondary color; Unknown says the check gave up, so it is yellow, to be noticed without being read as gone. A
|
|
247
|
+
selected row is written entirely in the selection's text color, of the active or inactive group as the window
|
|
248
|
+
is, so every line stays readable on the selection.
|
|
249
|
+
"""
|
|
250
|
+
palette = option.palette
|
|
251
|
+
dark = is_dark_palette(palette)
|
|
252
|
+
name_color = palette.color(QPalette.ColorRole.Text)
|
|
253
|
+
path_color = secondary_text_color(palette)
|
|
254
|
+
warning_color = QColor(result_color(ResultTone.WARNING, dark))
|
|
255
|
+
unknown_color = QColor(UNKNOWN_DARK_COLOR if dark else UNKNOWN_LIGHT_COLOR)
|
|
256
|
+
if option.state & QStyle.StateFlag.State_Selected:
|
|
257
|
+
group = QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else QPalette.ColorGroup.Inactive
|
|
258
|
+
selected_color = palette.color(group, QPalette.ColorRole.HighlightedText)
|
|
259
|
+
name_color = path_color = warning_color = unknown_color = selected_color
|
|
260
|
+
font = QFont(option.font)
|
|
261
|
+
lines = [RecentRowLine(repository_display_name(recent_dir), font, name_color), RecentRowLine(recent_dir, font, path_color)]
|
|
262
|
+
if mark == RecentMark.NONE:
|
|
263
|
+
return lines
|
|
264
|
+
italic_font = QFont(font)
|
|
265
|
+
italic_font.setItalic(True)
|
|
266
|
+
if mark == RecentMark.UNAVAILABLE:
|
|
267
|
+
lines.append(RecentRowLine(mark.label, italic_font, warning_color))
|
|
268
|
+
return lines
|
|
269
|
+
if mark == RecentMark.UNKNOWN:
|
|
270
|
+
lines.append(RecentRowLine(mark.label, italic_font, unknown_color))
|
|
271
|
+
return lines
|
|
272
|
+
# The arc sits in a square as tall as the line, left of the text.
|
|
273
|
+
arc_side = QFontMetrics(italic_font).height()
|
|
274
|
+
lines.append(RecentRowLine(mark.label, italic_font, path_color, arc_side + RECENT_BUSY_ARC_GAP, shows_busy_arc=True))
|
|
275
|
+
return lines
|
|
276
|
+
|
|
277
|
+
def _lay_out_lines(self, lines: list[RecentRowLine], width: int) -> tuple[list[tuple[QTextLayout, RecentRowLine, float]], float, float]:
|
|
278
|
+
"""Wrap each line at the width less its indent and stack them, returning the layouts, their total height and the widest wrapped line.
|
|
279
|
+
|
|
280
|
+
Each layout comes with its row line and the top of its first text line. A path without spaces still wraps,
|
|
281
|
+
anywhere if needed, as the other long values of the application do.
|
|
282
|
+
"""
|
|
283
|
+
text_option = QTextOption()
|
|
284
|
+
text_option.setWrapMode(LONG_WORD_WRAP_MODE)
|
|
285
|
+
layouts: list[tuple[QTextLayout, RecentRowLine, float]] = []
|
|
286
|
+
top = 0.0
|
|
287
|
+
widest = 0.0
|
|
288
|
+
for row_line in lines:
|
|
289
|
+
layout = QTextLayout(row_line.text, row_line.font)
|
|
290
|
+
layout.setTextOption(text_option)
|
|
291
|
+
line_top = top
|
|
292
|
+
layout.beginLayout()
|
|
293
|
+
while True:
|
|
294
|
+
text_line = layout.createLine()
|
|
295
|
+
if not text_line.isValid():
|
|
296
|
+
break
|
|
297
|
+
text_line.setLineWidth(max(1, width - row_line.indent))
|
|
298
|
+
text_line.setPosition(QPointF(row_line.indent, top))
|
|
299
|
+
top += text_line.height()
|
|
300
|
+
widest = max(widest, row_line.indent + text_line.naturalTextWidth())
|
|
301
|
+
layout.endLayout()
|
|
302
|
+
layouts.append((layout, row_line, line_top))
|
|
303
|
+
return layouts, top, widest
|
|
304
|
+
|
|
305
|
+
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
|
|
306
|
+
"""Measure the listed row at the width given in option.rect, which the list sets to its own width, padding included."""
|
|
307
|
+
item_option = QStyleOptionViewItem(option)
|
|
308
|
+
self.initStyleOption(item_option, index)
|
|
309
|
+
return self.measure_recent_row(item_option, index.data(Qt.ItemDataRole.UserRole) or "", index_recent_mark(index))
|
|
310
|
+
|
|
311
|
+
def measure_recent_row(self, option: QStyleOptionViewItem, recent_dir: str, mark: RecentMark) -> QSize:
|
|
312
|
+
"""Measure a recent's row at the width given in option.rect, padding included, whether or not the list shows it.
|
|
313
|
+
|
|
314
|
+
Qt also asks with an invalid rectangle, during its own layout and before the list has a width; the text is then
|
|
315
|
+
measured unwrapped, as Qt's own delegate does, rather than wrapped at one pixel into one glyph per line.
|
|
316
|
+
"""
|
|
317
|
+
horizontal_padding = RECENT_ROW_PADDING.left() + RECENT_ROW_PADDING.right()
|
|
318
|
+
vertical_padding = RECENT_ROW_PADDING.top() + RECENT_ROW_PADDING.bottom()
|
|
319
|
+
if option.rect.isValid():
|
|
320
|
+
text_width = max(1, option.rect.width() - horizontal_padding)
|
|
321
|
+
else:
|
|
322
|
+
text_width = UNWRAPPED_MEASURE_WIDTH
|
|
323
|
+
_layouts, height, widest = self._lay_out_lines(self.recent_row_lines(option, recent_dir, mark), text_width)
|
|
324
|
+
return QSize(ceil(widest) + horizontal_padding, ceil(height) + vertical_padding)
|
|
325
|
+
|
|
326
|
+
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
|
327
|
+
"""Let the style draw the row's background, selection, hover and focus without text, then draw the lines over it.
|
|
328
|
+
|
|
329
|
+
A "checking" line gets the turning arc at its left, at the angle the window's spinner timer last set; painting
|
|
330
|
+
only reads that angle.
|
|
331
|
+
"""
|
|
332
|
+
item_option = QStyleOptionViewItem(option)
|
|
333
|
+
self.initStyleOption(item_option, index)
|
|
334
|
+
lines = self.row_lines(item_option, index)
|
|
335
|
+
item_option.text = ""
|
|
336
|
+
widget = option.widget
|
|
337
|
+
style = widget.style() if widget is not None else QApplication.style()
|
|
338
|
+
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, item_option, painter, widget)
|
|
339
|
+
text_rect = option.rect.marginsRemoved(RECENT_ROW_PADDING)
|
|
340
|
+
layouts, _height, _widest = self._lay_out_lines(lines, max(1, text_rect.width()))
|
|
341
|
+
painter.save()
|
|
342
|
+
painter.setClipRect(option.rect)
|
|
343
|
+
origin = QPointF(text_rect.topLeft())
|
|
344
|
+
for layout, row_line, line_top in layouts:
|
|
345
|
+
painter.setPen(row_line.color)
|
|
346
|
+
layout.draw(painter, origin)
|
|
347
|
+
if row_line.shows_busy_arc:
|
|
348
|
+
arc_side = row_line.indent - RECENT_BUSY_ARC_GAP
|
|
349
|
+
paint_busy_arc(painter, QRectF(origin.x(), origin.y() + line_top, arc_side, arc_side), self.busy_angle, row_line.color)
|
|
350
|
+
painter.restore()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class RecentRepositoryList(QListWidget):
|
|
354
|
+
"""Recent repositories, as tall as all their rows when the form has room, otherwise scrolling on their own.
|
|
355
|
+
|
|
356
|
+
The list asks for the height of every row, so a short list needs no scrolling at all; when the form reaches its height
|
|
357
|
+
cap the layout shrinks only the list, which then scrolls, so the path field above it stays visible; the buttons sit
|
|
358
|
+
in the band under the form and are always visible.
|
|
359
|
+
The paths wrap, so a row's height depends on the list width: the wanted height is measured again whenever that width
|
|
360
|
+
changes.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
def __init__(self) -> None:
|
|
364
|
+
"""Draw the rows with the recent delegate, wrap long paths instead of eliding them, and scroll by pixel since a wrapped row can be several lines tall."""
|
|
365
|
+
super().__init__()
|
|
366
|
+
self.delegate = RecentRepositoryDelegate(self)
|
|
367
|
+
self.setItemDelegate(self.delegate)
|
|
368
|
+
self.setWordWrap(True)
|
|
369
|
+
self.setTextElideMode(Qt.TextElideMode.ElideNone)
|
|
370
|
+
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
|
371
|
+
|
|
372
|
+
def measure_option(self, width: int) -> QStyleOptionViewItem:
|
|
373
|
+
"""Return the list's item option with a rectangle of the given width; without any width yet, wide enough not to wrap."""
|
|
374
|
+
option = QStyleOptionViewItem()
|
|
375
|
+
self.initViewItemOption(option)
|
|
376
|
+
option.rect = QRect(0, 0, width if width > 0 else UNWRAPPED_MEASURE_WIDTH, MEASURE_RECT_MIN_HEIGHT)
|
|
377
|
+
return option
|
|
378
|
+
|
|
379
|
+
def row_height_at_width(self, row: int, width: int) -> int:
|
|
380
|
+
"""Return the height the row takes once its text wraps at the given width; without any width yet, its unwrapped height."""
|
|
381
|
+
index = self.model().index(row, RECENT_COLUMN)
|
|
382
|
+
return self.itemDelegateForIndex(index).sizeHint(self.measure_option(width), index).height()
|
|
383
|
+
|
|
384
|
+
def recent_height_at_width(self, recent_dir: str, mark: RecentMark, width: int) -> int:
|
|
385
|
+
"""Return the height a recent's row takes at the given width with the given mark, even while the search hides it."""
|
|
386
|
+
return self.delegate.measure_recent_row(self.measure_option(width), recent_dir, mark).height()
|
|
387
|
+
|
|
388
|
+
def sizeHintForRow(self, row: int) -> int:
|
|
389
|
+
"""Return the height the row takes once its text wraps at the list's current width.
|
|
390
|
+
|
|
391
|
+
The base implementation measures the text without any width, so a wrapped path would count as a single line.
|
|
392
|
+
"""
|
|
393
|
+
return self.row_height_at_width(row, self.viewport().width())
|
|
394
|
+
|
|
395
|
+
def rows_height(self) -> int:
|
|
396
|
+
"""Return the height every row takes at the current width, plus the list frame."""
|
|
397
|
+
height = self.frameWidth() * 2
|
|
398
|
+
for row in range(self.count()):
|
|
399
|
+
height += self.sizeHintForRow(row)
|
|
400
|
+
return height
|
|
401
|
+
|
|
402
|
+
def sizeHint(self) -> QSize:
|
|
403
|
+
"""Ask for the height of every row, so the form grows to show all the recents without scrolling when it can."""
|
|
404
|
+
return QSize(super().sizeHint().width(), self.rows_height())
|
|
405
|
+
|
|
406
|
+
def minimumSizeHint(self) -> QSize:
|
|
407
|
+
"""Let the layout shrink the list down to room for its first rows; below its wanted height the list scrolls on its own.
|
|
408
|
+
|
|
409
|
+
The first rows are measured as if their text did not wrap: a long path that wraps over many lines on a narrow form
|
|
410
|
+
would otherwise raise the minimum past the room left, and the whole form would scroll instead of the list.
|
|
411
|
+
"""
|
|
412
|
+
height = self.frameWidth() * 2
|
|
413
|
+
for row in range(min(RECENT_MIN_VISIBLE_ROW_COUNT, self.count())):
|
|
414
|
+
height += self.row_height_at_width(row, UNWRAPPED_MEASURE_WIDTH)
|
|
415
|
+
return QSize(super().minimumSizeHint().width(), height)
|
|
416
|
+
|
|
417
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
418
|
+
"""Measure the wanted height again when the width changes, since the paths then wrap differently.
|
|
419
|
+
|
|
420
|
+
Only a width change triggers it: the scroll bar appearing narrows the rows but not the list, so this cannot loop.
|
|
421
|
+
"""
|
|
422
|
+
super().resizeEvent(event)
|
|
423
|
+
if event.size().width() != event.oldSize().width():
|
|
424
|
+
self.updateGeometry()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class WelcomeFormContent(QWidget):
|
|
428
|
+
"""Content of the scrolling form, which the scroll area may shrink down to its minimum height rather than its preferred one."""
|
|
429
|
+
|
|
430
|
+
def heightForWidth(self, width: int) -> int:
|
|
431
|
+
"""Return the smallest height the form's layout accepts at this width, instead of the height it would like.
|
|
432
|
+
|
|
433
|
+
Workaround: a resizable QScrollArea never makes a content whose height depends on its width (the wrapped labels)
|
|
434
|
+
shorter than that content's heightForWidth, which QWidget answers with the preferred height. The form would then
|
|
435
|
+
always be as tall as every recent row and scroll as a whole. Answering with the minimum height lets the scroll
|
|
436
|
+
area fit the form to the window, so the layout shrinks the recent list, which scrolls on its own; the form only
|
|
437
|
+
scrolls once it is shorter than that minimum.
|
|
438
|
+
"""
|
|
439
|
+
layout = self.layout()
|
|
440
|
+
if layout is None or not layout.hasHeightForWidth():
|
|
441
|
+
return super().heightForWidth(width)
|
|
442
|
+
return layout.totalMinimumHeightForWidth(width)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class WelcomeWindow(QWidget):
|
|
446
|
+
"""Collect a repository path and display persisted successful openings.
|
|
447
|
+
|
|
448
|
+
Drawn like the Git Extensions windows: a white body holding the path row (field, Browse… and the "or Clone" button
|
|
449
|
+
that opens the Clone window) and the recent repositories, above a grey band carrying Settings on its left and Open
|
|
450
|
+
on its right. A Search field at the top right of the recent list filters it as the user types.
|
|
451
|
+
|
|
452
|
+
Each recent's folder is checked in the background: a recent whose answer is late shows "checking" with a turning
|
|
453
|
+
arc after a short delay, then Unknown when its check fails without answer or waits too long; a missing folder or a
|
|
454
|
+
failed opening shows Unavailable. A real answer, however late, always replaces "checking" and Unknown.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
open_requested = Signal(str)
|
|
458
|
+
clone_requested = Signal()
|
|
459
|
+
closed = Signal()
|
|
460
|
+
cancel_requested = Signal()
|
|
461
|
+
setting_requested = Signal()
|
|
462
|
+
availability_checked = Signal(str, bool)
|
|
463
|
+
availability_log_line = Signal(str)
|
|
464
|
+
availability_unanswered = Signal(str)
|
|
465
|
+
|
|
466
|
+
def __init__(self, recent_dirs: list[str], anchor: QWidget | None = None, is_reachable: Callable[[str], bool] = folder_is_reachable,
|
|
467
|
+
diagnostic_log: DiagnosticLog | None = None, read_mount_table: Callable[[], list[MountEntry]] | None = PLATFORM_MOUNT_TABLE_READER,
|
|
468
|
+
typed_path: Callable[[str], str] = session_typed_path) -> None:
|
|
469
|
+
"""Build the opening form as a body above a button band, with keyboard-accessible actions.
|
|
470
|
+
|
|
471
|
+
``anchor`` is the repository window the form was opened from; the form opens centred on it, or on the screen
|
|
472
|
+
when there is none (at startup). ``is_reachable`` answers whether a recent folder can be reached, on a
|
|
473
|
+
background thread; tests give their own answers instead of asking the disk. ``diagnostic_log`` is the --log
|
|
474
|
+
file, which learns why a folder check got no answer; without it those reasons are dropped.
|
|
475
|
+
``read_mount_table`` reads the system's mount table to group the folder checks by disk; tests give their own table.
|
|
476
|
+
``typed_path`` turns the text of the path field, without its blanks, into the path to open, expanding ``~`` where
|
|
477
|
+
that is the usage; tests give their own system and home folder.
|
|
478
|
+
"""
|
|
479
|
+
super().__init__()
|
|
480
|
+
mark_placement_anchor(self, WELCOME_ANCHOR_RANK)
|
|
481
|
+
self.placement_anchor = anchor
|
|
482
|
+
self.typed_path = typed_path
|
|
483
|
+
self.centred = False
|
|
484
|
+
self.setWindowTitle(WelcomeText.TITLE.value)
|
|
485
|
+
# Starts at the height cap so the first layout gives the recent list its full width; the first show then fits the
|
|
486
|
+
# height to the recent repositories. Both are measured on the screen the form opens on, which may not be its own yet.
|
|
487
|
+
self.resize(min(WELCOME_WIDTH, self.placement_screen().availableGeometry().width() - SCREEN_MARGIN * 2), self.recent_height_cap())
|
|
488
|
+
# The recent list does the scrolling when the recents do not fit; this scroll area is only the last resort for a
|
|
489
|
+
# form shorter than its controls plus one recent row (very small screen, or the user shrinking the window). The
|
|
490
|
+
# buttons sit in the band under it and never scroll.
|
|
491
|
+
self.scroll_area = QScrollArea()
|
|
492
|
+
self.scroll_area.setWidgetResizable(True)
|
|
493
|
+
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
|
494
|
+
# Without a frame the scroll area would be an invisible Tab stop; it still scrolls to whichever child takes the focus.
|
|
495
|
+
self.scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
496
|
+
content = WelcomeFormContent()
|
|
497
|
+
layout = QVBoxLayout(content)
|
|
498
|
+
self.scroll_area.setWidget(content)
|
|
499
|
+
layout.setSpacing(0)
|
|
500
|
+
layout.setContentsMargins(WELCOME_BODY_MARGINS)
|
|
501
|
+
self.path_edit = QLineEdit()
|
|
502
|
+
self.path_edit.setAccessibleName(WelcomeText.REPOSITORY_PATH.value)
|
|
503
|
+
self.path_edit.returnPressed.connect(self._request_open)
|
|
504
|
+
self.browse_button = QPushButton(BROWSE_TEXT)
|
|
505
|
+
# Enter opens the typed path; it never runs Browse….
|
|
506
|
+
self.browse_button.setAutoDefault(False)
|
|
507
|
+
self.browse_button.setIcon(action_icon("folder", self.devicePixelRatioF()))
|
|
508
|
+
self.browse_button.clicked.connect(self._browse)
|
|
509
|
+
self.clone_button = QPushButton(WelcomeText.CLONE.value)
|
|
510
|
+
# Enter opens the typed path; it never starts a clone.
|
|
511
|
+
self.clone_button.setAutoDefault(False)
|
|
512
|
+
self.clone_button.setAccessibleName(CLONE_BUTTON_ACCESSIBLE_NAME)
|
|
513
|
+
self.clone_button.clicked.connect(self.clone_requested)
|
|
514
|
+
path_grid = QGridLayout()
|
|
515
|
+
path_grid.setContentsMargins(0, 0, 0, 0)
|
|
516
|
+
path_grid.setHorizontalSpacing(FORM_GRID_COLUMN_SPACING)
|
|
517
|
+
path_grid.setVerticalSpacing(FORM_GRID_ROW_SPACING)
|
|
518
|
+
path_grid.setColumnStretch(FIELD_COLUMN, 1)
|
|
519
|
+
self.path_caption_label = add_form_caption(path_grid, PATH_ROW, WelcomeText.REPOSITORY_PATH.value + ":", self.path_edit)
|
|
520
|
+
path_grid.addWidget(self.path_edit, PATH_ROW, FIELD_COLUMN)
|
|
521
|
+
path_grid.addWidget(self.browse_button, PATH_ROW, BUTTON_COLUMN)
|
|
522
|
+
path_grid.addWidget(self.clone_button, PATH_ROW, CLONE_BUTTON_COLUMN)
|
|
523
|
+
layout.addLayout(path_grid)
|
|
524
|
+
# Plain text: the line shows paths and Git causes. A long path wraps instead of widening the window, and the
|
|
525
|
+
# line stays hidden while it has nothing to say.
|
|
526
|
+
self.status_label = WrappingLabel()
|
|
527
|
+
self.status_label.setWordWrap(True)
|
|
528
|
+
status_policy = self.status_label.sizePolicy()
|
|
529
|
+
status_policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
|
|
530
|
+
self.status_label.setSizePolicy(status_policy)
|
|
531
|
+
# One row space under the path, its text in line with the captions.
|
|
532
|
+
self.status_label.setContentsMargins(FORM_GRID_CAPTION_INDENT, FORM_GRID_ROW_SPACING, 0, 0)
|
|
533
|
+
self.status_label.hide()
|
|
534
|
+
layout.addWidget(self.status_label)
|
|
535
|
+
# The Clone window leaves this space between its rows and the block under them.
|
|
536
|
+
layout.addSpacing(FORM_GRID_ROWS_TO_BLOCK_SPACE)
|
|
537
|
+
self.recent_list = RecentRepositoryList()
|
|
538
|
+
self.recent_list.setAccessibleName(SharedText.RECENT_REPOSITORY.value)
|
|
539
|
+
self.unavailable_dir_set: set[str] = set()
|
|
540
|
+
self.recent_list.itemClicked.connect(self._select_recent)
|
|
541
|
+
self.recent_list.itemActivated.connect(self._open_recent)
|
|
542
|
+
self.recent_list.installEventFilter(self)
|
|
543
|
+
self.recent_dirs: list[str] = []
|
|
544
|
+
# Answers of the background folder checks, kept for the window's life: a new history only asks about new folders.
|
|
545
|
+
self.reachable_by_dir: dict[str, bool] = {}
|
|
546
|
+
# Folders asked about by this window that have no real answer yet and are not shown as Unknown.
|
|
547
|
+
self.pending_dir_set: set[str] = set()
|
|
548
|
+
# Pending folders that have waited longer than the display delay: their rows show "checking".
|
|
549
|
+
self.checking_dir_set: set[str] = set()
|
|
550
|
+
# Folders whose question failed without an answer, or waited too long: their rows show Unknown until an answer comes.
|
|
551
|
+
self.unknown_dir_set: set[str] = set()
|
|
552
|
+
# Row of each recent the list shows right now, rebuilt with the rows, so a mark change finds its row without a walk.
|
|
553
|
+
self.row_by_recent_dir: dict[str, int] = {}
|
|
554
|
+
# One timer turns the arcs of every "checking" row, and only runs while one is shown.
|
|
555
|
+
self.checking_spinner_timer = QTimer(self)
|
|
556
|
+
self.checking_spinner_timer.setInterval(FRAME_INTERVAL_MS)
|
|
557
|
+
self.checking_spinner_timer.timeout.connect(self._advance_checking_spinner)
|
|
558
|
+
# The answers arrive on worker threads; the queued connections run the row update and the log write on the
|
|
559
|
+
# window's own thread, the only one that writes the log file.
|
|
560
|
+
self.availability_checked.connect(self._show_availability, Qt.ConnectionType.QueuedConnection)
|
|
561
|
+
self.availability_unanswered.connect(self._show_unanswered, Qt.ConnectionType.QueuedConnection)
|
|
562
|
+
self.diagnostic_log = diagnostic_log
|
|
563
|
+
log_line = ignore_log_line
|
|
564
|
+
if diagnostic_log is not None:
|
|
565
|
+
self.availability_log_line.connect(self._write_availability_log_line, Qt.ConnectionType.QueuedConnection)
|
|
566
|
+
log_line = weak_signal_emitter(self, lambda form: form.availability_log_line)
|
|
567
|
+
self.availability_check = RecentAvailabilityCheck(weak_signal_emitter(self, lambda form: form.availability_checked), is_reachable, log_line=log_line,
|
|
568
|
+
read_mount_table=read_mount_table,
|
|
569
|
+
report_unanswered=weak_signal_emitter(self, lambda form: form.availability_unanswered))
|
|
570
|
+
self.search_edit = QLineEdit()
|
|
571
|
+
self.search_edit.setAccessibleName(RECENT_SEARCH_ACCESSIBLE_NAME)
|
|
572
|
+
self.search_edit.setClearButtonEnabled(True)
|
|
573
|
+
self.search_edit.setFixedWidth(RECENT_SEARCH_FIELD_WIDTH)
|
|
574
|
+
self.search_edit.textChanged.connect(self._search_changed)
|
|
575
|
+
self.search_edit.returnPressed.connect(self._open_search_result)
|
|
576
|
+
self.search_edit.installEventFilter(self)
|
|
577
|
+
self.search_label = QLabel(WelcomeText.RECENT_SEARCH.value + ":")
|
|
578
|
+
self.search_label.setBuddy(self.search_edit)
|
|
579
|
+
self.recent_caption_label = QLabel(SharedText.RECENT_REPOSITORY.value + ":")
|
|
580
|
+
self.recent_caption_label.setContentsMargins(FORM_GRID_CAPTION_INDENT, 0, 0, 0)
|
|
581
|
+
self.recent_caption_label.setBuddy(self.recent_list)
|
|
582
|
+
# The caption of the list on the left, the search on the right: the field's right edge falls on the list's.
|
|
583
|
+
recent_caption_row = QHBoxLayout()
|
|
584
|
+
recent_caption_row.setContentsMargins(0, 0, 0, 0)
|
|
585
|
+
recent_caption_row.setSpacing(FORM_GRID_COLUMN_SPACING)
|
|
586
|
+
recent_caption_row.addWidget(self.recent_caption_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
587
|
+
recent_caption_row.addStretch(1)
|
|
588
|
+
recent_caption_row.addWidget(self.search_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
589
|
+
recent_caption_row.addWidget(self.search_edit, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
590
|
+
layout.addLayout(recent_caption_row)
|
|
591
|
+
layout.addSpacing(SPACING_XS)
|
|
592
|
+
layout.addWidget(self.recent_list, 1)
|
|
593
|
+
# Without recents, this text takes the list's place inside the same border, so the window keeps its layout.
|
|
594
|
+
self.empty_label = QLabel(WelcomeText.RECENT_EMPTY.value + "\n" + WelcomeText.RECENT_HELP.value)
|
|
595
|
+
self.empty_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
596
|
+
self.empty_label.setWordWrap(True)
|
|
597
|
+
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
|
598
|
+
# A framed QLabel indents its text by half an "x" on its own; the padding alone places the text.
|
|
599
|
+
self.empty_label.setIndent(0)
|
|
600
|
+
layout.addWidget(self.empty_label, 1)
|
|
601
|
+
body = QVBoxLayout()
|
|
602
|
+
body.addWidget(self.scroll_area)
|
|
603
|
+
self.setting_button = QPushButton(SharedText.SETTING_BUTTON.value)
|
|
604
|
+
self.setting_button.clicked.connect(self.setting_requested)
|
|
605
|
+
self.open_button = QPushButton(WelcomeText.OPEN.value)
|
|
606
|
+
self.open_button.clicked.connect(self._request_open)
|
|
607
|
+
# Outside a dialog the default role only draws the default outline; Enter opens through the path field and the
|
|
608
|
+
# recent list, connected above. It is given before the band is built and measured: the default outline makes
|
|
609
|
+
# Open taller, and Qt does not measure a band again when one of its buttons becomes the default, so a band
|
|
610
|
+
# measured earlier would keep the shorter height and be laid out too short.
|
|
611
|
+
self.open_button.setDefault(True)
|
|
612
|
+
# Shown in Open's place while a repository is read: the band skips the hidden Open button.
|
|
613
|
+
self.cancel_button = QPushButton(WelcomeText.CANCEL_READING.value)
|
|
614
|
+
self.cancel_button.clicked.connect(self.cancel_requested)
|
|
615
|
+
self.cancel_button.hide()
|
|
616
|
+
for button in (self.setting_button, self.open_button, self.cancel_button):
|
|
617
|
+
button.setMinimumSize(FORM_BAND_BUTTON_MIN_SIZE)
|
|
618
|
+
self.button_band = install_banded_layout(self, body, [self.open_button, self.cancel_button], body_margins=QMargins(), leading_buttons=[self.setting_button], button_sizing=BandButtonSizing.WINDOW)
|
|
619
|
+
# Height of the button band when the form last laid its theme look or fitted its metrics: a theme switch changes
|
|
620
|
+
# it, and a shown form follows by the difference only.
|
|
621
|
+
self.band_height = self.button_band.sizeHint().height()
|
|
622
|
+
self._apply_theme_look()
|
|
623
|
+
# Tab follows the reading order: the form from top to bottom, then the band from left to right.
|
|
624
|
+
tab_order = [self.path_edit, self.browse_button, self.clone_button, self.search_edit, self.recent_list, self.setting_button, self.open_button,
|
|
625
|
+
self.cancel_button]
|
|
626
|
+
for previous_control, next_control in pairwise(tab_order):
|
|
627
|
+
QWidget.setTabOrder(previous_control, next_control)
|
|
628
|
+
self.show_recent(recent_dirs)
|
|
629
|
+
close_action = QAction("Close", self)
|
|
630
|
+
close_action.setShortcut("Ctrl+W")
|
|
631
|
+
close_action.triggered.connect(self.close)
|
|
632
|
+
self.addAction(close_action)
|
|
633
|
+
# Set once the band helper has created the outer layout: without it, Qt makes that layout's minimum size the
|
|
634
|
+
# window's, and the form could not be shrunk below all its rows.
|
|
635
|
+
self.layout().setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
636
|
+
self.refresh_metrics(self.placement_screen().availableGeometry())
|
|
637
|
+
self.path_edit.setFocus()
|
|
638
|
+
follow_theme(self, self._apply_theme_look)
|
|
639
|
+
|
|
640
|
+
def _apply_theme_look(self) -> None:
|
|
641
|
+
"""Lay down everything of the form's own look that depends on the light or dark theme; the only place that does.
|
|
642
|
+
|
|
643
|
+
The body, the band and the band's buttons are laid by the banded layout, which follows the theme on its own.
|
|
644
|
+
This lays the rest: Browse… and the clone button, the two text fields, the recent list and its empty text. It
|
|
645
|
+
runs once at construction and again at each theme switch, after the band. The colors come from the application
|
|
646
|
+
palette, never from a widget's: a style sheet changes its widget's palette. The recent list keeps its focus
|
|
647
|
+
rectangle for keyboard navigation.
|
|
648
|
+
|
|
649
|
+
The band's buttons are not as tall in both themes, so a shown form changes its height by as much as the band
|
|
650
|
+
did since the last look: the recent list keeps its height and does not start scrolling, exactly as in a form
|
|
651
|
+
opened in the new theme.
|
|
652
|
+
"""
|
|
653
|
+
source = QApplication.palette()
|
|
654
|
+
# Browse… and the clone button beside it share the Windows look, the field height and one common width.
|
|
655
|
+
apply_side_button_look([self.browse_button, self.clone_button], CLONE_PLAIN_SIDE_BUTTON_TEXTS)
|
|
656
|
+
apply_windows_line_edit_look(self.path_edit)
|
|
657
|
+
apply_windows_line_edit_look(self.search_edit)
|
|
658
|
+
self.recent_list.setStyleSheet(windows_list_style_sheet(source, "QListWidget", hide_focus_outline=False))
|
|
659
|
+
self.empty_label.setStyleSheet(empty_recent_style_sheet(source))
|
|
660
|
+
band_height = self.button_band.sizeHint().height()
|
|
661
|
+
if self.centred:
|
|
662
|
+
self.resize(self.width(), self.height() + band_height - self.band_height)
|
|
663
|
+
self.band_height = band_height
|
|
664
|
+
|
|
665
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
666
|
+
"""Follow the screen density and the system text size, and keep the opening form within the current screen.
|
|
667
|
+
|
|
668
|
+
Runs again after every font change: the path field, Browse… and the clone button keep the captures' size at the
|
|
669
|
+
default text size and grow with a larger one, so their text is never cut; the caption follows the font on its own,
|
|
670
|
+
and takes the Clone window's caption column width so the path field stays in line with the Clone window's fields.
|
|
671
|
+
"""
|
|
672
|
+
refresh_style_sheet_fonts(self)
|
|
673
|
+
fit_caption_to_other_form(self.path_caption_label, CLONE_FORM_CAPTIONS)
|
|
674
|
+
self.browse_button.setIcon(action_icon("folder", self.devicePixelRatioF()))
|
|
675
|
+
fit_side_buttons([self.browse_button, self.clone_button], CLONE_PLAIN_SIDE_BUTTON_TEXTS)
|
|
676
|
+
fit_windows_field_height(self.path_edit)
|
|
677
|
+
fit_windows_field_height(self.search_edit)
|
|
678
|
+
fit_window(self, WELCOME_MIN_WIDTH, WELCOME_MIN_HEIGHT, available)
|
|
679
|
+
self.band_height = self.button_band.sizeHint().height()
|
|
680
|
+
|
|
681
|
+
def show_recent(self, recent_dirs: list[str]) -> None:
|
|
682
|
+
"""Keep the full recent history and show it through the current search at once, then learn in the background which folders are gone.
|
|
683
|
+
|
|
684
|
+
Only the folders this window never asked about are checked, on background threads: a disk spinning up from
|
|
685
|
+
sleep or a disconnected network drive never freezes the window, and a new history does not ask again about
|
|
686
|
+
the folders already answered. A folder already known unavailable is not asked about. Rows are shown without
|
|
687
|
+
a mark until their answer comes back; a folder still without an answer after a short delay shows "checking",
|
|
688
|
+
so a local folder answering at once never makes the list jump, and Unknown after the long wait. Its answer
|
|
689
|
+
still replaces either mark whenever it comes. The editable path text is left alone.
|
|
690
|
+
"""
|
|
691
|
+
self.recent_dirs = list(recent_dirs)
|
|
692
|
+
checked_recent_dirs: list[str] = []
|
|
693
|
+
new_pending_dir_set: set[str] = set()
|
|
694
|
+
for recent_dir in recent_dirs:
|
|
695
|
+
if recent_dir in self.unavailable_dir_set:
|
|
696
|
+
continue
|
|
697
|
+
checked_recent_dirs.append(recent_dir)
|
|
698
|
+
if recent_dir not in self.reachable_by_dir and recent_dir not in self.pending_dir_set and recent_dir not in self.unknown_dir_set:
|
|
699
|
+
new_pending_dir_set.add(recent_dir)
|
|
700
|
+
self.pending_dir_set.update(new_pending_dir_set)
|
|
701
|
+
self.availability_check.request(checked_recent_dirs)
|
|
702
|
+
if new_pending_dir_set:
|
|
703
|
+
# The folders asked about together wait from the same instant: one single-shot timer per batch, with the
|
|
704
|
+
# window as its context so it is dropped along with a deleted window.
|
|
705
|
+
batch = frozenset(new_pending_dir_set)
|
|
706
|
+
QTimer.singleShot(RECENT_CHECKING_DISPLAY_DELAY_MS, self, partial(self._show_checking, batch))
|
|
707
|
+
QTimer.singleShot(RECENT_CHECK_UNKNOWN_DELAY_MS, self, partial(self._show_unknown_after_wait, batch))
|
|
708
|
+
self._apply_recent_filter(keep_current=True)
|
|
709
|
+
|
|
710
|
+
def recent_mark(self, recent_dir: str) -> RecentMark:
|
|
711
|
+
"""Return the mark the row of a recent carries now, by the one rule the window follows."""
|
|
712
|
+
return self._mark_with_answer(recent_dir, self.reachable_by_dir.get(recent_dir))
|
|
713
|
+
|
|
714
|
+
def _mark_with_answer(self, recent_dir: str, reachable: bool | None) -> RecentMark:
|
|
715
|
+
"""Return the mark of a recent whose folder check answered ``reachable``, None for no answer yet.
|
|
716
|
+
|
|
717
|
+
In this order: a failed opening or a folder found missing is Unavailable; a folder found present has no mark;
|
|
718
|
+
then a folder without answer is Unknown once the check gave up, "checking" once it waited past the display
|
|
719
|
+
delay, and unmarked before that. A real answer therefore always wins over Unknown, and only a real "absent" or
|
|
720
|
+
a failed opening ever gives Unavailable.
|
|
721
|
+
"""
|
|
722
|
+
if recent_dir in self.unavailable_dir_set or reachable is False:
|
|
723
|
+
return RecentMark.UNAVAILABLE
|
|
724
|
+
if reachable:
|
|
725
|
+
return RecentMark.NONE
|
|
726
|
+
if recent_dir in self.unknown_dir_set:
|
|
727
|
+
return RecentMark.UNKNOWN
|
|
728
|
+
if recent_dir in self.checking_dir_set:
|
|
729
|
+
return RecentMark.CHECKING
|
|
730
|
+
return RecentMark.NONE
|
|
731
|
+
|
|
732
|
+
def is_marked_unavailable(self, recent_dir: str) -> bool:
|
|
733
|
+
"""Tell whether the row of a recent carries the Unavailable mark: it failed to open, or its folder was found missing.
|
|
734
|
+
|
|
735
|
+
A folder whose answer has not come back yet is never marked Unavailable: a slow answer is never taken for a missing folder.
|
|
736
|
+
"""
|
|
737
|
+
return self.recent_mark(recent_dir) is RecentMark.UNAVAILABLE
|
|
738
|
+
|
|
739
|
+
def _show_checking(self, batch: frozenset[str]) -> None:
|
|
740
|
+
"""Show "checking" on the folders of a batch that still have no answer once the display delay has passed.
|
|
741
|
+
|
|
742
|
+
Nothing changes once the check is closed: its folders will never answer this window.
|
|
743
|
+
"""
|
|
744
|
+
if self.availability_check.closed:
|
|
745
|
+
return
|
|
746
|
+
late_dir_set: set[str] = set()
|
|
747
|
+
for recent_dir in batch:
|
|
748
|
+
if recent_dir in self.pending_dir_set and recent_dir not in self.checking_dir_set:
|
|
749
|
+
late_dir_set.add(recent_dir)
|
|
750
|
+
if not late_dir_set:
|
|
751
|
+
return
|
|
752
|
+
fitted_before = self.fitted_height() if self.centred else 0
|
|
753
|
+
self.checking_dir_set.update(late_dir_set)
|
|
754
|
+
self._refresh_recent_rows(late_dir_set)
|
|
755
|
+
self._follow_fitted_height(fitted_before)
|
|
756
|
+
|
|
757
|
+
def _show_unknown_after_wait(self, batch: frozenset[str]) -> None:
|
|
758
|
+
"""Stop waiting for the folders of a batch still without answer after the long wait: show them as Unknown and say so in --log.
|
|
759
|
+
|
|
760
|
+
A question blocked on a dead network mount cannot be cancelled, so it goes on: its real answer, present or
|
|
761
|
+
absent, still replaces Unknown whenever it comes. Nothing changes once the check is closed.
|
|
762
|
+
"""
|
|
763
|
+
if self.availability_check.closed:
|
|
764
|
+
return
|
|
765
|
+
waited_dir_set: set[str] = set()
|
|
766
|
+
for recent_dir in batch:
|
|
767
|
+
if recent_dir in self.pending_dir_set:
|
|
768
|
+
waited_dir_set.add(recent_dir)
|
|
769
|
+
if not waited_dir_set:
|
|
770
|
+
return
|
|
771
|
+
fitted_before = self.fitted_height() if self.centred else 0
|
|
772
|
+
self.pending_dir_set.difference_update(waited_dir_set)
|
|
773
|
+
self.checking_dir_set.difference_update(waited_dir_set)
|
|
774
|
+
self.unknown_dir_set.update(waited_dir_set)
|
|
775
|
+
self._refresh_recent_rows(waited_dir_set)
|
|
776
|
+
self._follow_fitted_height(fitted_before)
|
|
777
|
+
waited_seconds = RECENT_CHECK_UNKNOWN_DELAY_MS / MILLISECONDS_PER_SECOND
|
|
778
|
+
for recent_dir in sorted(waited_dir_set):
|
|
779
|
+
self._write_availability_log_line(unanswered_after_wait_log_line(recent_dir, waited_seconds))
|
|
780
|
+
|
|
781
|
+
def _show_unanswered(self, recent_dir: str) -> None:
|
|
782
|
+
"""Show a recent as Unknown when its folder question failed without saying whether the folder exists.
|
|
783
|
+
|
|
784
|
+
Access refused, a network timeout or a drive that is not ready say nothing about the folder: it is not marked
|
|
785
|
+
Unavailable and stays openable. A real answer already known, or a failed opening, keeps its mark.
|
|
786
|
+
"""
|
|
787
|
+
if recent_dir in self.reachable_by_dir or recent_dir in self.unavailable_dir_set or recent_dir in self.unknown_dir_set:
|
|
788
|
+
return
|
|
789
|
+
fitted_before = self.fitted_height() if self.centred else 0
|
|
790
|
+
self.pending_dir_set.discard(recent_dir)
|
|
791
|
+
self.checking_dir_set.discard(recent_dir)
|
|
792
|
+
self.unknown_dir_set.add(recent_dir)
|
|
793
|
+
self._refresh_recent_rows({recent_dir})
|
|
794
|
+
self._follow_fitted_height(fitted_before)
|
|
795
|
+
|
|
796
|
+
def _advance_checking_spinner(self) -> None:
|
|
797
|
+
"""Turn the arcs one step and repaint the listed "checking" rows alone, not the whole list."""
|
|
798
|
+
delegate = self.recent_list.delegate
|
|
799
|
+
delegate.busy_angle = next_busy_angle(delegate.busy_angle)
|
|
800
|
+
model = self.recent_list.model()
|
|
801
|
+
for recent_dir in self.checking_dir_set:
|
|
802
|
+
row = self.row_by_recent_dir.get(recent_dir)
|
|
803
|
+
if row is not None:
|
|
804
|
+
self.recent_list.update(model.index(row, RECENT_COLUMN))
|
|
805
|
+
|
|
806
|
+
def _follow_checking_spinner(self) -> None:
|
|
807
|
+
"""Run the spinner timer only while a row shows "checking" and the form is visible, so it costs nothing otherwise."""
|
|
808
|
+
if self.checking_dir_set and self.isVisible():
|
|
809
|
+
if not self.checking_spinner_timer.isActive():
|
|
810
|
+
self.checking_spinner_timer.start()
|
|
811
|
+
return
|
|
812
|
+
self.checking_spinner_timer.stop()
|
|
813
|
+
|
|
814
|
+
def _show_availability(self, recent_dir: str, reachable: bool) -> None:
|
|
815
|
+
"""Remember whether a recent folder can be reached and update its row in place, only when its mark changes.
|
|
816
|
+
|
|
817
|
+
A real answer ends the wait, even one that comes after the folder was shown as Unknown: a late answer always
|
|
818
|
+
wins. The row is changed where it stands, so the search, the chosen row and the scroll position stay as the
|
|
819
|
+
user left them; a recent the search hides right now gets its mark from the remembered answer once it is listed
|
|
820
|
+
again. The answers always come back after the first fit of the form's height, even for a local folder answered
|
|
821
|
+
at once, since they cross threads through the event loop: a shown form changes its height by as much as the row
|
|
822
|
+
did, so the list does not start scrolling while every recent still fits. That holds for a row the search hides
|
|
823
|
+
too: the fitted height counts every recent of the history, so the room is there once the search is emptied.
|
|
824
|
+
"""
|
|
825
|
+
mark_changes = self._mark_with_answer(recent_dir, reachable) is not self.recent_mark(recent_dir)
|
|
826
|
+
fitted_before = self.fitted_height() if mark_changes and self.centred else 0
|
|
827
|
+
self.pending_dir_set.discard(recent_dir)
|
|
828
|
+
self.checking_dir_set.discard(recent_dir)
|
|
829
|
+
self.unknown_dir_set.discard(recent_dir)
|
|
830
|
+
self.reachable_by_dir[recent_dir] = reachable
|
|
831
|
+
if mark_changes:
|
|
832
|
+
self._refresh_recent_rows({recent_dir})
|
|
833
|
+
self._follow_fitted_height(fitted_before)
|
|
834
|
+
|
|
835
|
+
def _write_availability_log_line(self, line: str) -> None:
|
|
836
|
+
"""Write why a folder check got no answer into the --log file, filtered like every other record of that file."""
|
|
837
|
+
if self.diagnostic_log is None:
|
|
838
|
+
return
|
|
839
|
+
self.diagnostic_log.write_line(SensitiveTextFilter().redact(line))
|
|
840
|
+
|
|
841
|
+
def _refresh_recent_rows(self, changed_dir_set: set[str] | frozenset[str]) -> None:
|
|
842
|
+
"""Rewrite the listed rows of recents whose mark changed, in one pass, and let the list measure its rows again.
|
|
843
|
+
|
|
844
|
+
A whole batch goes through one pass, and its caller through one height measure: a hundred recents of a dead
|
|
845
|
+
mount changing their mark at once must not walk the list and measure the form a hundred times. A recent the
|
|
846
|
+
search hides has no row to rewrite; the fitted height counts it with its new mark all the same. Every change of
|
|
847
|
+
the waiting folders comes through here, so the spinner timer follows them here too.
|
|
848
|
+
"""
|
|
849
|
+
for recent_dir in changed_dir_set:
|
|
850
|
+
row = self.row_by_recent_dir.get(recent_dir)
|
|
851
|
+
if row is None:
|
|
852
|
+
continue
|
|
853
|
+
mark = self.recent_mark(recent_dir)
|
|
854
|
+
item = self.recent_list.item(row)
|
|
855
|
+
item.setText(recent_item_text(recent_dir, mark))
|
|
856
|
+
item.setData(RECENT_MARK_ROLE, mark.value)
|
|
857
|
+
# A mark line makes a row taller: the list lays its rows out again by itself after the data change, and the form
|
|
858
|
+
# learns the list's new height.
|
|
859
|
+
self.recent_list.updateGeometry()
|
|
860
|
+
self._follow_checking_spinner()
|
|
861
|
+
|
|
862
|
+
def _search_changed(self, _text: str) -> None:
|
|
863
|
+
"""List what the new search keeps and let go of the row chosen before it, so Enter in Search opens the best match.
|
|
864
|
+
|
|
865
|
+
A row chosen before the search changed, by a click or by Qt picking the first row when the list took the focus,
|
|
866
|
+
must not win over the match the user now sees at the top; a row is chosen again only in the list itself.
|
|
867
|
+
"""
|
|
868
|
+
self._apply_recent_filter(keep_current=False)
|
|
869
|
+
|
|
870
|
+
def _apply_recent_filter(self, *, keep_current: bool) -> None:
|
|
871
|
+
"""Show the recents the search keeps, best first, from what show_recent remembered, without looking at the disk.
|
|
872
|
+
|
|
873
|
+
With ``keep_current``, the row chosen in the list stays chosen while it is still listed: a new history or an
|
|
874
|
+
Unavailable mark does not undo the user's choice. When no row is left, a grey text takes the list's place in the
|
|
875
|
+
same border: the usual help without any recent, or a no-match line when the search hides them all. The window is
|
|
876
|
+
never resized here: the list scrolls or leaves room, as for any other number of recents.
|
|
877
|
+
"""
|
|
878
|
+
listed_dirs = ranked_recent_dirs(self.search_edit.text(), self.recent_dirs)
|
|
879
|
+
current_item = self.recent_list.currentItem() if keep_current else None
|
|
880
|
+
current_dir = None if current_item is None else current_item.data(Qt.ItemDataRole.UserRole)
|
|
881
|
+
self.recent_list.clear()
|
|
882
|
+
self.row_by_recent_dir = {}
|
|
883
|
+
for recent_dir in listed_dirs:
|
|
884
|
+
mark = self.recent_mark(recent_dir)
|
|
885
|
+
item = QListWidgetItem(recent_item_text(recent_dir, mark))
|
|
886
|
+
item.setData(Qt.ItemDataRole.UserRole, recent_dir)
|
|
887
|
+
item.setData(RECENT_MARK_ROLE, mark.value)
|
|
888
|
+
self.row_by_recent_dir[recent_dir] = self.recent_list.count()
|
|
889
|
+
self.recent_list.addItem(item)
|
|
890
|
+
if recent_dir == current_dir:
|
|
891
|
+
# Only the current row moves: itemClicked and itemActivated come from the user alone, so nothing opens.
|
|
892
|
+
self.recent_list.setCurrentItem(item)
|
|
893
|
+
if self.recent_dirs:
|
|
894
|
+
self.empty_label.setText(WelcomeText.RECENT_NO_MATCH.value)
|
|
895
|
+
else:
|
|
896
|
+
self.empty_label.setText(WelcomeText.RECENT_EMPTY.value + "\n" + WelcomeText.RECENT_HELP.value)
|
|
897
|
+
self.empty_label.setVisible(not listed_dirs)
|
|
898
|
+
self.recent_list.setVisible(bool(listed_dirs))
|
|
899
|
+
self.recent_list.updateGeometry()
|
|
900
|
+
|
|
901
|
+
def placement_screen(self) -> QScreen:
|
|
902
|
+
"""Return the screen the form opens on: the one of the repository window it was opened from, else its own.
|
|
903
|
+
|
|
904
|
+
Before its first show the form's own screen is the primary one, which may be taller than the screen it is centred on.
|
|
905
|
+
"""
|
|
906
|
+
if is_visible_window(self.placement_anchor):
|
|
907
|
+
return self.placement_anchor.screen()
|
|
908
|
+
return self.screen()
|
|
909
|
+
|
|
910
|
+
def recent_height_cap(self) -> int:
|
|
911
|
+
"""Return the tallest the form grows to show its recent repositories: a share of the usable height of the screen it opens on."""
|
|
912
|
+
return round(WELCOME_MAX_SCREEN_RATIO * self.placement_screen().availableGeometry().height())
|
|
913
|
+
|
|
914
|
+
def fitted_height(self) -> int:
|
|
915
|
+
"""Return the height that shows every recent repository without scrolling, within the minimum height and the height cap.
|
|
916
|
+
|
|
917
|
+
Measured at the current width, since the recent paths and the status line wrap. The button band sits outside the
|
|
918
|
+
scrolling form, so its height is added to the form's. Every recent of the history counts, with its known mark,
|
|
919
|
+
including those the search hides right now.
|
|
920
|
+
"""
|
|
921
|
+
content_layout = self.scroll_area.widget().layout()
|
|
922
|
+
scroll_frame_height = self.scroll_area.frameWidth() * 2
|
|
923
|
+
content_width = self.scroll_area.width() - scroll_frame_height
|
|
924
|
+
if content_layout.hasHeightForWidth():
|
|
925
|
+
content_height = content_layout.totalHeightForWidth(content_width)
|
|
926
|
+
else:
|
|
927
|
+
content_height = content_layout.totalSizeHint().height()
|
|
928
|
+
content_height += self._unlisted_recent_height(content_layout, content_width)
|
|
929
|
+
outer_margins = self.layout().contentsMargins()
|
|
930
|
+
wanted_height = content_height + scroll_frame_height + outer_margins.top() + outer_margins.bottom() + self.button_band.sizeHint().height()
|
|
931
|
+
return min(max(wanted_height, WELCOME_MIN_HEIGHT), self.recent_height_cap())
|
|
932
|
+
|
|
933
|
+
def _unlisted_recent_height(self, content_layout: QLayout, content_width: int) -> int:
|
|
934
|
+
"""Return the height the form's laid-out content lacks to hold every recent of the history, the ones the search hides included.
|
|
935
|
+
|
|
936
|
+
The window keeps its height while the user types in Search, so the room for the whole history has to be known
|
|
937
|
+
all along: a mark that arrives on a hidden row then grows the window at once, and every row fits without
|
|
938
|
+
scrolling once the search is emptied. Without a search every recent is listed and nothing is missing.
|
|
939
|
+
"""
|
|
940
|
+
if self.recent_list.count() == len(self.recent_dirs):
|
|
941
|
+
return 0
|
|
942
|
+
history_height = self.recent_list.frameWidth() * 2
|
|
943
|
+
row_width = self.recent_list.viewport().width()
|
|
944
|
+
for recent_dir in self.recent_dirs:
|
|
945
|
+
history_height += self.recent_list.recent_height_at_width(recent_dir, self.recent_mark(recent_dir), row_width)
|
|
946
|
+
if not self.recent_list.isHidden():
|
|
947
|
+
return history_height - self.recent_list.sizeHint().height()
|
|
948
|
+
# The search hides every recent: the no-match text holds the list's place in the layout, at the layout's inner width.
|
|
949
|
+
label_item = content_layout.itemAt(content_layout.indexOf(self.empty_label))
|
|
950
|
+
layout_margins = content_layout.contentsMargins()
|
|
951
|
+
label_width = content_width - layout_margins.left() - layout_margins.right()
|
|
952
|
+
if label_item.hasHeightForWidth():
|
|
953
|
+
return history_height - label_item.heightForWidth(label_width)
|
|
954
|
+
return history_height - label_item.sizeHint().height()
|
|
955
|
+
|
|
956
|
+
def fit_recent_height(self) -> None:
|
|
957
|
+
"""Make the form tall enough to show every recent repository without scrolling, up to the height cap.
|
|
958
|
+
|
|
959
|
+
Runs at the first show, once the children have their real widths: the recent paths wrap, so their height depends
|
|
960
|
+
on the width. The window never goes below its minimum height; past the cap, the layout shrinks only the recent
|
|
961
|
+
list, which then scrolls while every other control stays visible.
|
|
962
|
+
"""
|
|
963
|
+
self.resize(self.width(), self.fitted_height())
|
|
964
|
+
|
|
965
|
+
def mark_unavailable(self, repository_dir: str) -> None:
|
|
966
|
+
"""Keep a recent that failed to open visible with its Unavailable marker, without reading the disk again for the others.
|
|
967
|
+
|
|
968
|
+
The mark goes into the remembered history, not into the shown rows, so a recent the search hides right now is
|
|
969
|
+
still marked once the search is emptied; a history received later keeps it marked too, whatever the background
|
|
970
|
+
check of its folder answers, so the folder stops waiting for one. The form keeps its height: the list shows the
|
|
971
|
+
taller row or scrolls.
|
|
972
|
+
"""
|
|
973
|
+
self.unavailable_dir_set.add(repository_dir)
|
|
974
|
+
self.pending_dir_set.discard(repository_dir)
|
|
975
|
+
self.checking_dir_set.discard(repository_dir)
|
|
976
|
+
self.unknown_dir_set.discard(repository_dir)
|
|
977
|
+
self._refresh_recent_rows({repository_dir})
|
|
978
|
+
|
|
979
|
+
def set_loading(self, loading: bool) -> None:
|
|
980
|
+
"""Keep the window responsive while preventing a duplicate opening request.
|
|
981
|
+
|
|
982
|
+
While a repository is read, Cancel reading takes Open's place in the band and the status line says so. Open is
|
|
983
|
+
hidden and disabled: the path field and the recent list refuse to open while it is disabled. The clone button of
|
|
984
|
+
the path row is disabled too, since a clone cannot start while a repository is read. The keyboard focus follows
|
|
985
|
+
the swap, so a user who pressed Open lands on Cancel reading, and back on Open once the reading ends.
|
|
986
|
+
"""
|
|
987
|
+
focus_widget = self.focusWidget()
|
|
988
|
+
self.cancel_button.setVisible(loading)
|
|
989
|
+
self.open_button.setVisible(not loading)
|
|
990
|
+
self.open_button.setEnabled(not loading)
|
|
991
|
+
self.clone_button.setEnabled(not loading)
|
|
992
|
+
self.show_status(WelcomeText.OPENING.value if loading else "")
|
|
993
|
+
if loading and focus_widget is self.open_button:
|
|
994
|
+
self.cancel_button.setFocus()
|
|
995
|
+
return
|
|
996
|
+
if not loading and focus_widget is self.cancel_button:
|
|
997
|
+
self.open_button.setFocus()
|
|
998
|
+
|
|
999
|
+
def show_status(self, text: str) -> None:
|
|
1000
|
+
"""Write the line under the repository path, and show it only while it has something to say.
|
|
1001
|
+
|
|
1002
|
+
An empty text hides the line, so no blank space sits between the path and the recent repositories. A shown form
|
|
1003
|
+
changes its height by as much as the line changes its fitted height, so the recent list keeps its room and does
|
|
1004
|
+
not start scrolling while every recent still fits; a height the user set by hand keeps its difference.
|
|
1005
|
+
"""
|
|
1006
|
+
fitted_before = self.fitted_height() if self.centred else 0
|
|
1007
|
+
self.status_label.setText(text)
|
|
1008
|
+
self.status_label.setVisible(bool(text))
|
|
1009
|
+
self._follow_fitted_height(fitted_before)
|
|
1010
|
+
|
|
1011
|
+
def _follow_fitted_height(self, fitted_before: int) -> None:
|
|
1012
|
+
"""Change a shown form's height by as much as its fitted height changed since ``fitted_before``.
|
|
1013
|
+
|
|
1014
|
+
The one rule for content that grows or shrinks after the first fit (the status line, the mark of a folder check):
|
|
1015
|
+
the recent list keeps its room, a height the user set by hand keeps its difference, and a form at its height cap
|
|
1016
|
+
does not move, its list scrolling instead. Before the first show there is nothing to follow: the first fit
|
|
1017
|
+
measures everything.
|
|
1018
|
+
"""
|
|
1019
|
+
if not self.centred:
|
|
1020
|
+
return
|
|
1021
|
+
self.scroll_area.widget().layout().invalidate()
|
|
1022
|
+
difference = self.fitted_height() - fitted_before
|
|
1023
|
+
if difference:
|
|
1024
|
+
self.resize(self.width(), self.height() + difference)
|
|
1025
|
+
|
|
1026
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
1027
|
+
"""Size and centre the form on its first show: on the repository window it was opened from, else on the usable screen.
|
|
1028
|
+
|
|
1029
|
+
The form is itself a placement anchor, so the application-wide placement of new windows skips it and it has to
|
|
1030
|
+
place itself. Later shows keep the form's size and place, including a place the user dragged it to. The arcs of
|
|
1031
|
+
the rows still "checking" turn again.
|
|
1032
|
+
"""
|
|
1033
|
+
super().showEvent(event)
|
|
1034
|
+
self._follow_checking_spinner()
|
|
1035
|
+
if self.centred:
|
|
1036
|
+
return
|
|
1037
|
+
self.centred = True
|
|
1038
|
+
self.fit_recent_height()
|
|
1039
|
+
if centre_on_placement_anchor(self, self.placement_anchor):
|
|
1040
|
+
return
|
|
1041
|
+
screen = self.placement_screen()
|
|
1042
|
+
center_window_at(self, screen.availableGeometry().center(), screen)
|
|
1043
|
+
|
|
1044
|
+
def closeEvent(self, event: QCloseEvent) -> None:
|
|
1045
|
+
"""Stop listening to the folder checks still running, then notify the owner before discarding a pending opening.
|
|
1046
|
+
|
|
1047
|
+
A question to a sleeping disk cannot be cancelled; closing the check makes sure its late answer is dropped
|
|
1048
|
+
instead of reaching a window Qt may already have deleted; the pending "checking" and Unknown timers then change
|
|
1049
|
+
nothing. The arcs of the "checking" rows stop turning.
|
|
1050
|
+
"""
|
|
1051
|
+
self.availability_check.close()
|
|
1052
|
+
self.checking_spinner_timer.stop()
|
|
1053
|
+
self.closed.emit()
|
|
1054
|
+
super().closeEvent(event)
|
|
1055
|
+
|
|
1056
|
+
def hideEvent(self, event: QHideEvent) -> None:
|
|
1057
|
+
"""Stop turning the arcs of the "checking" rows while the form is hidden: nobody sees them."""
|
|
1058
|
+
super().hideEvent(event)
|
|
1059
|
+
self._follow_checking_spinner()
|
|
1060
|
+
|
|
1061
|
+
def _browse(self) -> None:
|
|
1062
|
+
"""Start the native chooser at the typed path, read as Open reads it, and keep the current text when the chooser is cancelled.
|
|
1063
|
+
|
|
1064
|
+
GitPane does not look at that folder itself: the chooser starts there, or where it can when the folder is missing.
|
|
1065
|
+
"""
|
|
1066
|
+
repository_dir = QFileDialog.getExistingDirectory(self, WelcomeText.OPEN_REPOSITORY.value, typed_path_value(self.path_edit.text(), self.typed_path))
|
|
1067
|
+
if repository_dir:
|
|
1068
|
+
self.path_edit.setText(repository_dir)
|
|
1069
|
+
self.path_edit.setFocus()
|
|
1070
|
+
|
|
1071
|
+
def _request_open(self) -> None:
|
|
1072
|
+
"""Validate the empty form locally before requesting asynchronous opening.
|
|
1073
|
+
|
|
1074
|
+
The path is used without the blanks around it, so a path pasted with a space opens its repository, and with
|
|
1075
|
+
``~`` expanded to the home folder where that is the usage; the field keeps the text as typed.
|
|
1076
|
+
"""
|
|
1077
|
+
if not self.open_button.isEnabled():
|
|
1078
|
+
return
|
|
1079
|
+
if not typed_value(self.path_edit.text()):
|
|
1080
|
+
self.show_status(WelcomeText.EMPTY_PATH.value)
|
|
1081
|
+
self.path_edit.setFocus()
|
|
1082
|
+
return
|
|
1083
|
+
self.open_requested.emit(typed_path_value(self.path_edit.text(), self.typed_path))
|
|
1084
|
+
|
|
1085
|
+
def invalid_path_message(self, repository_dir: str) -> QMessageBox:
|
|
1086
|
+
"""Build, without opening it, the message saying that no folder exists at ``repository_dir``.
|
|
1087
|
+
|
|
1088
|
+
The path is plain text, since a path may hold a ``<`` or a ``&`` that rich text would read as markup, and it can
|
|
1089
|
+
be selected, so the user can copy it. The caller keeps and opens the message.
|
|
1090
|
+
"""
|
|
1091
|
+
box = QMessageBox(QMessageBox.Icon.Warning, WelcomeText.INVALID_PATH_TITLE.value, WelcomeText.INVALID_PATH.value.format(path=repository_dir), QMessageBox.StandardButton.Ok, self)
|
|
1092
|
+
box.setTextFormat(Qt.TextFormat.PlainText)
|
|
1093
|
+
box.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
|
|
1094
|
+
prepare_message_box(box, WelcomeText.INVALID_PATH_TITLE.value)
|
|
1095
|
+
return box
|
|
1096
|
+
|
|
1097
|
+
def eventFilter(self, watched: QWidget, event: QEvent) -> bool:
|
|
1098
|
+
"""Open the current recent on Enter in the recent list, and give the Search field the two keys it leaves unused.
|
|
1099
|
+
|
|
1100
|
+
Qt for macOS does not activate a list row on Enter, so the list's Enter goes through the shared helper that does
|
|
1101
|
+
it on every system. In Search, Down moves to the recent list and Escape empties the search.
|
|
1102
|
+
"""
|
|
1103
|
+
if watched is self.recent_list and activate_current_row(self.recent_list, event):
|
|
1104
|
+
return True
|
|
1105
|
+
if watched is not self.search_edit or event.type() != QEvent.Type.KeyPress:
|
|
1106
|
+
return super().eventFilter(watched, event)
|
|
1107
|
+
if event.key() == Qt.Key.Key_Down and self.recent_list.count():
|
|
1108
|
+
self.recent_list.setFocus()
|
|
1109
|
+
if self.recent_list.currentRow() < 0:
|
|
1110
|
+
self.recent_list.setCurrentRow(FIRST_ROW)
|
|
1111
|
+
return True
|
|
1112
|
+
if event.key() == Qt.Key.Key_Escape and self.search_edit.text():
|
|
1113
|
+
self.search_edit.clear()
|
|
1114
|
+
return True
|
|
1115
|
+
return super().eventFilter(watched, event)
|
|
1116
|
+
|
|
1117
|
+
def _open_search_result(self) -> None:
|
|
1118
|
+
"""Open the recent chosen in the list since the search last changed on Enter in Search, else the best match, as a launcher does.
|
|
1119
|
+
|
|
1120
|
+
Every change of the search lets go of the chosen row, so a current row here was picked after the last key typed.
|
|
1121
|
+
"""
|
|
1122
|
+
item = self.recent_list.currentItem()
|
|
1123
|
+
if item is None:
|
|
1124
|
+
item = self.recent_list.item(FIRST_ROW)
|
|
1125
|
+
if item is None:
|
|
1126
|
+
return
|
|
1127
|
+
self._open_recent(item)
|
|
1128
|
+
|
|
1129
|
+
def _open_recent(self, item: QListWidgetItem) -> None:
|
|
1130
|
+
"""Activate the exact saved directory without substituting another location."""
|
|
1131
|
+
if not self.open_button.isEnabled():
|
|
1132
|
+
return
|
|
1133
|
+
self._select_recent(item)
|
|
1134
|
+
self._request_open()
|
|
1135
|
+
|
|
1136
|
+
def _select_recent(self, item: QListWidgetItem) -> None:
|
|
1137
|
+
self.path_edit.setText(item.data(Qt.ItemDataRole.UserRole))
|