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,127 @@
|
|
|
1
|
+
"""Edit plain text with its line numbers in a margin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent, Qt
|
|
8
|
+
from PySide6.QtGui import QPainter, QPalette
|
|
9
|
+
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
|
10
|
+
|
|
11
|
+
from gitpane.config import LAYOUT_SPACING, LINE_SEPARATOR, QT_PARAGRAPH_SEPARATOR, SPACING_XS
|
|
12
|
+
from gitpane.ui.look.theme_color import line_number_gutter_color
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from PySide6.QtCore import QRect
|
|
16
|
+
from PySide6.QtGui import QPaintEvent, QResizeEvent
|
|
17
|
+
|
|
18
|
+
# Space on each side of the numbers, so the widest one touches neither the window edge nor the text; the layout spacing
|
|
19
|
+
# keeps it in step with the space between the other controls of a window.
|
|
20
|
+
LINE_NUMBER_MARGIN_PADDING = LAYOUT_SPACING
|
|
21
|
+
# Space in the background of the text between the gutter and the first character, on top of Qt's own document margin,
|
|
22
|
+
# so the numbers do not look stuck to the text.
|
|
23
|
+
LINE_NUMBER_TEXT_GAP = SPACING_XS
|
|
24
|
+
# Qt counts blocks from zero, an editor numbers lines from one.
|
|
25
|
+
LINE_NUMBER_OF_FIRST_BLOCK = 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LineNumberMargin(QWidget):
|
|
29
|
+
"""Paint the number of each line of its editor beside the text, outside what the user selects or edits."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, editor: NumberedTextEdit) -> None:
|
|
32
|
+
"""Sit over the left viewport margin of the editor, which places it and tells it when to repaint."""
|
|
33
|
+
super().__init__(editor)
|
|
34
|
+
self.editor = editor
|
|
35
|
+
|
|
36
|
+
def visible_numbers(self) -> list[tuple[int, int]]:
|
|
37
|
+
"""Answer the number and the top of each visible line, top to bottom, as the margin paints them.
|
|
38
|
+
|
|
39
|
+
One block is one line of the file, and its number sits on its first visual line when a message wraps.
|
|
40
|
+
"""
|
|
41
|
+
numbers: list[tuple[int, int]] = []
|
|
42
|
+
bottom = self.editor.viewport().height()
|
|
43
|
+
block = self.editor.firstVisibleBlock()
|
|
44
|
+
while block.isValid():
|
|
45
|
+
top = round(self.editor.blockBoundingGeometry(block).translated(self.editor.contentOffset()).top())
|
|
46
|
+
if top > bottom:
|
|
47
|
+
break
|
|
48
|
+
if block.isVisible():
|
|
49
|
+
numbers.append((block.blockNumber() + LINE_NUMBER_OF_FIRST_BLOCK, top))
|
|
50
|
+
block = block.next()
|
|
51
|
+
return numbers
|
|
52
|
+
|
|
53
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
54
|
+
"""Paint the visible numbers right-aligned on the gutter colour, in the font of the text, then the gap before the text in the text's background colour."""
|
|
55
|
+
painter = QPainter(self)
|
|
56
|
+
gutter_width = self.editor.margin_width()
|
|
57
|
+
viewport = self.editor.viewport()
|
|
58
|
+
painter.fillRect(0, event.rect().top(), gutter_width, event.rect().height(), line_number_gutter_color(self.palette()))
|
|
59
|
+
painter.fillRect(gutter_width, event.rect().top(), self.width() - gutter_width, event.rect().height(), viewport.palette().color(viewport.backgroundRole()))
|
|
60
|
+
painter.setFont(self.editor.font())
|
|
61
|
+
painter.setPen(self.palette().color(QPalette.ColorRole.Text))
|
|
62
|
+
height = self.editor.fontMetrics().height()
|
|
63
|
+
for number, top in self.visible_numbers():
|
|
64
|
+
if top > event.rect().bottom():
|
|
65
|
+
break
|
|
66
|
+
painter.drawText(0, top, gutter_width - LINE_NUMBER_MARGIN_PADDING, height, Qt.AlignmentFlag.AlignRight, str(number))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NumberedTextEdit(QPlainTextEdit):
|
|
70
|
+
"""Edit plain text without any colouring, its lines numbered in a margin, and give the text back as it was typed.
|
|
71
|
+
|
|
72
|
+
The caller chooses how lines wrap: the widget numbers blocks, so a wrapped line keeps a single number.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
76
|
+
"""Attach the margin and keep it sized to the line count and in step with scrolling."""
|
|
77
|
+
super().__init__(parent)
|
|
78
|
+
self.number_margin = LineNumberMargin(self)
|
|
79
|
+
self.blockCountChanged.connect(self.refresh_margin)
|
|
80
|
+
self.updateRequest.connect(self.follow_update_request)
|
|
81
|
+
self.refresh_margin()
|
|
82
|
+
|
|
83
|
+
def raw_text(self) -> str:
|
|
84
|
+
"""Answer the text with a line feed between lines and nothing else changed.
|
|
85
|
+
|
|
86
|
+
The plain text conversion of Qt turns no-break spaces into spaces and line separators into line feeds; the raw
|
|
87
|
+
text keeps them, and only Qt's own paragraph separator stands for a line feed.
|
|
88
|
+
"""
|
|
89
|
+
return self.document().toRawText().replace(QT_PARAGRAPH_SEPARATOR, LINE_SEPARATOR)
|
|
90
|
+
|
|
91
|
+
def margin_width(self) -> int:
|
|
92
|
+
"""Answer the width of the gutter: the widest line number of the current font, with its padding on both sides.
|
|
93
|
+
|
|
94
|
+
The margin beside the text is wider by the gap that keeps the numbers off the first character.
|
|
95
|
+
"""
|
|
96
|
+
widest_number = str(max(LINE_NUMBER_OF_FIRST_BLOCK, self.blockCount()))
|
|
97
|
+
return self.fontMetrics().horizontalAdvance(widest_number) + 2 * LINE_NUMBER_MARGIN_PADDING
|
|
98
|
+
|
|
99
|
+
def refresh_margin(self) -> None:
|
|
100
|
+
"""Make room for the gutter and the gap beside the text, then place the margin there."""
|
|
101
|
+
width = self.margin_width() + LINE_NUMBER_TEXT_GAP
|
|
102
|
+
if self.viewportMargins().left() != width:
|
|
103
|
+
self.setViewportMargins(width, 0, 0, 0)
|
|
104
|
+
self.place_margin()
|
|
105
|
+
|
|
106
|
+
def place_margin(self) -> None:
|
|
107
|
+
"""Put the margin over the left viewport margin, as high as the text area."""
|
|
108
|
+
contents = self.contentsRect()
|
|
109
|
+
self.number_margin.setGeometry(contents.left(), contents.top(), self.viewportMargins().left(), contents.height())
|
|
110
|
+
|
|
111
|
+
def follow_update_request(self, rectangle: QRect, scrolled_by: int) -> None:
|
|
112
|
+
"""Scroll the numbers with the text, or repaint the part of the margin beside the changed text."""
|
|
113
|
+
if scrolled_by:
|
|
114
|
+
self.number_margin.scroll(0, scrolled_by)
|
|
115
|
+
return
|
|
116
|
+
self.number_margin.update(0, rectangle.y(), self.number_margin.width(), rectangle.height())
|
|
117
|
+
|
|
118
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
119
|
+
"""Keep the margin as high as the text area."""
|
|
120
|
+
super().resizeEvent(event)
|
|
121
|
+
self.place_margin()
|
|
122
|
+
|
|
123
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
124
|
+
"""Resize the margin when the font changes, since the numbers take the font of the text."""
|
|
125
|
+
super().changeEvent(event)
|
|
126
|
+
if event.type() == QEvent.Type.FontChange:
|
|
127
|
+
self.refresh_margin()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Show untrusted text as plain, selectable, wrapped text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt
|
|
6
|
+
from PySide6.QtWidgets import QLabel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def plain_label(text: str) -> QLabel:
|
|
10
|
+
"""Show untrusted text such as an address or a path as plain, selectable, wrapped text."""
|
|
11
|
+
label = QLabel(text)
|
|
12
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
13
|
+
label.setWordWrap(True)
|
|
14
|
+
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
|
|
15
|
+
return label
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Qt tooltip shown as plain text, whatever the paths or names it carries contain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
|
|
7
|
+
# Qt shows a tooltip as rich text when it looks like markup; lines are wrapped in a paragraph that keeps the spacing
|
|
8
|
+
# of the text while still wrapping a long line, and joined with explicit line breaks.
|
|
9
|
+
TOOLTIP_PLAIN_TEXT_START = "<p style='white-space:pre-wrap'>"
|
|
10
|
+
TOOLTIP_PLAIN_TEXT_END = "</p>"
|
|
11
|
+
TOOLTIP_LINE_BREAK = "<br>"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plain_text_tooltip(lines: list[str]) -> str:
|
|
15
|
+
"""Build a tooltip that Qt shows as the given lines of plain text, whatever markup they contain."""
|
|
16
|
+
escaped_lines = []
|
|
17
|
+
for line in lines:
|
|
18
|
+
escaped_lines.append(html.escape(line))
|
|
19
|
+
return TOOLTIP_PLAIN_TEXT_START + TOOLTIP_LINE_BREAK.join(escaped_lines) + TOOLTIP_PLAIN_TEXT_END
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Write the advice that sends the user to the Refresh action of the repository window, with the shortcut of the system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PySide6.QtGui import QKeySequence
|
|
8
|
+
|
|
9
|
+
from gitpane.config import SharedText
|
|
10
|
+
from gitpane.ui.widget.shortcut_text import shortcut_text
|
|
11
|
+
from gitpane.ui.widget.system_shortcut import refresh_shortcuts
|
|
12
|
+
|
|
13
|
+
# A menu shows only the primary shortcut of an action, so the advice names that one too.
|
|
14
|
+
PRIMARY_SHORTCUT_INDEX = 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def refresh_advice_text(advice: SharedText, platform: str = sys.platform, **values: str) -> str:
|
|
18
|
+
"""Return the text of ``advice`` with the name of the Refresh action and its shortcut on ``platform`` filled in.
|
|
19
|
+
|
|
20
|
+
The label and the key are read from the action's own label and from the shortcut table rather than written in
|
|
21
|
+
the text, so a renamed action or a changed shortcut changes every advice at once and the advice can never cite a
|
|
22
|
+
key that does nothing. ``values`` fill the other fields of the text, such as a branch name.
|
|
23
|
+
"""
|
|
24
|
+
shortcut = shortcut_text(QKeySequence(refresh_shortcuts(platform)[PRIMARY_SHORTCUT_INDEX]), platform)
|
|
25
|
+
return advice.value.format(refresh=SharedText.REFRESH.value, shortcut=shortcut, **values)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Show the result of an action in the colour of its tone: bold green success, bold red failure, bold orange attention, ordinary text otherwise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import weakref
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QEvent, Qt, QTimer
|
|
10
|
+
from PySide6.QtGui import QColor, QPalette
|
|
11
|
+
from PySide6.QtWidgets import QApplication
|
|
12
|
+
from shiboken6 import isValid
|
|
13
|
+
|
|
14
|
+
from gitpane.config import ERROR_DARK_COLOR, ERROR_LIGHT_COLOR, SUCCESS_DARK_COLOR, SUCCESS_LIGHT_COLOR, WARNING_DARK_COLOR, WARNING_LIGHT_COLOR
|
|
15
|
+
from gitpane.ui.look.theme_color import is_dark_palette
|
|
16
|
+
from gitpane.ui.look.theme_look import PALETTE_EVENT_TYPE_SET
|
|
17
|
+
from gitpane.ui.widget.long_text import LineLimitedLabel, WrappingLabel
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from PySide6.QtCore import QObject
|
|
21
|
+
from PySide6.QtWidgets import QWidget
|
|
22
|
+
|
|
23
|
+
RESULT_STYLE_TEMPLATE = "color: {color}; font-weight: bold;"
|
|
24
|
+
NEUTRAL_STYLE = ""
|
|
25
|
+
# Events after which Qt may have drawn the label again from another palette or font than its style sheet's: a theme,
|
|
26
|
+
# style, font or polish change. The colour of a shown result is checked again once they are over.
|
|
27
|
+
RESTYLE_EVENT_TYPE_SET = PALETTE_EVENT_TYPE_SET | frozenset({QEvent.Type.StyleChange, QEvent.Type.FontChange, QEvent.Type.ApplicationFontChange,
|
|
28
|
+
QEvent.Type.PolishRequest, QEvent.Type.ThemeChange})
|
|
29
|
+
# Events after which the label may sit in another window than the one it watches for theme changes.
|
|
30
|
+
WINDOW_TRACKING_EVENT_TYPE_SET = frozenset({QEvent.Type.Show, QEvent.Type.ParentChange})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResultTone(Enum):
|
|
34
|
+
"""Say whether the text shown is a success, a failure, a request to switch to another method, or none of these."""
|
|
35
|
+
|
|
36
|
+
NEUTRAL = "neutral"
|
|
37
|
+
SUCCESS = "success"
|
|
38
|
+
FAILURE = "failure"
|
|
39
|
+
WARNING = "warning"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Light and dark colour of each coloured tone; ordinary text has no colour of its own.
|
|
43
|
+
COLOR_PAIR_BY_TONE = {
|
|
44
|
+
ResultTone.SUCCESS: (SUCCESS_LIGHT_COLOR, SUCCESS_DARK_COLOR),
|
|
45
|
+
ResultTone.FAILURE: (ERROR_LIGHT_COLOR, ERROR_DARK_COLOR),
|
|
46
|
+
ResultTone.WARNING: (WARNING_LIGHT_COLOR, WARNING_DARK_COLOR),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def result_color(tone: ResultTone, dark: bool) -> str:
|
|
51
|
+
"""Return the colour of a tone in the theme's variant, or an empty text for ordinary text, which has none of its own."""
|
|
52
|
+
color_pair = COLOR_PAIR_BY_TONE.get(tone)
|
|
53
|
+
if color_pair is None:
|
|
54
|
+
return ""
|
|
55
|
+
light_color, dark_color = color_pair
|
|
56
|
+
return dark_color if dark else light_color
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def result_style_sheet(tone: ResultTone, dark: bool) -> str:
|
|
60
|
+
"""Return the style sheet of a tone: bold green, red or orange in the theme's variant, nothing for ordinary text."""
|
|
61
|
+
color = result_color(tone, dark)
|
|
62
|
+
if not color:
|
|
63
|
+
return NEUTRAL_STYLE
|
|
64
|
+
return RESULT_STYLE_TEMPLATE.format(color=color)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ToneStyleMixin:
|
|
68
|
+
"""Keep a widget drawn bold in the colour of its current tone, in the light or dark variant of the appearance in effect.
|
|
69
|
+
|
|
70
|
+
Mixed in front of a Qt widget class. The colour is carried by a style sheet, never by markup in the text, so a
|
|
71
|
+
diagnostic can never inject formatting. After any theme, style, font or polish change, the widget checks, once the
|
|
72
|
+
change is over, that it is still drawn bold in its tone's colour, and styles itself again if Qt drew it from another
|
|
73
|
+
palette or font.
|
|
74
|
+
|
|
75
|
+
A widget inside a window never hears of a theme change itself: its style sheet gives it a palette of its own, and
|
|
76
|
+
Qt only passes a new application palette to widgets without one. The widget therefore watches its window, which
|
|
77
|
+
does hear of it, from the moment it is shown or moved to another parent. The window is held through a weak
|
|
78
|
+
reference, so a closed window is never kept alive by one of its own widgets.
|
|
79
|
+
|
|
80
|
+
The state starts as class attributes so the widget's own constructor can already show text in a tone.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
tone = ResultTone.NEUTRAL
|
|
84
|
+
style_check_pending = False
|
|
85
|
+
restyling = False
|
|
86
|
+
watched_window_reference: weakref.ReferenceType[QWidget] | None = None
|
|
87
|
+
|
|
88
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
89
|
+
"""Pick the light or dark colour again when the theme changes while a coloured text is shown."""
|
|
90
|
+
super().changeEvent(event)
|
|
91
|
+
if event.type() in PALETTE_EVENT_TYPE_SET:
|
|
92
|
+
self._apply_style()
|
|
93
|
+
|
|
94
|
+
def event(self, event: QEvent) -> bool:
|
|
95
|
+
"""Check again, once the change is over, that a coloured text is still drawn in its colour after a theme, style, font or polish change."""
|
|
96
|
+
handled = super().event(event)
|
|
97
|
+
if event.type() in WINDOW_TRACKING_EVENT_TYPE_SET:
|
|
98
|
+
self._watch_window()
|
|
99
|
+
if event.type() in RESTYLE_EVENT_TYPE_SET:
|
|
100
|
+
self._schedule_style_check()
|
|
101
|
+
return handled
|
|
102
|
+
|
|
103
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
104
|
+
"""Check the colour again when the window the widget sits in changes palette; the event goes on as usual."""
|
|
105
|
+
if event.type() in PALETTE_EVENT_TYPE_SET and watched is self._watched_window():
|
|
106
|
+
self._schedule_style_check()
|
|
107
|
+
return super().eventFilter(watched, event)
|
|
108
|
+
|
|
109
|
+
def _watch_window(self) -> None:
|
|
110
|
+
"""Watch the theme changes of the window the widget sits in now, and stop watching the one it left.
|
|
111
|
+
|
|
112
|
+
A widget that is a window itself receives them directly and watches nothing.
|
|
113
|
+
"""
|
|
114
|
+
window = self.window()
|
|
115
|
+
current_window = None if window is self else window
|
|
116
|
+
previous_window = self._watched_window()
|
|
117
|
+
if current_window is previous_window:
|
|
118
|
+
return
|
|
119
|
+
if previous_window is not None:
|
|
120
|
+
previous_window.removeEventFilter(self)
|
|
121
|
+
self.watched_window_reference = None if current_window is None else weakref.ref(current_window)
|
|
122
|
+
if current_window is not None:
|
|
123
|
+
current_window.installEventFilter(self)
|
|
124
|
+
|
|
125
|
+
def _watched_window(self) -> QWidget | None:
|
|
126
|
+
"""Answer the window watched for theme changes while it still exists, or nothing."""
|
|
127
|
+
if self.watched_window_reference is None:
|
|
128
|
+
return None
|
|
129
|
+
window = self.watched_window_reference()
|
|
130
|
+
if window is None or not isValid(window):
|
|
131
|
+
return None
|
|
132
|
+
return window
|
|
133
|
+
|
|
134
|
+
def _apply_style(self) -> None:
|
|
135
|
+
"""Set the style of the current tone and theme, only when it differs, so a restyle never triggers another one."""
|
|
136
|
+
style_sheet = result_style_sheet(self.tone, is_dark_palette(QApplication.palette()))
|
|
137
|
+
if self.styleSheet() != style_sheet:
|
|
138
|
+
self.setStyleSheet(style_sheet)
|
|
139
|
+
|
|
140
|
+
def _schedule_style_check(self) -> None:
|
|
141
|
+
"""Check the drawn colour once the current burst of style events is over; while restyling, the widget's own events are ignored."""
|
|
142
|
+
if self.style_check_pending or self.restyling or self.tone is ResultTone.NEUTRAL:
|
|
143
|
+
return
|
|
144
|
+
self.style_check_pending = True
|
|
145
|
+
QTimer.singleShot(0, self, self._check_style)
|
|
146
|
+
|
|
147
|
+
def _check_style(self) -> None:
|
|
148
|
+
"""Style the widget again when a coloured text is no longer drawn bold in its tone's colour.
|
|
149
|
+
|
|
150
|
+
Setting the same style sheet again changes nothing in Qt, so the style is polished again instead. Polishing sends
|
|
151
|
+
palette and font events to the widget; they are ignored meanwhile, so one check never starts another.
|
|
152
|
+
"""
|
|
153
|
+
self.style_check_pending = False
|
|
154
|
+
self._apply_style()
|
|
155
|
+
color = result_color(self.tone, is_dark_palette(QApplication.palette()))
|
|
156
|
+
if not color:
|
|
157
|
+
return
|
|
158
|
+
if self.palette().color(QPalette.ColorRole.WindowText) == QColor(color) and self.font().bold():
|
|
159
|
+
return
|
|
160
|
+
self.restyling = True
|
|
161
|
+
try:
|
|
162
|
+
self.style().unpolish(self)
|
|
163
|
+
self.style().polish(self)
|
|
164
|
+
self.update()
|
|
165
|
+
finally:
|
|
166
|
+
self.restyling = False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ResultToneMixin(ToneStyleMixin):
|
|
170
|
+
"""Colour handling shared by every label that shows the result of an action: bold green, red or orange, or ordinary text.
|
|
171
|
+
|
|
172
|
+
Mixed in front of a ``QLabel`` subclass, so the label's own ``setText`` (wrapping, line limit) still lays the text
|
|
173
|
+
out. A result lost in ordinary text is easy to miss, so ``show_success`` and ``show_failure`` draw it bold green or
|
|
174
|
+
bold red, and ``show_warning`` draws in bold orange a result that is no error but needs the user's attention. Any
|
|
175
|
+
other text written with ``setText`` or ``clear`` (a wait message, an information line) goes back to the ordinary
|
|
176
|
+
look, so a wait never keeps the colour of the previous result. A shown result keeps its colour until other text
|
|
177
|
+
replaces it, through every theme change.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def setText(self, text: str) -> None:
|
|
181
|
+
"""Show ordinary text, dropping the colour of any previous result."""
|
|
182
|
+
self.show_result(text, ResultTone.NEUTRAL)
|
|
183
|
+
|
|
184
|
+
def clear(self) -> None:
|
|
185
|
+
"""Empty the label and drop the colour of any previous result."""
|
|
186
|
+
self.show_result("", ResultTone.NEUTRAL)
|
|
187
|
+
|
|
188
|
+
def show_success(self, text: str) -> None:
|
|
189
|
+
"""Show the text of an action that worked, in bold green."""
|
|
190
|
+
self.show_result(text, ResultTone.SUCCESS)
|
|
191
|
+
|
|
192
|
+
def show_failure(self, text: str) -> None:
|
|
193
|
+
"""Show the text of an action that failed or needs attention, in bold red."""
|
|
194
|
+
self.show_result(text, ResultTone.FAILURE)
|
|
195
|
+
|
|
196
|
+
def show_warning(self, text: str) -> None:
|
|
197
|
+
"""Show, in bold orange, a result that is not an error but needs the user's attention."""
|
|
198
|
+
self.show_result(text, ResultTone.WARNING)
|
|
199
|
+
|
|
200
|
+
def show_result(self, text: str, tone: ResultTone) -> None:
|
|
201
|
+
"""Show a text in the given tone; the text and its colour always change together."""
|
|
202
|
+
super().setText(text)
|
|
203
|
+
self.tone = tone
|
|
204
|
+
self._apply_style()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ResultLabel(ResultToneMixin, WrappingLabel):
|
|
208
|
+
"""Plain, selectable, wrapped text whose colour says at a glance whether the last action worked.
|
|
209
|
+
|
|
210
|
+
Long words such as URLs wrap like in any other wrapping label, so a diagnostic never widens its window.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
|
|
214
|
+
"""Build the label with the same rendering as the other plain Settings labels, in the ordinary tone."""
|
|
215
|
+
super().__init__("", parent)
|
|
216
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
217
|
+
self.setWordWrap(True)
|
|
218
|
+
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
|
|
219
|
+
self.setText(text)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class LineLimitedResultLabel(ResultToneMixin, LineLimitedLabel):
|
|
223
|
+
"""Status text kept to a fixed number of lines, ending with an ellipsis when cut, whose colour says whether it is a result that needs attention.
|
|
224
|
+
|
|
225
|
+
The lines are measured with the font the label is drawn with, bold included, so a coloured result never runs past
|
|
226
|
+
the edge or past its last line.
|
|
227
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Show a status bar message in the colour of its result tone until another message replaces it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtWidgets import QStatusBar
|
|
8
|
+
|
|
9
|
+
from gitpane.ui.widget.result_label import ResultTone, ToneStyleMixin
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from PySide6.QtWidgets import QWidget
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResultStatusBar(ToneStyleMixin, QStatusBar):
|
|
16
|
+
"""A status bar that can show a failure bold red, in the error red of the appearance in effect, so a refusal there is not missed.
|
|
17
|
+
|
|
18
|
+
``QStatusBar`` draws its message with the ``WindowText`` colour of its palette and with its own font, so the tone's
|
|
19
|
+
style sheet, set on the bar, colours and bolds the message. Any other message, written by ``showMessage``, by Qt
|
|
20
|
+
itself (a status tip, a message that times out) or cleared, goes back to the ordinary look: the red belongs to the
|
|
21
|
+
failure it was shown with. The colour follows a light or dark switch while the failure is shown.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
25
|
+
"""Build the bar in the ordinary tone, ready to drop a tone as soon as another message replaces the one it coloured."""
|
|
26
|
+
super().__init__(parent)
|
|
27
|
+
self.toned_message = ""
|
|
28
|
+
self.messageChanged.connect(self._drop_tone_of_replaced_message)
|
|
29
|
+
|
|
30
|
+
def showMessage(self, text: str, timeout: int = 0) -> None:
|
|
31
|
+
"""Show an ordinary message, dropping the colour of any previous one, even when the text is the same."""
|
|
32
|
+
self.show_message_in_tone(text, ResultTone.NEUTRAL, timeout)
|
|
33
|
+
|
|
34
|
+
def show_failure(self, text: str) -> None:
|
|
35
|
+
"""Show the message of an action that failed, in bold red, until another message replaces it."""
|
|
36
|
+
self.show_message_in_tone(text, ResultTone.FAILURE)
|
|
37
|
+
|
|
38
|
+
def show_message_in_tone(self, text: str, tone: ResultTone, timeout: int = 0) -> None:
|
|
39
|
+
"""Show a message in the given tone; the message and its colour always change together."""
|
|
40
|
+
self.toned_message = text
|
|
41
|
+
self.tone = tone
|
|
42
|
+
self._apply_style()
|
|
43
|
+
super().showMessage(text, timeout)
|
|
44
|
+
|
|
45
|
+
def _drop_tone_of_replaced_message(self, text: str) -> None:
|
|
46
|
+
"""Go back to the ordinary look once the bar shows another text than the one its tone was given with."""
|
|
47
|
+
if text == self.toned_message:
|
|
48
|
+
return
|
|
49
|
+
self.toned_message = text
|
|
50
|
+
if self.tone is ResultTone.NEUTRAL:
|
|
51
|
+
return
|
|
52
|
+
self.tone = ResultTone.NEUTRAL
|
|
53
|
+
self._apply_style()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Show a text that may be longer than its window can hold in an area that scrolls, never asking the window's layout for more than a few lines.
|
|
2
|
+
|
|
3
|
+
A label laid directly in a window makes the window's layout ask for the whole text as its minimum height, and Qt grows
|
|
4
|
+
a window to its layout's minimum by itself: a Git or hook output of a hundred lines then makes the window taller than
|
|
5
|
+
the screen, with the buttons under the text out of reach. In this area the layout counts the text at most at Qt's own
|
|
6
|
+
minimum height of a scroll area, and at the text's height when it is shorter; the growth rule of the window then gives
|
|
7
|
+
it the text's whole height up to its cap, and the text scrolls beyond.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QEvent, QSize, Qt
|
|
15
|
+
from PySide6.QtWidgets import QFrame, QScrollArea
|
|
16
|
+
|
|
17
|
+
from gitpane.ui.widget.dialog_layout import scrolled_content_missing_height
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from PySide6.QtGui import QResizeEvent
|
|
21
|
+
from PySide6.QtWidgets import QLabel, QWidget
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScrolledText(QScrollArea):
|
|
25
|
+
"""Hold one label in a frameless area, drawn on the window's own background, that scrolls once the window gives it less height than the text needs."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, label: QLabel, parent: QWidget | None = None) -> None:
|
|
28
|
+
"""Hold ``label``, which wraps to the area's width and scrolls only vertically."""
|
|
29
|
+
super().__init__(parent)
|
|
30
|
+
self.setFrameShape(QFrame.Shape.NoFrame)
|
|
31
|
+
self.setWidgetResizable(True)
|
|
32
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
33
|
+
self.setWidget(label)
|
|
34
|
+
# setWidget makes the label fill its background, and the viewport fills its own with the input colour: the text
|
|
35
|
+
# keeps the window's background instead, as when it was laid directly in the window.
|
|
36
|
+
label.setAutoFillBackground(False)
|
|
37
|
+
self.viewport().setAutoFillBackground(False)
|
|
38
|
+
|
|
39
|
+
def needed_height(self) -> int:
|
|
40
|
+
"""Return the area's height that shows the whole text without scrolling, measured at the width the text has without a scroll bar."""
|
|
41
|
+
return self.height() + scrolled_content_missing_height(self)
|
|
42
|
+
|
|
43
|
+
def minimumSizeHint(self) -> QSize:
|
|
44
|
+
"""Ask the window's layout for Qt's minimum height of a scroll area, or for the text's height when the text is shorter."""
|
|
45
|
+
hint = super().minimumSizeHint()
|
|
46
|
+
return QSize(hint.width(), min(hint.height(), self.needed_height()))
|
|
47
|
+
|
|
48
|
+
def sizeHint(self) -> QSize:
|
|
49
|
+
"""Prefer the text's whole height, so a window with room to spare shows the text without scrolling."""
|
|
50
|
+
hint = super().sizeHint()
|
|
51
|
+
return QSize(hint.width(), self.needed_height())
|
|
52
|
+
|
|
53
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
54
|
+
"""Tell the window's layout that the text's height changed with the area's width.
|
|
55
|
+
|
|
56
|
+
Qt keeps the size hints it read from the area until the area asks it to read them again, and the text's height
|
|
57
|
+
depends on the width: an area hidden when its window opened was measured at the size it was built with, wider
|
|
58
|
+
than the window gives it, and an area the user narrows takes more lines. Measured at the older width, the text
|
|
59
|
+
would hide its last lines behind a scroll bar while the window had room for them.
|
|
60
|
+
"""
|
|
61
|
+
super().resizeEvent(event)
|
|
62
|
+
if event.oldSize().width() != event.size().width():
|
|
63
|
+
self.updateGeometry()
|
|
64
|
+
|
|
65
|
+
def viewportEvent(self, event: QEvent) -> bool:
|
|
66
|
+
"""Tell the window's layout that the text changed, since the label only asks its own parent, the viewport, to lay it out again."""
|
|
67
|
+
if event.type() == QEvent.Type.LayoutRequest:
|
|
68
|
+
self.updateGeometry()
|
|
69
|
+
return super().viewportEvent(event)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Write the text of a keyboard shortcut the way the user's system writes it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PySide6.QtGui import QKeySequence
|
|
8
|
+
|
|
9
|
+
from gitpane.config import MACOS_PLATFORM
|
|
10
|
+
|
|
11
|
+
# macOS writes its shortcuts with Apple key symbols; Windows and Linux write them in words, Qt's portable format.
|
|
12
|
+
SHORTCUT_FORMAT_BY_PLATFORM: dict[str, QKeySequence.SequenceFormat] = {MACOS_PLATFORM: QKeySequence.SequenceFormat.NativeText}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def shortcut_text(sequence: QKeySequence, platform: str = sys.platform) -> str:
|
|
16
|
+
"""Return the text of a shortcut as the given system writes it.
|
|
17
|
+
|
|
18
|
+
Qt writes the text from the sequence itself, so it follows any change of the shortcut, and on macOS it puts the
|
|
19
|
+
Apple symbols in Apple's order with Ctrl standing for the Command key. A system missing from the table gets the
|
|
20
|
+
worded format rather than another system's symbols.
|
|
21
|
+
"""
|
|
22
|
+
sequence_format = SHORTCUT_FORMAT_BY_PLATFORM.get(platform, QKeySequence.SequenceFormat.PortableText)
|
|
23
|
+
return sequence.toString(sequence_format)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def with_shortcut(description: str, shortcut: str, platform: str = sys.platform) -> str:
|
|
27
|
+
"""Return a tooltip made of a description followed by the text of its shortcut between parentheses.
|
|
28
|
+
|
|
29
|
+
The shortcut is the same portable sequence the action reacts to, so the tooltip can never cite another key.
|
|
30
|
+
"""
|
|
31
|
+
return description + " (" + shortcut_text(QKeySequence(shortcut), platform) + ")"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Show one of the style's standard icons, drawn again whenever the palette changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent
|
|
8
|
+
from PySide6.QtGui import QPixmap
|
|
9
|
+
from PySide6.QtWidgets import QLabel
|
|
10
|
+
|
|
11
|
+
from gitpane.ui.look.theme_look import PALETTE_EVENT_TYPE_SET
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from PySide6.QtCore import QSize
|
|
15
|
+
from PySide6.QtWidgets import QStyle, QWidget
|
|
16
|
+
|
|
17
|
+
# Events after which the style may draw the icon differently: another palette (a switch between the light and the dark
|
|
18
|
+
# appearance) or another style.
|
|
19
|
+
REDRAW_EVENT_TYPE_SET = PALETTE_EVENT_TYPE_SET | frozenset({QEvent.Type.StyleChange})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StandardIconLabel(QLabel):
|
|
23
|
+
"""A fixed-size label showing a standard icon of the style, which follows a change of appearance while its window is open.
|
|
24
|
+
|
|
25
|
+
The style draws some icons after the palette they are shown on (the warning icon in the light or the dark warning
|
|
26
|
+
color, the question icon in the text color of a dark theme). An image set once would keep the look of the appearance
|
|
27
|
+
the window was opened in, so the label keeps which icon it shows and asks the style for it again after each palette
|
|
28
|
+
or style change. It must never get a style sheet: Qt does not pass palette changes on to a widget that has one.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, side: QSize, standard_pixmap: QStyle.StandardPixmap | None = None, parent: QWidget | None = None) -> None:
|
|
32
|
+
"""Build the label at the icon's size, showing that icon, or nothing until one is chosen."""
|
|
33
|
+
super().__init__(parent)
|
|
34
|
+
self.side = side
|
|
35
|
+
self.standard_pixmap = standard_pixmap
|
|
36
|
+
self.setFixedSize(side)
|
|
37
|
+
self._draw_icon()
|
|
38
|
+
|
|
39
|
+
def set_standard_pixmap(self, standard_pixmap: QStyle.StandardPixmap | None) -> None:
|
|
40
|
+
"""Show another standard icon, or no image at all, and keep it drawn after later palette changes."""
|
|
41
|
+
self.standard_pixmap = standard_pixmap
|
|
42
|
+
self._draw_icon()
|
|
43
|
+
|
|
44
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
45
|
+
"""Draw the icon again from the style once the palette or the style the label is shown with has changed."""
|
|
46
|
+
if event.type() in REDRAW_EVENT_TYPE_SET:
|
|
47
|
+
self._draw_icon()
|
|
48
|
+
super().changeEvent(event)
|
|
49
|
+
|
|
50
|
+
def _draw_icon(self) -> None:
|
|
51
|
+
"""Lay the image of the chosen icon at the label's size, as the style draws it for this label's palette now."""
|
|
52
|
+
if self.standard_pixmap is None:
|
|
53
|
+
self.setPixmap(QPixmap())
|
|
54
|
+
return
|
|
55
|
+
self.setPixmap(self.style().standardIcon(self.standard_pixmap, None, self).pixmap(self.side))
|