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.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. 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