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,373 @@
|
|
|
1
|
+
"""Show the filtered output of one operation, one bounded page at a time, following its end while it streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QElapsedTimer, Qt, QTimer
|
|
9
|
+
from PySide6.QtGui import QFontDatabase, QTextCursor
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QGridLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget
|
|
11
|
+
|
|
12
|
+
from gitpane.config import TRACE_NAVIGATION_INTERVAL_MS, TRACE_RENDER_INTERVAL_MS, TRACE_VIEW_LIMIT_BYTES
|
|
13
|
+
from gitpane.history.revision_file import utf16_length
|
|
14
|
+
from gitpane.operation.console_tone import ConsoleTone
|
|
15
|
+
from gitpane.ui.console.operation_output import OperationOutputHighlighter
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
from gitpane.operation.operation_trace import OperationTrace, TraceEvent
|
|
21
|
+
|
|
22
|
+
# Reading this many bytes from any offset always reaches the first byte of the next UTF-8 character.
|
|
23
|
+
UTF8_CHARACTER_MAX_BYTES = 4
|
|
24
|
+
DOCUMENT_START_POSITION = 0
|
|
25
|
+
DOCUMENT_START_BLOCK_NUMBER = 0
|
|
26
|
+
# What starts a new block when Qt reads plain text: a line feed, a carriage return, both together (a single break), or
|
|
27
|
+
# the Unicode paragraph separator, written here in UTF-8.
|
|
28
|
+
QT_BLOCK_BREAK_PATTERN = re.compile(rb"\r\n|\r|\n|\xe2\x80\xa9")
|
|
29
|
+
# Page buttons go on two columns, with the byte counter below them, when one row does not fit the available width.
|
|
30
|
+
NARROW_NAVIGATION_COLUMNS = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BlockNumberWalker:
|
|
34
|
+
"""Give the Qt block number of increasing byte offsets of a page, reading the page's line breaks only once."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, data: bytes, first_block_number: int) -> None:
|
|
37
|
+
"""Start before the first byte of the page, which lies in the given block."""
|
|
38
|
+
self.break_ends: Iterator[int] = (match.end() for match in QT_BLOCK_BREAK_PATTERN.finditer(data))
|
|
39
|
+
self.next_break_end = next(self.break_ends, None)
|
|
40
|
+
self.block_number = first_block_number
|
|
41
|
+
|
|
42
|
+
def block_number_at(self, offset: int) -> int:
|
|
43
|
+
"""Return the number of the block holding the byte at an offset no smaller than the one asked before."""
|
|
44
|
+
while self.next_break_end is not None and self.next_break_end <= offset:
|
|
45
|
+
self.block_number += 1
|
|
46
|
+
self.next_break_end = next(self.break_ends, None)
|
|
47
|
+
return self.block_number
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OperationOutputView(QWidget):
|
|
51
|
+
"""Display the already filtered text of an operation trace, as the operation consoles and the clone form show it.
|
|
52
|
+
|
|
53
|
+
Only one bounded page of text lives in Qt at a time; the page buttons reach earlier and later output. While the
|
|
54
|
+
user follows the end, new output is appended in batches; a user who scrolls up, selects text or reads an older page
|
|
55
|
+
keeps an untouched view.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, trace: OperationTrace, parent: QWidget | None = None) -> None:
|
|
59
|
+
"""Build the read-only output above its page buttons and byte counter, then show the latest page."""
|
|
60
|
+
super().__init__(parent)
|
|
61
|
+
self.trace = trace
|
|
62
|
+
self.stopped = False
|
|
63
|
+
self.page_start = 0
|
|
64
|
+
self.page_end = 0
|
|
65
|
+
self.unstable_start: int | None = None
|
|
66
|
+
self.unstable_length = 0
|
|
67
|
+
self.follow_tail = True
|
|
68
|
+
# Set only by the user's own scrolling (wheel, keys, scrollbar) and by page navigation, never read back from the
|
|
69
|
+
# scroll position: Qt moves the scrollbar by itself when it lays wrapped lines out again, for instance when the
|
|
70
|
+
# first overflowing output shows the vertical scrollbar and narrows the view.
|
|
71
|
+
self.scrolled_away = False
|
|
72
|
+
self.render_pending = False
|
|
73
|
+
self.output_missed = False
|
|
74
|
+
self.render_timer = QTimer(self)
|
|
75
|
+
self.render_timer.setSingleShot(True)
|
|
76
|
+
self.render_timer.timeout.connect(self.flush_output)
|
|
77
|
+
self.navigation_timer = QTimer(self)
|
|
78
|
+
self.navigation_timer.setSingleShot(True)
|
|
79
|
+
self.navigation_timer.timeout.connect(self._update_page_label)
|
|
80
|
+
self.navigation_clock = QElapsedTimer()
|
|
81
|
+
layout = QVBoxLayout(self)
|
|
82
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
83
|
+
self.detail_edit = QPlainTextEdit()
|
|
84
|
+
self.detail_edit.setReadOnly(True)
|
|
85
|
+
# Output is appended to the document while it streams; an undo history would keep every appended and trimmed
|
|
86
|
+
# piece alive for the whole operation, and a read-only view never undoes anything.
|
|
87
|
+
self.detail_edit.setUndoRedoEnabled(False)
|
|
88
|
+
self.detail_edit.setAccessibleName("Operation output")
|
|
89
|
+
self.detail_edit.setPlaceholderText("Waiting for output…")
|
|
90
|
+
self.detail_edit.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
|
|
91
|
+
self.output_highlighter = OperationOutputHighlighter(self.detail_edit)
|
|
92
|
+
self.detail_edit.selectionChanged.connect(self._output_selection_changed)
|
|
93
|
+
output_scrollbar = self.detail_edit.verticalScrollBar()
|
|
94
|
+
output_scrollbar.actionTriggered.connect(self._output_scrolled_by_user)
|
|
95
|
+
output_scrollbar.rangeChanged.connect(self._output_range_changed)
|
|
96
|
+
output_scrollbar.valueChanged.connect(self._output_value_changed)
|
|
97
|
+
layout.addWidget(self.detail_edit, 1)
|
|
98
|
+
self.navigation_layout = QGridLayout()
|
|
99
|
+
self.first_button = QPushButton("First output")
|
|
100
|
+
self.first_button.clicked.connect(lambda: self.show_page(0))
|
|
101
|
+
self.previous_button = QPushButton("Previous output")
|
|
102
|
+
self.previous_button.clicked.connect(lambda: self.show_page(max(0, self.page_start - TRACE_VIEW_LIMIT_BYTES)))
|
|
103
|
+
self.next_button = QPushButton("Next output")
|
|
104
|
+
self.next_button.clicked.connect(lambda: self.show_page(self.page_end))
|
|
105
|
+
self.latest_button = QPushButton("Latest output")
|
|
106
|
+
self.latest_button.clicked.connect(self.show_latest)
|
|
107
|
+
self.navigation_buttons = (self.first_button, self.previous_button, self.next_button, self.latest_button)
|
|
108
|
+
for column, button in enumerate(self.navigation_buttons):
|
|
109
|
+
button.setAutoDefault(False)
|
|
110
|
+
button.setAccessibleName(button.text())
|
|
111
|
+
self.navigation_layout.addWidget(button, 0, column)
|
|
112
|
+
self.page_label = QLabel()
|
|
113
|
+
self.page_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
114
|
+
self.page_label.setWordWrap(True)
|
|
115
|
+
self.navigation_layout.addWidget(self.page_label, 0, len(self.navigation_buttons))
|
|
116
|
+
layout.addLayout(self.navigation_layout)
|
|
117
|
+
trace.event_added.connect(self.append_event)
|
|
118
|
+
self.show_latest()
|
|
119
|
+
|
|
120
|
+
def refresh_metrics(self, available_width: int) -> None:
|
|
121
|
+
"""Put the page buttons on two columns, the byte counter below them, when one row is wider than the available width."""
|
|
122
|
+
spacing = self.navigation_layout.horizontalSpacing()
|
|
123
|
+
navigation_width = self.page_label.sizeHint().width()
|
|
124
|
+
for button in self.navigation_buttons:
|
|
125
|
+
navigation_width += button.sizeHint().width() + spacing
|
|
126
|
+
columns = len(self.navigation_buttons)
|
|
127
|
+
if navigation_width > available_width:
|
|
128
|
+
columns = NARROW_NAVIGATION_COLUMNS
|
|
129
|
+
for button in self.navigation_buttons:
|
|
130
|
+
self.navigation_layout.removeWidget(button)
|
|
131
|
+
self.navigation_layout.removeWidget(self.page_label)
|
|
132
|
+
for index, button in enumerate(self.navigation_buttons):
|
|
133
|
+
row, column = divmod(index, columns)
|
|
134
|
+
self.navigation_layout.addWidget(button, row, column)
|
|
135
|
+
if columns < len(self.navigation_buttons):
|
|
136
|
+
page_row = len(self.navigation_buttons) // columns
|
|
137
|
+
self.navigation_layout.addWidget(self.page_label, page_row, 0, 1, columns)
|
|
138
|
+
else:
|
|
139
|
+
self.navigation_layout.addWidget(self.page_label, 0, columns)
|
|
140
|
+
self.layout().activate()
|
|
141
|
+
|
|
142
|
+
def stop(self) -> None:
|
|
143
|
+
"""Stop the render and counter timers and ignore any later output, once nobody looks at this view anymore."""
|
|
144
|
+
self.stopped = True
|
|
145
|
+
self.render_timer.stop()
|
|
146
|
+
self.navigation_timer.stop()
|
|
147
|
+
|
|
148
|
+
def show_page(self, start: int) -> None:
|
|
149
|
+
"""Load one bounded page of already filtered text on explicit navigation.
|
|
150
|
+
|
|
151
|
+
The page is read from its top: nothing follows the end while it loads, and on the latest page following resumes
|
|
152
|
+
only once the user scrolls down to its end.
|
|
153
|
+
"""
|
|
154
|
+
if self.trace.closed:
|
|
155
|
+
return
|
|
156
|
+
text, self.page_start, self.page_end = self.trace.read_text_page(start)
|
|
157
|
+
self.output_missed = False
|
|
158
|
+
self.follow_tail = False
|
|
159
|
+
self.detail_edit.setPlainText(text)
|
|
160
|
+
self._apply_tones(self.page_start, text, DOCUMENT_START_BLOCK_NUMBER)
|
|
161
|
+
self._remember_unstable_tail(text, self.page_start)
|
|
162
|
+
self.follow_tail = self.page_end == self._total_size()
|
|
163
|
+
scrollbar = self.detail_edit.verticalScrollBar()
|
|
164
|
+
self.scrolled_away = scrollbar.value() < scrollbar.maximum()
|
|
165
|
+
self._update_navigation()
|
|
166
|
+
|
|
167
|
+
def show_latest(self) -> None:
|
|
168
|
+
"""Resume following output from the last bounded page."""
|
|
169
|
+
self.show_page(max(0, self._total_size() - TRACE_VIEW_LIMIT_BYTES))
|
|
170
|
+
self.follow_tail = True
|
|
171
|
+
self.scrolled_away = False
|
|
172
|
+
self.detail_edit.verticalScrollBar().setValue(self.detail_edit.verticalScrollBar().maximum())
|
|
173
|
+
|
|
174
|
+
def append_event(self, event: TraceEvent) -> None:
|
|
175
|
+
"""Schedule a render for records of the trace's current operation; the batch interval keeps the UI thread free."""
|
|
176
|
+
if event.operation_id != self.trace.operation_id or self.stopped:
|
|
177
|
+
return
|
|
178
|
+
self.render_pending = True
|
|
179
|
+
if not self.render_timer.isActive():
|
|
180
|
+
self.render_timer.start(TRACE_RENDER_INTERVAL_MS)
|
|
181
|
+
|
|
182
|
+
def flush_output(self, *, exact_count: bool = False) -> None:
|
|
183
|
+
"""Render one batch only when doing so preserves the user's selection and scroll.
|
|
184
|
+
|
|
185
|
+
While the user follows the end of the output, only the text written since the last batch is appended; a user
|
|
186
|
+
who scrolled up, selected text or reads an older page keeps an untouched view. What they missed is appended,
|
|
187
|
+
as a terminal does, once they scroll back down to the end of the latest page or clear their selection.
|
|
188
|
+
``exact_count`` rewrites the byte counter at once, for a finished operation whose
|
|
189
|
+
total must be shown exactly; otherwise the counter is rewritten at most once per interval.
|
|
190
|
+
"""
|
|
191
|
+
self.render_timer.stop()
|
|
192
|
+
if self.stopped or self.trace.closed:
|
|
193
|
+
return
|
|
194
|
+
if self._following_output():
|
|
195
|
+
self._append_latest()
|
|
196
|
+
elif self.render_pending:
|
|
197
|
+
self.output_missed = True
|
|
198
|
+
self.render_pending = False
|
|
199
|
+
if exact_count:
|
|
200
|
+
self._update_navigation()
|
|
201
|
+
return
|
|
202
|
+
self._schedule_navigation_update()
|
|
203
|
+
|
|
204
|
+
def copy_detail(self) -> None:
|
|
205
|
+
"""Copy the same filtered text exposed by the model."""
|
|
206
|
+
QApplication.clipboard().setText(self.trace.copy_text())
|
|
207
|
+
|
|
208
|
+
def _total_size(self) -> int:
|
|
209
|
+
"""Return the size of the whole filtered text, including the closing record of an incomplete trace."""
|
|
210
|
+
return self.trace.text_size + len(self.trace.failure_text)
|
|
211
|
+
|
|
212
|
+
def _update_navigation(self) -> None:
|
|
213
|
+
"""Keep earlier and later output accessible without loading it into Qt."""
|
|
214
|
+
self._update_navigation_buttons()
|
|
215
|
+
self._update_page_label()
|
|
216
|
+
|
|
217
|
+
def _update_navigation_buttons(self) -> None:
|
|
218
|
+
"""Enable the page buttons that lead somewhere; an unchanged state costs Qt nothing, so this runs on every batch."""
|
|
219
|
+
total = self._total_size()
|
|
220
|
+
self.first_button.setEnabled(self.page_start > 0)
|
|
221
|
+
self.previous_button.setEnabled(self.page_start > 0)
|
|
222
|
+
self.next_button.setEnabled(self.page_end < total)
|
|
223
|
+
self.latest_button.setEnabled(self.page_end < total)
|
|
224
|
+
|
|
225
|
+
def _update_page_label(self) -> None:
|
|
226
|
+
"""Rewrite the byte counter now, restarting the clock that spaces out its rewrites and dropping a deferred one."""
|
|
227
|
+
self.navigation_clock.start()
|
|
228
|
+
self.navigation_timer.stop()
|
|
229
|
+
total = self._total_size()
|
|
230
|
+
# The last line can shrink while the user keeps an unchanged view of older output.
|
|
231
|
+
self.page_label.setText(f"{self.page_start}–{min(self.page_end, total)} / {total} bytes")
|
|
232
|
+
|
|
233
|
+
def _following_output(self) -> bool:
|
|
234
|
+
"""Tell whether the view keeps showing the newest output: latest page loaded, no user scroll away from its end, no selection."""
|
|
235
|
+
return self.follow_tail and not self.scrolled_away and not self.detail_edit.textCursor().hasSelection()
|
|
236
|
+
|
|
237
|
+
def _output_scrolled_by_user(self, _action: int) -> None:
|
|
238
|
+
"""Stop following when the user scrolls above the end, and follow again once they scroll back down to it.
|
|
239
|
+
|
|
240
|
+
Qt reports the position the user's action leads to before applying it. Coming back to the end also shows the
|
|
241
|
+
output written meanwhile, which matters when the operation ended while the user was reading higher up.
|
|
242
|
+
"""
|
|
243
|
+
scrollbar = self.detail_edit.verticalScrollBar()
|
|
244
|
+
self.scrolled_away = scrollbar.sliderPosition() < scrollbar.maximum()
|
|
245
|
+
missed_output = self.output_missed or self.page_end < self._total_size()
|
|
246
|
+
if self._following_output() and missed_output and not self.render_timer.isActive():
|
|
247
|
+
self.render_timer.start(TRACE_RENDER_INTERVAL_MS)
|
|
248
|
+
|
|
249
|
+
def _output_range_changed(self, _minimum: int, maximum: int) -> None:
|
|
250
|
+
"""Keep the end in view when new text, wrapping or resizing changes the scroll range."""
|
|
251
|
+
if self._following_output():
|
|
252
|
+
self.detail_edit.verticalScrollBar().setValue(maximum)
|
|
253
|
+
|
|
254
|
+
def _output_selection_changed(self) -> None:
|
|
255
|
+
"""Show output missed during a selection once its release resumes following the end."""
|
|
256
|
+
if self._following_output() and self.output_missed and not self.render_timer.isActive():
|
|
257
|
+
self.render_timer.start(TRACE_RENDER_INTERVAL_MS)
|
|
258
|
+
|
|
259
|
+
def _output_value_changed(self, value: int) -> None:
|
|
260
|
+
"""Put the end back in view when Qt itself moves a followed view, as it does when it lays wrapped lines out again."""
|
|
261
|
+
scrollbar = self.detail_edit.verticalScrollBar()
|
|
262
|
+
if value != scrollbar.maximum() and self._following_output():
|
|
263
|
+
scrollbar.setValue(scrollbar.maximum())
|
|
264
|
+
|
|
265
|
+
def _remember_unstable_tail(self, text: str, text_start: int) -> None:
|
|
266
|
+
"""Remember the shown tail the trace can still replace, measured in Qt's UTF-16 positions."""
|
|
267
|
+
stable_size = self.trace.stable_size()
|
|
268
|
+
self.unstable_start = None
|
|
269
|
+
self.unstable_length = 0
|
|
270
|
+
if stable_size >= self.page_end:
|
|
271
|
+
return
|
|
272
|
+
self.unstable_start = stable_size
|
|
273
|
+
offset = max(0, stable_size - text_start)
|
|
274
|
+
self.unstable_length = utf16_length(text.encode("utf-8")[offset:].decode("utf-8"))
|
|
275
|
+
|
|
276
|
+
def _append_latest(self) -> None:
|
|
277
|
+
"""Replace the still-open tail and append new bytes, then trim the start back to one page.
|
|
278
|
+
|
|
279
|
+
More new text than one page at once is shown by reloading the last page instead, which gives the same view.
|
|
280
|
+
"""
|
|
281
|
+
self.output_missed = False
|
|
282
|
+
total = self._total_size()
|
|
283
|
+
if self.unstable_start is not None and self.unstable_start < self.page_start:
|
|
284
|
+
self.show_latest()
|
|
285
|
+
return
|
|
286
|
+
document = self.detail_edit.document()
|
|
287
|
+
cursor = QTextCursor(document)
|
|
288
|
+
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
289
|
+
if self.unstable_start is not None:
|
|
290
|
+
cursor.setPosition(document.characterCount() - 1 - self.unstable_length)
|
|
291
|
+
cursor.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor)
|
|
292
|
+
self.page_end = self.unstable_start
|
|
293
|
+
document.findBlock(cursor.selectionStart()).setUserState(int(ConsoleTone.NEUTRAL))
|
|
294
|
+
if total - self.page_end > TRACE_VIEW_LIMIT_BYTES:
|
|
295
|
+
self.show_latest()
|
|
296
|
+
return
|
|
297
|
+
text, text_start, end = self.trace.read_text_page(self.page_end, total - self.page_end)
|
|
298
|
+
if not text and self.unstable_start is None:
|
|
299
|
+
return
|
|
300
|
+
last_block_number = document.findBlock(cursor.selectionStart()).blockNumber()
|
|
301
|
+
cursor.insertText(text)
|
|
302
|
+
self._apply_tones(text_start, text, last_block_number)
|
|
303
|
+
self.page_end = end
|
|
304
|
+
self._remember_unstable_tail(text, text_start)
|
|
305
|
+
if not self._trim_start():
|
|
306
|
+
self.show_latest()
|
|
307
|
+
return
|
|
308
|
+
# Text inserted where the view's own cursor stands pushes that cursor along, and a reload used to put it back
|
|
309
|
+
# at the start of the page; moving it back keeps a shift-click selecting from the same place as before.
|
|
310
|
+
view_cursor = self.detail_edit.textCursor()
|
|
311
|
+
if view_cursor.position() != DOCUMENT_START_POSITION:
|
|
312
|
+
view_cursor.setPosition(DOCUMENT_START_POSITION)
|
|
313
|
+
self.detail_edit.setTextCursor(view_cursor)
|
|
314
|
+
self.follow_tail = True
|
|
315
|
+
scrollbar = self.detail_edit.verticalScrollBar()
|
|
316
|
+
scrollbar.setValue(scrollbar.maximum())
|
|
317
|
+
|
|
318
|
+
def _apply_tones(self, start: int, text: str, first_block_number: int) -> None:
|
|
319
|
+
"""Set on each block of a page the tone the trace recorded for its bytes, then colour those blocks again.
|
|
320
|
+
|
|
321
|
+
``text`` was just put in the document from byte ``start`` of the trace, its first character in block
|
|
322
|
+
``first_block_number``. A coloured range always starts at the beginning of a line, or is cut to the start of
|
|
323
|
+
the page, so a block number is a count of line breaks; the breaks are counted once, from range to range.
|
|
324
|
+
"""
|
|
325
|
+
data = text.encode("utf-8")
|
|
326
|
+
spans = self.trace.tone_spans_between(start, start + len(data))
|
|
327
|
+
if not spans:
|
|
328
|
+
return
|
|
329
|
+
document = self.detail_edit.document()
|
|
330
|
+
walker = BlockNumberWalker(data, first_block_number)
|
|
331
|
+
for span in spans:
|
|
332
|
+
first_number = walker.block_number_at(max(0, span.start - start))
|
|
333
|
+
last_number = walker.block_number_at(min(len(data), span.end - start) - 1)
|
|
334
|
+
block = document.findBlockByNumber(first_number)
|
|
335
|
+
while block.isValid() and block.blockNumber() <= last_number:
|
|
336
|
+
block.setUserState(int(span.tone))
|
|
337
|
+
self.output_highlighter.rehighlightBlock(block)
|
|
338
|
+
block = block.next()
|
|
339
|
+
|
|
340
|
+
def _trim_start(self) -> bool:
|
|
341
|
+
"""Remove from the start of the view the bytes a reload of the last page would not show.
|
|
342
|
+
|
|
343
|
+
The cut is computed on the trace bytes, never on Qt blocks, so the view keeps matching exactly the byte range
|
|
344
|
+
the navigation buttons and the counter describe. Returns False when the removed text could not be read back
|
|
345
|
+
exactly; the caller then reloads the last page.
|
|
346
|
+
"""
|
|
347
|
+
bound = self.page_end - TRACE_VIEW_LIMIT_BYTES
|
|
348
|
+
if bound <= self.page_start:
|
|
349
|
+
return True
|
|
350
|
+
if self.unstable_start is not None and bound > self.unstable_start:
|
|
351
|
+
return False
|
|
352
|
+
_character, bound, _character_end = self.trace.read_text_page(bound, UTF8_CHARACTER_MAX_BYTES)
|
|
353
|
+
prefix, _prefix_start, prefix_end = self.trace.read_text_page(self.page_start, bound - self.page_start)
|
|
354
|
+
# A carriage return followed by a line feed is a single position in a Qt document, so the position count would be wrong.
|
|
355
|
+
if prefix_end != bound or "\r" in prefix:
|
|
356
|
+
return False
|
|
357
|
+
cursor = QTextCursor(self.detail_edit.document())
|
|
358
|
+
cursor.setPosition(utf16_length(prefix), QTextCursor.MoveMode.KeepAnchor)
|
|
359
|
+
cursor.removeSelectedText()
|
|
360
|
+
self.page_start = bound
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
def _schedule_navigation_update(self) -> None:
|
|
364
|
+
"""Keep the page buttons current, and rewrite the byte counter at most once per interval while output streams.
|
|
365
|
+
|
|
366
|
+
A counter change arriving too early is written once the interval has passed, so the last value is never lost.
|
|
367
|
+
"""
|
|
368
|
+
self._update_navigation_buttons()
|
|
369
|
+
if not self.navigation_clock.isValid() or self.navigation_clock.elapsed() >= TRACE_NAVIGATION_INTERVAL_MS:
|
|
370
|
+
self._update_page_label()
|
|
371
|
+
return
|
|
372
|
+
if not self.navigation_timer.isActive():
|
|
373
|
+
self.navigation_timer.start(TRACE_NAVIGATION_INTERVAL_MS - self.navigation_clock.elapsed())
|
|
File without changes
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Give each system its colour emoji font: the Noto Color Emoji file shipped with GitPane, bundled exactly as Google publishes it, or the system's own.
|
|
2
|
+
|
|
3
|
+
Windows and Linux draw emoji with the bundled Noto file; macOS draws them with its own Apple Color Emoji and never reads
|
|
4
|
+
the bundled file, because CoreText does not draw that file in colour. A table keyed by the system says which font each
|
|
5
|
+
one uses, and only one font file is shipped. This module holds the family names, the file names of the font and of its
|
|
6
|
+
licence, and reads the font bytes from the installed package. It loads the font of the running system once per
|
|
7
|
+
process, the first time a symbol area needs it, checks that it really draws in colour, and builds the font the emoji
|
|
8
|
+
keyboard and the commit message use. The bundled file keeps its bytes and its family name on disk; only when a system
|
|
9
|
+
font of the same family name takes that name and draws it blank does GitPane register an in-memory copy renamed
|
|
10
|
+
GitPane Noto Color Emoji. The rest of the application keeps the system font.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import functools
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from importlib.resources import files
|
|
19
|
+
|
|
20
|
+
from PySide6.QtCore import QPointF, Qt
|
|
21
|
+
from PySide6.QtGui import QFont, QFontDatabase, QFontInfo, QImage, QPainter, QTextLayout
|
|
22
|
+
|
|
23
|
+
from gitpane.config import LINUX_PLATFORM, MACOS_PLATFORM, UI_RESOURCE_DIR_NAME, UI_RESOURCE_PACKAGE_NAME, WINDOWS_PLATFORM
|
|
24
|
+
from gitpane.ui.emoji.opentype_family_name import font_with_family_name
|
|
25
|
+
|
|
26
|
+
# The upstream family name of the bundled file, which is shipped without any modification.
|
|
27
|
+
EMOJI_FONT_FAMILY = "Noto Color Emoji"
|
|
28
|
+
# The family name of the in-memory copy registered when a system font of the upstream name draws blank in its place. The
|
|
29
|
+
# font licence reserves no font name, so the copy may carry another one; the file on disk is never changed.
|
|
30
|
+
RENAMED_EMOJI_FONT_FAMILY = "GitPane Noto Color Emoji"
|
|
31
|
+
# The file keeps the name Google publishes it under, so its origin can be read from the name alone.
|
|
32
|
+
EMOJI_FONT_BASENAME = "NotoColorEmoji_WindowsCompatible.ttf"
|
|
33
|
+
EMOJI_FONT_LICENSE_BASENAME = "NotoColorEmoji-OFL.txt"
|
|
34
|
+
# A red circle, not the check mark: Noto draws the check mark in grey, which would look like a monochrome font.
|
|
35
|
+
EMOJI_PROBE_TEXT = "\U0001F534"
|
|
36
|
+
EMOJI_PROBE_SIDE = 64
|
|
37
|
+
# The probe glyph takes half the image, so it fits whole whatever the ascent of the font.
|
|
38
|
+
EMOJI_PROBE_PIXEL_SIZE = EMOJI_PROBE_SIDE // 2
|
|
39
|
+
# The centre of the Noto red circle is #f44336, a red lead of 177 over green and blue; a monochrome glyph has a black
|
|
40
|
+
# or white centre, a lead of 0.
|
|
41
|
+
EMOJI_PROBE_RED_MARGIN = 96
|
|
42
|
+
# Draws, on Windows, the keyboard symbols the bundled font lacks: the mathematical letter, the arrow and the combining
|
|
43
|
+
# marks. This family ships only with Windows.
|
|
44
|
+
WINDOWS_SYMBOL_FALLBACK_FAMILIES = ("Segoe UI Symbol",)
|
|
45
|
+
# Draws, on Linux, the same symbols. Without a family named here, fontconfig picked one among the installed fonts, and a
|
|
46
|
+
# CJK font could take a combining mark and give it the advance of a whole character, so the button elided its sample.
|
|
47
|
+
# Symbola comes first: Debian's desktop task recommends it, it gives every combining mark a zero advance, and it is the
|
|
48
|
+
# font the offscreen tests already get. FreeSans and DejaVu Math TeX Gyre follow because each of them also covers all
|
|
49
|
+
# the combining marks, so the row is never split between two fonts. No CJK font is listed.
|
|
50
|
+
LINUX_SYMBOL_FALLBACK_FAMILIES = ("Symbola", "FreeSans", "DejaVu Math TeX Gyre")
|
|
51
|
+
# The colour emoji font every macOS ships with.
|
|
52
|
+
APPLE_COLOR_EMOJI_FAMILY = "Apple Color Emoji"
|
|
53
|
+
FAILED_FONT_ID = -1
|
|
54
|
+
FIRST_FAMILY_INDEX = 0
|
|
55
|
+
# A symbol drawn by one font alone gives one glyph run, the only one read.
|
|
56
|
+
ONLY_GLYPH_RUN_INDEX = 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class EmojiFontStatus:
|
|
61
|
+
"""Outcome of loading the colour emoji font: the colour family, or an empty family and why the system fonts are used.
|
|
62
|
+
|
|
63
|
+
The font is usable only when `family` is not empty. `font_id` is the Qt id of the registration of the bundled font,
|
|
64
|
+
kept even when the font was removed again, and -1 when Qt refused the bytes or no file was registered. `warning` is
|
|
65
|
+
meant for the --log file.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
family: str
|
|
69
|
+
warning: str
|
|
70
|
+
font_id: int = FAILED_FONT_ID
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class EmojiFontChoice:
|
|
75
|
+
"""How one system draws colour emoji: with the bundled Noto file or with a colour font of its own.
|
|
76
|
+
|
|
77
|
+
`loads_bundled_font` says whether the bundled file is read and registered; `system_color_family` names the colour
|
|
78
|
+
family the system already has, empty when the bundled font is used; `symbol_fallback_families` follow the colour
|
|
79
|
+
family for the keyboard symbols it lacks.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
loads_bundled_font: bool
|
|
83
|
+
system_color_family: str
|
|
84
|
+
symbol_fallback_families: tuple[str, ...]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Windows and Linux both load the bundled Noto file, each with the fallback families its own fonts provide.
|
|
88
|
+
# macOS differs: CoreText does not draw the bundled Noto file in colour, it hands every emoji to Apple Color Emoji
|
|
89
|
+
# instead, and Apple Color Emoji is the colour font of the system. Reading the 10.7 MB file there would only end in a
|
|
90
|
+
# refusal and a warning in the --log file.
|
|
91
|
+
EMOJI_FONT_CHOICE_BY_PLATFORM = {
|
|
92
|
+
WINDOWS_PLATFORM: EmojiFontChoice(loads_bundled_font=True, system_color_family="", symbol_fallback_families=WINDOWS_SYMBOL_FALLBACK_FAMILIES),
|
|
93
|
+
LINUX_PLATFORM: EmojiFontChoice(loads_bundled_font=True, system_color_family="", symbol_fallback_families=LINUX_SYMBOL_FALLBACK_FAMILIES),
|
|
94
|
+
MACOS_PLATFORM: EmojiFontChoice(loads_bundled_font=False, system_color_family=APPLE_COLOR_EMOJI_FAMILY, symbol_fallback_families=()),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def emoji_font_choice(platform: str) -> EmojiFontChoice:
|
|
99
|
+
"""Return how this system draws colour emoji, read from the table of the systems GitPane runs on.
|
|
100
|
+
|
|
101
|
+
The system is a parameter, so the choice of each system can be checked from any other. An unknown system raises
|
|
102
|
+
OSError rather than borrowing the choice of another one.
|
|
103
|
+
"""
|
|
104
|
+
choice = EMOJI_FONT_CHOICE_BY_PLATFORM.get(platform)
|
|
105
|
+
if choice is None:
|
|
106
|
+
raise OSError("No emoji font is chosen for the system " + platform)
|
|
107
|
+
return choice
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def read_packaged_font() -> bytes:
|
|
111
|
+
"""Return the bytes of the bundled emoji font, read from the installed package and never from a user supplied path.
|
|
112
|
+
|
|
113
|
+
A missing or unreadable file raises OSError; the caller that registers the font decides how to fall back.
|
|
114
|
+
"""
|
|
115
|
+
return files(UI_RESOURCE_PACKAGE_NAME).joinpath(UI_RESOURCE_DIR_NAME, EMOJI_FONT_BASENAME).read_bytes()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register_emoji_font(content: bytes) -> EmojiFontStatus:
|
|
119
|
+
"""Register the bundled font bytes with Qt and keep them only if they give a family that draws in colour here.
|
|
120
|
+
|
|
121
|
+
The upstream family name is tried first; when it does not draw in colour, a copy renamed in memory is tried in its
|
|
122
|
+
place. A font refused after registration is removed from the Qt font database again, so no half working family is
|
|
123
|
+
left for Qt to pick as a fallback. Nothing is shown on screen: the warning goes back to the caller.
|
|
124
|
+
"""
|
|
125
|
+
# Qt fills its font database with the system fonts at the first family lookup. A font registered before that wins
|
|
126
|
+
# its family name over a system font of the same name, but loses it again as soon as Qt fills or refills the
|
|
127
|
+
# database, so the colour check would judge a font Qt no longer draws. Listing the families first makes the check
|
|
128
|
+
# judge the database Qt keeps; in the running application it is already filled and this costs only the list.
|
|
129
|
+
QFontDatabase.families()
|
|
130
|
+
status = register_first_family(content)
|
|
131
|
+
if not status.family or draws_in_color(status.family):
|
|
132
|
+
return status
|
|
133
|
+
QFontDatabase.removeApplicationFont(status.font_id)
|
|
134
|
+
refusal = refused_color_warning(status.family)
|
|
135
|
+
# Workaround: a system can install its own font under the same family name, one this Qt draws blank (Fedora ships a
|
|
136
|
+
# COLRv1 Noto Color Emoji that Qt 6.11 draws empty), and Qt then answers the upstream name with the system font.
|
|
137
|
+
# Qt's name with the foundry ("Noto Color Emoji [GOOG]") does reach the bundled font when asked directly, but Qt's
|
|
138
|
+
# emoji fallback, which draws an emoji the first family of a font lacks, matches the family name only and drops the
|
|
139
|
+
# foundry, so menu icons and the commit message still got the blank system font. A copy under a name of its own has
|
|
140
|
+
# no rival; it is only built here, so systems where the upstream name draws in colour keep that name.
|
|
141
|
+
try:
|
|
142
|
+
renamed_content = font_with_family_name(content, RENAMED_EMOJI_FONT_FAMILY)
|
|
143
|
+
except ValueError as error:
|
|
144
|
+
return EmojiFontStatus("", refusal + " Its renamed copy could not be built: " + str(error), status.font_id)
|
|
145
|
+
renamed = register_first_family(renamed_content)
|
|
146
|
+
if not renamed.family or draws_in_color(renamed.family):
|
|
147
|
+
return renamed
|
|
148
|
+
QFontDatabase.removeApplicationFont(renamed.font_id)
|
|
149
|
+
return EmojiFontStatus("", refusal, renamed.font_id)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def refused_color_warning(family: str) -> str:
|
|
153
|
+
"""Return the --log line of a bundled font family that does not draw in colour here."""
|
|
154
|
+
return f"The bundled emoji font {family} does not draw in color here; using system fonts."
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def register_first_family(content: bytes) -> EmojiFontStatus:
|
|
158
|
+
"""Register font bytes with Qt and return their first family, without judging how it draws.
|
|
159
|
+
|
|
160
|
+
Bytes Qt refuses, or that give no family name, are not kept: the status then has an empty family and the reason.
|
|
161
|
+
"""
|
|
162
|
+
font_id = QFontDatabase.addApplicationFontFromData(content)
|
|
163
|
+
if font_id == FAILED_FONT_ID:
|
|
164
|
+
return EmojiFontStatus("", "Qt refused the bundled emoji font; using system fonts.", font_id)
|
|
165
|
+
families = QFontDatabase.applicationFontFamilies(font_id)
|
|
166
|
+
if not families:
|
|
167
|
+
QFontDatabase.removeApplicationFont(font_id)
|
|
168
|
+
return EmojiFontStatus("", "The bundled emoji font has no family name; using system fonts.", font_id)
|
|
169
|
+
return EmojiFontStatus(families[FIRST_FAMILY_INDEX], "", font_id)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def emoji_font_status(choice: EmojiFontChoice) -> EmojiFontStatus:
|
|
173
|
+
"""Load the colour emoji font a system uses and check that it draws in colour, falling back to the system fonts if not.
|
|
174
|
+
|
|
175
|
+
The bundled font is read from the package and registered with Qt; a colour family of the system is only probed, no
|
|
176
|
+
file is read. The warning of a refusal is returned for the --log file, nothing is shown on screen.
|
|
177
|
+
"""
|
|
178
|
+
if not choice.loads_bundled_font:
|
|
179
|
+
family = choice.system_color_family
|
|
180
|
+
if draws_in_color(family):
|
|
181
|
+
return EmojiFontStatus(family, "")
|
|
182
|
+
return EmojiFontStatus("", f"The system emoji font {family} does not draw in color here; using system fonts.")
|
|
183
|
+
try:
|
|
184
|
+
content = read_packaged_font()
|
|
185
|
+
except OSError as error:
|
|
186
|
+
return EmojiFontStatus("", f"Cannot read the bundled emoji font; using system fonts: {error}")
|
|
187
|
+
return register_emoji_font(content)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@functools.cache
|
|
191
|
+
def load_emoji_font() -> EmojiFontStatus:
|
|
192
|
+
"""Load and check the colour emoji font of the running system once per process, the first time a symbol area needs it.
|
|
193
|
+
|
|
194
|
+
The bundled file weighs about 10.7 MB; reading, registering and probing it took about 30 ms on Windows 10, so it
|
|
195
|
+
is done once and never at import or application start. The emoji window controller writes the warning to the --log file.
|
|
196
|
+
"""
|
|
197
|
+
return emoji_font_status(emoji_font_choice(sys.platform))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def draws_in_color(family: str) -> bool:
|
|
201
|
+
"""Tell whether this family draws the red probe emoji in colour on this system.
|
|
202
|
+
|
|
203
|
+
The probe must be laid out as one glyph run of this very family, so a fallback font cannot answer in its place; it is
|
|
204
|
+
then drawn glyph by glyph (plain text drawing would let Qt substitute another emoji font) and the pixel at the
|
|
205
|
+
centre of the glyph must be clearly red.
|
|
206
|
+
"""
|
|
207
|
+
font = QFont(family)
|
|
208
|
+
font.setPixelSize(EMOJI_PROBE_PIXEL_SIZE)
|
|
209
|
+
layout = QTextLayout(EMOJI_PROBE_TEXT, font)
|
|
210
|
+
layout.beginLayout()
|
|
211
|
+
layout.createLine()
|
|
212
|
+
layout.endLayout()
|
|
213
|
+
runs = layout.glyphRuns()
|
|
214
|
+
if len(runs) != 1:
|
|
215
|
+
return False
|
|
216
|
+
run = runs[ONLY_GLYPH_RUN_INDEX]
|
|
217
|
+
if run.rawFont().familyName() != family:
|
|
218
|
+
return False
|
|
219
|
+
image = QImage(EMOJI_PROBE_SIDE, EMOJI_PROBE_SIDE, QImage.Format.Format_ARGB32)
|
|
220
|
+
image.fill(Qt.GlobalColor.white)
|
|
221
|
+
painter = QPainter(image)
|
|
222
|
+
painter.drawGlyphRun(QPointF(0, 0), run)
|
|
223
|
+
painter.end()
|
|
224
|
+
centre = run.boundingRect().center().toPoint()
|
|
225
|
+
if not image.rect().contains(centre):
|
|
226
|
+
return False
|
|
227
|
+
colour = image.pixelColor(centre)
|
|
228
|
+
return colour.red() - max(colour.green(), colour.blue()) >= EMOJI_PROBE_RED_MARGIN
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def emoji_font_families(status: EmojiFontStatus, platform: str = sys.platform) -> list[str]:
|
|
232
|
+
"""Return the families of the symbol font, in the order Qt tries them.
|
|
233
|
+
|
|
234
|
+
The system family comes first, otherwise digits, spaces and '#' would be drawn by the emoji font, which has them
|
|
235
|
+
too. The colour family follows when it loaded, then the families this system uses for the symbols it lacks.
|
|
236
|
+
"""
|
|
237
|
+
# The system font can be named by a generic family ("Sans Serif"), and Qt skips a generic name at the head of
|
|
238
|
+
# setFamilies: plain text then fell to the next family, the emoji or a symbol font with wide spaces. The family Qt
|
|
239
|
+
# resolves for the system font is the one it really draws the interface with.
|
|
240
|
+
families = [QFontInfo(QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont)).family()]
|
|
241
|
+
if status.family:
|
|
242
|
+
families.append(status.family)
|
|
243
|
+
families.extend(emoji_font_choice(platform).symbol_fallback_families)
|
|
244
|
+
return families
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def emoji_capable_font(point_size: float | None = None) -> QFont:
|
|
248
|
+
"""Return the font of the emoji keyboard symbols and of the commit message, loading the colour font on first use.
|
|
249
|
+
|
|
250
|
+
An empty QFont only sets the families, so the widget keeps inheriting everything else, including the size of its
|
|
251
|
+
window; a size is set only when one is given.
|
|
252
|
+
"""
|
|
253
|
+
font = QFont()
|
|
254
|
+
font.setFamilies(emoji_font_families(load_emoji_font()))
|
|
255
|
+
if point_size is not None:
|
|
256
|
+
font.setPointSizeF(point_size)
|
|
257
|
+
return font
|