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