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,317 @@
1
+ """The style GitPane draws with: Fusion, whose dark theme greys are decided at the moment something is drawn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QPointF, QSize, Qt
9
+ from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPalette, QPen, QPixmap
10
+ from PySide6.QtWidgets import QAbstractButton, QApplication, QComboBox, QFrame, QHeaderView, QProxyStyle, QScrollBar, QStyle, QStyleFactory, QStyleOptionFrame, QTabBar
11
+
12
+ from gitpane.config import FUSION_STYLE_NAME, LEGACY_WINDOWS_STYLE_NAME, MACOS_PLATFORM_NAME, WINDOWS_PLATFORM_NAME
13
+ from gitpane.ui.look.color_contrast import contrast_ratio, relative_luminance, shown_on_background
14
+ from gitpane.ui.look.theme_color import is_dark_palette, theme_control_color, theme_line_color, toward_text
15
+ from gitpane.ui.look.theme_palette import PALETTE_COLOR_GROUPS
16
+ from gitpane.ui.look.warning_icon import readable_warning_icon
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterator
20
+
21
+ from PySide6.QtWidgets import QStyleOption, QStyleOptionComplex, QWidget
22
+
23
+ SEPARATOR_SHAPE_SET = {QFrame.Shape.HLine, QFrame.Shape.VLine}
24
+ # The controls whose face Fusion paints with the palette's button color and that the user saw lighter on the dark
25
+ # captures. A tab widget's pane, sliders and the empty bands of the commit box also read that color and are not listed:
26
+ # they keep Fusion's look.
27
+ CONTROL_CLASSES = (QAbstractButton, QComboBox, QTabBar, QHeaderView, QScrollBar)
28
+ # Geometry of Fusion's own check box square and radio button circle, so the added contour lies exactly on Fusion's.
29
+ # The square is drawn one pixel smaller than its rectangle, shifted half a pixel so its one-pixel lines fall on whole pixels.
30
+ CHECK_BOX_CONTOUR_SHRINK = 1
31
+ HALF_PIXEL = 0.5
32
+ # The circle's center is one pixel right of and below the rectangle's center; its radius comes from the rectangle's
33
+ # width made odd, halved, less one pixel.
34
+ RADIO_CENTER_SHIFT = 1
35
+ RADIO_WIDTH_PARITY = 2
36
+ RADIO_RADIUS_DIVISOR = 2.0
37
+ RADIO_RADIUS_INSET = 1
38
+ # State a check box or radio button is in while it has the keyboard focus: Fusion frames it in the selection color then.
39
+ KEYBOARD_FOCUS_STATE = QStyle.StateFlag.State_HasFocus | QStyle.StateFlag.State_KeyboardFocusChange
40
+
41
+ # Contrast readability guidelines ask of a graphical element that is not text: an icon with no pixel reaching it is redrawn.
42
+ MINIMUM_ICON_CONTRAST = 3.0
43
+ # Contrast a disabled control's greyed text must keep against both the control grey and the line grey for the control to
44
+ # get the lighter face: the face Fusion draws from the control grey it is given lies between these two greys. A
45
+ # half-transparent greyed text, as macOS gives, lightens with the face and keeps its distance from it; an opaque grey, as
46
+ # GTK themes give, is caught up by the face and almost vanishes, and so does an opaque grey about as dark as the control
47
+ # grey, as KDE's Breeze gives. The recorded macOS dark palette gives 1.53 and the recorded Windows dark palette 1.50;
48
+ # the recorded GTK dark themes give 1.16 to 1.22, and Breeze dark 1.04 against its control grey.
49
+ DISABLED_TEXT_MINIMUM_CONTRAST = 1.35
50
+ OPAQUE_ALPHA = 255
51
+ # The text color is taken the whole way from the window color, as the text shows on the window, its transparency included.
52
+ SHOWN_TEXT_RATIO = 1.0
53
+ # Question and warning icons are made readable at the size a question shows them and at twice that size, for high-density screens,
54
+ # when the system icon does not say which sizes it has.
55
+ HIGH_DENSITY_ICON_SCALE = 2
56
+ # Device pixel ratio an icon image is read at: the sizes asked for are already in device pixels, one per image pixel, so
57
+ # Qt must not scale them again for the screen's density.
58
+ UNSCALED_DEVICE_PIXEL_RATIO = 1.0
59
+
60
+
61
+ class GitPaneFusionStyle(QProxyStyle):
62
+ """Workaround: Fusion with GitPane's dark-theme greys, decided each time something is drawn, never set in a palette.
63
+
64
+ Under macOS, setting any role of the application palette makes Qt drop the palettes macOS gives each kind of control,
65
+ which turns the text of buttons black in inactive windows. The dark greys are therefore never set in a palette: the
66
+ style decides them when it draws. In a dark palette:
67
+
68
+ - buttons, drop-down lists, tabs, column headers and scroll bars are drawn with a lighter control grey, laid on the
69
+ drawing's own option for that drawing only, so their text keeps the colors the system gives it; a disabled one
70
+ keeps Fusion's face when the system's greyed text would fade on the lighter one;
71
+ - check boxes and radio buttons get a contour in the theme's line grey, which Fusion would draw too dark to be seen
72
+ on a dark field, unless they have the keyboard focus, whose frame Fusion draws itself;
73
+ - a separator line (horizontal or vertical, sunken or plain) is one line of the line grey instead of Fusion's pair of
74
+ a near-white and a dark line.
75
+
76
+ The question icon of a dialog is also redrawn in the text color in a dark palette when none of its pixels stands out
77
+ from the window: macOS draws it in black at a low opacity, the same in both appearances. The warning icon is the icon
78
+ theme's symbolic shape painted in the warning color when the theme has no colored one, as GNOME's Adwaita.
79
+
80
+ A light palette is drawn by Fusion unchanged. The greys come from the application palette, not from the widget's
81
+ one: the palettes macOS gives column headers and lists carry an opaque white text that would give other greys.
82
+ """
83
+
84
+ def __init__(self) -> None:
85
+ """Wrap a fresh Fusion style and keep its name, which the rest of the application compares against."""
86
+ super().__init__(QStyleFactory.create(FUSION_STYLE_NAME))
87
+ self.setObjectName(self.baseStyle().objectName())
88
+
89
+ def drawPrimitive(self, element: QStyle.PrimitiveElement, option: QStyleOption, painter: QPainter, widget: QWidget | None = None) -> None:
90
+ """Draw a primitive as Fusion does, with the control grey on a dark control, then the contour of a check box or radio button."""
91
+ with lighter_control(option, widget):
92
+ super().drawPrimitive(element, option, painter, widget)
93
+ if not is_dark_palette(option.palette) or option.state & KEYBOARD_FOCUS_STATE == KEYBOARD_FOCUS_STATE:
94
+ return
95
+ if element == QStyle.PrimitiveElement.PE_IndicatorCheckBox:
96
+ draw_check_box_contour(option, painter)
97
+ elif element == QStyle.PrimitiveElement.PE_IndicatorRadioButton:
98
+ draw_radio_button_contour(option, painter)
99
+
100
+ def drawControl(self, element: QStyle.ControlElement, option: QStyleOption, painter: QPainter, widget: QWidget | None = None) -> None:
101
+ """Draw a separator as one grey line in a dark theme, and every other control as Fusion does, lighter when it is a dark control."""
102
+ if element == QStyle.ControlElement.CE_ShapedFrame and is_separator_option(option) and is_dark_palette(option.palette):
103
+ draw_separator_line(option, painter)
104
+ return
105
+ with lighter_control(option, widget):
106
+ super().drawControl(element, option, painter, widget)
107
+
108
+ def drawComplexControl(self, control: QStyle.ComplexControl, option: QStyleOptionComplex, painter: QPainter, widget: QWidget | None = None) -> None:
109
+ """Draw scroll bars, drop-down lists and tool buttons as Fusion does, with the control grey in a dark theme."""
110
+ with lighter_control(option, widget):
111
+ super().drawComplexControl(control, option, painter, widget)
112
+
113
+ def standardIcon(self, standard_icon: QStyle.StandardPixmap, option: QStyleOption | None = None, widget: QWidget | None = None) -> QIcon:
114
+ """Answer Fusion's icon, except the question and the warning icons, which are made readable on the palette they are shown on.
115
+
116
+ A question icon on a dark palette is redrawn in the text color when it is unreadable there; each size the icon has
117
+ is checked on its own, and an icon that already stands out from the window is kept as it is. A warning icon is the
118
+ icon theme's symbolic shape painted in the warning color when the theme has no colored warning icon, Fusion's
119
+ otherwise. The error and information icons stay Fusion's. The palette is the widget's when there is one, else the
120
+ application's.
121
+ """
122
+ icon = super().standardIcon(standard_icon, option, widget)
123
+ if standard_icon == QStyle.StandardPixmap.SP_MessageBoxWarning:
124
+ return readable_warning_icon(icon, self._icon_palette(widget), self._icon_sizes(icon, option, widget))
125
+ if standard_icon != QStyle.StandardPixmap.SP_MessageBoxQuestion:
126
+ return icon
127
+ palette = self._icon_palette(widget)
128
+ if not is_dark_palette(palette):
129
+ return icon
130
+ background = palette.color(QPalette.ColorRole.Window)
131
+ text_color = toward_text(palette, SHOWN_TEXT_RATIO)
132
+ readable_icon = QIcon()
133
+ for size in self._icon_sizes(icon, option, widget):
134
+ image = icon.pixmap(size, UNSCALED_DEVICE_PIXEL_RATIO).toImage()
135
+ readable_icon.addPixmap(QPixmap.fromImage(readable_icon_image(image, background, text_color)))
136
+ return readable_icon
137
+
138
+ def _icon_palette(self, widget: QWidget | None) -> QPalette:
139
+ """Return the palette an icon is shown on: the widget's when there is one, else the application's."""
140
+ return widget.palette() if widget is not None else QApplication.palette()
141
+
142
+ def _icon_sizes(self, icon: QIcon, option: QStyleOption | None, widget: QWidget | None) -> list[QSize]:
143
+ """Return the sizes an icon has, or the size a dialog shows it at and twice that for high-density screens when it does not say."""
144
+ sizes = icon.availableSizes()
145
+ if sizes:
146
+ return sizes
147
+ side = self.pixelMetric(QStyle.PixelMetric.PM_MessageBoxIconSize, option, widget)
148
+ return [QSize(side, side), QSize(side * HIGH_DENSITY_ICON_SCALE, side * HIGH_DENSITY_ICON_SCALE)]
149
+
150
+
151
+ @contextmanager
152
+ def lighter_control(option: QStyleOption, widget: QWidget | None) -> Iterator[None]:
153
+ """Lay the control grey on a dark control's option for one drawing, then give the option its own palette back.
154
+
155
+ The option Qt hands over is changed in place rather than copied: Python only knows the base type of some options,
156
+ such as the header option Qt builds for each column section, and a copy of that base type loses the fields Qt adds,
157
+ which Fusion would then read from memory nobody set. Any other widget, and any control in a light theme, is drawn
158
+ with its option untouched. A disabled control whose greyed text would fade on the lighter face gets the window color
159
+ instead: Fusion then draws a face just lighter than the window, on which that text keeps the color the system gives
160
+ it and still reads. The GTK themes give their disabled controls the window color already, so their look does not
161
+ change; KDE's Breeze gives them a lighter grey, on which its dark greyed text would almost vanish once Fusion lightens it.
162
+ """
163
+ if not isinstance(widget, CONTROL_CLASSES) or not is_dark_palette(option.palette):
164
+ yield
165
+ return
166
+ application_palette = QApplication.palette()
167
+ face_color = theme_control_color(application_palette)
168
+ if not option.state & QStyle.StateFlag.State_Enabled and disabled_text_fades_on_lighter_face(application_palette):
169
+ face_color = application_palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window)
170
+ original_palette = QPalette(option.palette)
171
+ palette = QPalette(original_palette)
172
+ for group in PALETTE_COLOR_GROUPS:
173
+ palette.setColor(group, QPalette.ColorRole.Button, face_color)
174
+ option.palette = palette
175
+ try:
176
+ yield
177
+ finally:
178
+ option.palette = original_palette
179
+
180
+
181
+ def disabled_text_fades_on_lighter_face(palette: QPalette) -> bool:
182
+ """Tell whether a disabled control's greyed text would fade on the lighter control face of a dark theme.
183
+
184
+ The face Fusion draws lies between the control grey and the line grey. The text is composed on each of them,
185
+ transparency included, and compared with it; the lower contrast decides, so that a greyed text lighter than the
186
+ face (GTK themes) and one about as dark as the control grey (Breeze) are both caught. The decision reads the palette
187
+ only, so it is the same for every control of a theme and never depends on which system draws it.
188
+
189
+ The palette given is the application palette, and the decision reads its disabled ``ButtonText``, while Fusion draws
190
+ the text with the class palette of the control. It therefore assumes that the control classes the style lightens
191
+ (buttons, combo boxes, tabs, headers, scroll bars) have the same disabled text as the application. That holds on
192
+ every recorded palette, macOS, Windows, Nordic, Yaru-dark, Adwaita and Breeze: macOS gives menus another disabled
193
+ text, but menus are not lightened.
194
+ """
195
+ text = palette.color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText)
196
+ for face in (theme_control_color(palette), theme_line_color(palette)):
197
+ if contrast_ratio(shown_on_background(text, face), face) < DISABLED_TEXT_MINIMUM_CONTRAST:
198
+ return True
199
+ return False
200
+
201
+
202
+ def is_separator_option(option: QStyleOption) -> bool:
203
+ """Tell whether a frame being drawn is a horizontal or vertical separator line rather than a box."""
204
+ return isinstance(option, QStyleOptionFrame) and option.frameShape in SEPARATOR_SHAPE_SET
205
+
206
+
207
+ def contour_pen() -> QPen:
208
+ """Answer the pen of a contour: the theme's line grey of the application palette, one pixel wide."""
209
+ return QPen(theme_line_color(QApplication.palette()))
210
+
211
+
212
+ def draw_check_box_contour(option: QStyleOption, painter: QPainter) -> None:
213
+ """Draw a one-pixel square in the line grey over the border Fusion just gave a check box, leaving its inside as it is."""
214
+ painter.save()
215
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
216
+ painter.translate(HALF_PIXEL, HALF_PIXEL)
217
+ painter.setPen(contour_pen())
218
+ painter.setBrush(Qt.BrushStyle.NoBrush)
219
+ painter.drawRect(option.rect.adjusted(0, 0, -CHECK_BOX_CONTOUR_SHRINK, -CHECK_BOX_CONTOUR_SHRINK))
220
+ painter.restore()
221
+
222
+
223
+ def draw_radio_button_contour(option: QStyleOption, painter: QPainter) -> None:
224
+ """Draw a one-pixel circle in the line grey over the outline Fusion just gave a radio button, leaving its inside as it is."""
225
+ rect = option.rect
226
+ center = QPointF(rect.center()) + QPointF(RADIO_CENTER_SHIFT, RADIO_CENTER_SHIFT)
227
+ radius = (rect.width() + (rect.width() + 1) % RADIO_WIDTH_PARITY) / RADIO_RADIUS_DIVISOR - RADIO_RADIUS_INSET
228
+ painter.save()
229
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
230
+ painter.setPen(contour_pen())
231
+ painter.setBrush(Qt.BrushStyle.NoBrush)
232
+ painter.drawEllipse(center, radius, radius)
233
+ painter.restore()
234
+
235
+
236
+ def draw_separator_line(option: QStyleOptionFrame, painter: QPainter) -> None:
237
+ """Draw a separator as a single one-pixel line through the middle of its rectangle, in the theme's line grey.
238
+
239
+ The grey comes from the application palette, the same for every window, so a separator matches the other lines.
240
+ An empty rectangle draws nothing.
241
+ """
242
+ rect = option.rect
243
+ if rect.isEmpty():
244
+ return
245
+ painter.save()
246
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
247
+ painter.setPen(QPen(theme_line_color(QApplication.palette())))
248
+ if option.frameShape == QFrame.Shape.HLine:
249
+ painter.drawLine(rect.left(), rect.center().y(), rect.right(), rect.center().y())
250
+ else:
251
+ painter.drawLine(rect.center().x(), rect.top(), rect.center().x(), rect.bottom())
252
+ painter.restore()
253
+
254
+
255
+ def readable_icon_image(image: QImage, background: QColor, text_color: QColor) -> QImage:
256
+ """Return an icon image that stands out from the background it is shown on.
257
+
258
+ macOS draws its question icon as a black glyph at a low opacity, the same in both appearances, almost invisible on a
259
+ dark window. When no pixel of the image reaches the contrast asked of an icon on that background, the pixels darker
260
+ than the background are redrawn in the text color, their opacity raised so the strongest becomes opaque; the other
261
+ pixels, such as highlights, are kept. An image that already stands out comes back as a copy, unchanged.
262
+ """
263
+ source = image.convertToFormat(QImage.Format.Format_ARGB32)
264
+ background_luminance = relative_luminance(background)
265
+ strongest_dark_alpha = 0
266
+ for y in range(source.height()):
267
+ for x in range(source.width()):
268
+ color = source.pixelColor(x, y)
269
+ if not color.alpha():
270
+ continue
271
+ if contrast_ratio(shown_on_background(color, background), background) >= MINIMUM_ICON_CONTRAST:
272
+ return QImage(image)
273
+ if relative_luminance(color) < background_luminance:
274
+ strongest_dark_alpha = max(strongest_dark_alpha, color.alpha())
275
+ if not strongest_dark_alpha:
276
+ return QImage(image)
277
+ readable = QImage(source)
278
+ for y in range(source.height()):
279
+ for x in range(source.width()):
280
+ color = source.pixelColor(x, y)
281
+ if not color.alpha() or relative_luminance(color) >= background_luminance:
282
+ continue
283
+ redrawn = QColor(text_color)
284
+ redrawn.setAlphaF(text_color.alphaF() * min(OPAQUE_ALPHA, color.alpha() * OPAQUE_ALPHA / strongest_dark_alpha) / OPAQUE_ALPHA)
285
+ readable.setPixelColor(x, y, redrawn)
286
+ readable.setDevicePixelRatio(image.devicePixelRatio())
287
+ return readable
288
+
289
+
290
+ def system_style_name(platform_name: str, current_style_name: str) -> str:
291
+ """Name the style GitPane puts in place of Qt's default on a Qt platform, or "" to keep the default.
292
+
293
+ Fusion gives the same control sizes and the same form layout on every system, whereas Qt's own macOS style makes a
294
+ button taller than the field beside it and centres forms. On Windows only the legacy default style is replaced: it
295
+ forces light colours whatever the system palette, while Fusion follows it and keeps selected controls readable.
296
+ """
297
+ if platform_name == MACOS_PLATFORM_NAME:
298
+ return FUSION_STYLE_NAME
299
+ if platform_name == WINDOWS_PLATFORM_NAME and current_style_name.casefold() == LEGACY_WINDOWS_STYLE_NAME:
300
+ return FUSION_STYLE_NAME
301
+ return ""
302
+
303
+
304
+ def configure_system_style(application: QApplication, platform_name: str = "") -> None:
305
+ """Put the style GitPane runs on in place of Qt's default, at start-up and in the test session.
306
+
307
+ Whenever that style is Fusion, it is GitPane's Fusion, which adds the greys of a dark theme: on macOS, on Windows in
308
+ place of the legacy style, and offscreen or on Linux where Qt already starts with Fusion. A style that is not Fusion
309
+ is left as it is. The platform name comes from Qt unless given; tests give it to check every platform from any
310
+ system. A second call changes nothing.
311
+ """
312
+ current_style = application.style()
313
+ current_style_name = current_style.objectName()
314
+ style_name = system_style_name(platform_name or application.platformName(), current_style_name) or current_style_name
315
+ if style_name.casefold() != FUSION_STYLE_NAME.casefold() or isinstance(current_style, GitPaneFusionStyle):
316
+ return
317
+ application.setStyle(GitPaneFusionStyle())
@@ -0,0 +1,53 @@
1
+ """Render large fallback initials without letting font margins enlarge the author avatar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtGui import QFontMetricsF, QPainter, QPalette
9
+ from PySide6.QtWidgets import QLabel
10
+
11
+ from gitpane.config import AVATAR_INITIALS_FILL_RATIO
12
+
13
+ if TYPE_CHECKING:
14
+ from PySide6.QtGui import QPaintEvent
15
+
16
+
17
+ class AvatarLabel(QLabel):
18
+ """Keep native photo display while fitting fallback glyphs inside a centered square."""
19
+
20
+ def paintEvent(self, event: QPaintEvent) -> None:
21
+ """Size initials by their visible strokes so accents and wide letters remain unclipped."""
22
+ text = self.text()
23
+ if not text or not self.pixmap().isNull():
24
+ super().paintEvent(event)
25
+ return
26
+ rectangle = self.contentsRect()
27
+ font = self.font()
28
+ font.setWeight(font.Weight.DemiBold)
29
+ reference_size = max(1, min(rectangle.width(), rectangle.height()))
30
+ font.setPixelSize(reference_size)
31
+ bounds = QFontMetricsF(font, self).tightBoundingRect(text)
32
+ if bounds.isEmpty():
33
+ return
34
+ target_width = rectangle.width() * AVATAR_INITIALS_FILL_RATIO
35
+ target_height = rectangle.height() * AVATAR_INITIALS_FILL_RATIO
36
+ lower_size = 1
37
+ upper_size = max(1, math.ceil(reference_size * max(target_width / bounds.width(), target_height / bounds.height())))
38
+ fitted_size = lower_size
39
+ while lower_size <= upper_size:
40
+ candidate_size = (lower_size + upper_size) // 2
41
+ font.setPixelSize(candidate_size)
42
+ bounds = QFontMetricsF(font, self).tightBoundingRect(text)
43
+ if bounds.width() <= target_width and bounds.height() <= target_height:
44
+ fitted_size = candidate_size
45
+ lower_size = candidate_size + 1
46
+ else:
47
+ upper_size = candidate_size - 1
48
+ font.setPixelSize(fitted_size)
49
+ bounds = QFontMetricsF(font, self).tightBoundingRect(text)
50
+ painter = QPainter(self)
51
+ painter.setFont(font)
52
+ painter.setPen(self.palette().color(QPalette.ColorRole.WindowText))
53
+ painter.drawText(rectangle.toRectF().center() - bounds.center(), text)
@@ -0,0 +1,43 @@
1
+ """Measure how far apart two colours look, the way readability guidelines compute it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtGui import QColor
6
+
7
+ # Relative luminance of a color as readability guidelines define it: each sRGB channel is made linear (a straight
8
+ # division below a small threshold, a power curve above it), then the channels are weighted by how bright the eye sees them.
9
+ SRGB_LINEAR_THRESHOLD = 0.03928
10
+ SRGB_LINEAR_DIVISOR = 12.92
11
+ SRGB_CURVE_OFFSET = 0.055
12
+ SRGB_CURVE_SCALE = 1.055
13
+ SRGB_CURVE_EXPONENT = 2.4
14
+ RED_LUMINANCE_WEIGHT = 0.2126
15
+ GREEN_LUMINANCE_WEIGHT = 0.7152
16
+ BLUE_LUMINANCE_WEIGHT = 0.0722
17
+ # Light added to both luminances of a contrast ratio, for the glare of a real screen; it keeps black on black at 1:1.
18
+ CONTRAST_FLARE = 0.05
19
+
20
+
21
+ def relative_luminance(color: QColor) -> float:
22
+ """Return how bright a color looks, from 0 for black to 1 for white, the way readability guidelines measure it."""
23
+ channels: list[float] = []
24
+ for value in (color.redF(), color.greenF(), color.blueF()):
25
+ if value <= SRGB_LINEAR_THRESHOLD:
26
+ channels.append(value / SRGB_LINEAR_DIVISOR)
27
+ else:
28
+ channels.append(((value + SRGB_CURVE_OFFSET) / SRGB_CURVE_SCALE) ** SRGB_CURVE_EXPONENT)
29
+ red, green, blue = channels
30
+ return RED_LUMINANCE_WEIGHT * red + GREEN_LUMINANCE_WEIGHT * green + BLUE_LUMINANCE_WEIGHT * blue
31
+
32
+
33
+ def contrast_ratio(first: QColor, second: QColor) -> float:
34
+ """Compare two colors the way readability guidelines do: 1 for identical colors, 21 for black on white."""
35
+ lighter, darker = sorted((relative_luminance(first), relative_luminance(second)), reverse=True)
36
+ return (lighter + CONTRAST_FLARE) / (darker + CONTRAST_FLARE)
37
+
38
+
39
+ def shown_on_background(color: QColor, background: QColor) -> QColor:
40
+ """Return the opaque color a possibly transparent pixel shows once drawn on an opaque background."""
41
+ alpha = color.alphaF()
42
+ return QColor.fromRgbF(color.redF() * alpha + background.redF() * (1 - alpha), color.greenF() * alpha + background.greenF() * (1 - alpha),
43
+ color.blueF() * alpha + background.blueF() * (1 - alpha))
@@ -0,0 +1,118 @@
1
+ """Keep the palette of Qt's GTK 3 theme on the light or dark appearance the desktop portal declares.
2
+
3
+ Under GNOME, Settings > Appearance only changes the portal's ``color-scheme`` preference; GTK 3 does not follow it, and
4
+ Qt's GTK theme takes its palette from GTK, so GitPane's windows kept their light content under a dark title bar. The
5
+ follower reads the preference once before the first window, then follows each change, and passes it on to GTK for
6
+ this process through ``GtkSettingsLibrary``. The windows then follow the new palette through the application's usual
7
+ palette followers, like on any desktop whose theme changes.
8
+
9
+ When the portal declares no preference (GNOME's "Default", a portal missing or too slow at start), GTK keeps the dark
10
+ preference it read from its own configuration: nothing is changed at start, and a later change to no preference gives
11
+ GTK back the value it had before GitPane changed it.
12
+
13
+ A GTK theme without a dark variant stays light: GitPane then shows what GTK can do, like a GTK 3 application. With
14
+ ``GTK_THEME`` set, the user picked the theme and its variant for the process, and GTK ignores the preference: nothing
15
+ is followed then.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import TYPE_CHECKING
22
+
23
+ from PySide6.QtCore import QObject, Qt, Slot
24
+ from PySide6.QtDBus import QDBusConnection
25
+ from PySide6.QtGui import QGuiApplication
26
+
27
+ from gitpane.config import PORTAL_SETTING_READ_TIMEOUT_MS, WAYLAND_PLATFORM_NAME, X11_PLATFORM_NAME
28
+ from gitpane.system.appearance_portal import PortalColorScheme, PortalColorSchemeWatcher, read_portal_color_scheme
29
+ from gitpane.system.xdg_session import desktop_names
30
+ from gitpane.ui.look.gtk_settings import GtkSettingsLibrary
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable, Mapping
34
+
35
+ from PySide6.QtWidgets import QApplication
36
+
37
+ GTK_THEME_ENVIRONMENT_NAME = "GTK_THEME"
38
+ # The Qt platforms on which Qt's GTK 3 theme can be the platform theme.
39
+ GTK_THEME_PLATFORM_NAME_SET = frozenset({WAYLAND_PLATFORM_NAME, X11_PLATFORM_NAME})
40
+ # The desktops, as lowercased XDG_CURRENT_DESKTOP names, on which passing the portal's preference to GTK was checked to
41
+ # give the theme's own dark palette. Another desktop keeps Qt's own behaviour until it is checked the same way.
42
+ PORTAL_APPEARANCE_DESKTOP_SET = frozenset({"gnome"})
43
+
44
+
45
+ def follows_portal_appearance(platform_name: str, environment_by_name: Mapping[str, str]) -> bool:
46
+ """Tell whether GitPane passes the portal's light or dark preference on to GTK in this session.
47
+
48
+ Only on a Qt platform that can load Qt's GTK 3 theme, on a desktop where the manoeuvre was checked, and when
49
+ ``GTK_THEME`` does not pin the GTK theme and its variant.
50
+ """
51
+ if platform_name not in GTK_THEME_PLATFORM_NAME_SET:
52
+ return False
53
+ if environment_by_name.get(GTK_THEME_ENVIRONMENT_NAME, ""):
54
+ return False
55
+ for name in desktop_names(environment_by_name):
56
+ if name in PORTAL_APPEARANCE_DESKTOP_SET:
57
+ return True
58
+ return False
59
+
60
+
61
+ class DesktopAppearanceFollower(QObject):
62
+ """Pass each preference the portal declares on to GTK, then make Qt's GTK theme read GTK's colours again."""
63
+
64
+ def __init__(self, gtk_settings: GtkSettingsLibrary, watcher: PortalColorSchemeWatcher, parent: QObject | None = None) -> None:
65
+ """Keep the GTK settings and own the watcher, whose changes are applied inside its own signal."""
66
+ super().__init__(parent)
67
+ self.gtk_settings = gtk_settings
68
+ # Read before any change, so this is the dark preference GTK took from its own configuration: the one a later
69
+ # "no preference" of the portal gives back.
70
+ self.gtk_prefers_dark = gtk_settings.prefers_dark()
71
+ self.watcher = watcher
72
+ watcher.setParent(self)
73
+ watcher.changed.connect(self.apply, Qt.ConnectionType.DirectConnection)
74
+
75
+ @Slot(object)
76
+ def apply(self, scheme: PortalColorScheme) -> None:
77
+ """Ask GTK for the variant ``scheme`` names, then set its theme name again so Qt rebuilds its palette from it.
78
+
79
+ No preference names GTK's own dark preference, read when the follower was made.
80
+
81
+ Workaround, tied to two details of Qt 6.11: Qt's GTK theme rebuilds its palette only when GTK's theme name
82
+ changes, and QtDBus delivers the portal's change to GitPane's subscription, made after Qt's own, before Qt's
83
+ listeners. Applied at once, inside the D-Bus delivery and without a timer, GTK already has the new colours when
84
+ Qt's listeners react. Deferred, Qt first rebuilds its palette with GTK's old colours, after which the theme
85
+ name set again no longer rebuilds it, and the window keeps the preference of one appearance with the palette of
86
+ the other.
87
+ """
88
+ prefers_dark = self.gtk_prefers_dark if scheme == PortalColorScheme.NO_PREFERENCE else scheme.prefers_dark
89
+ self.gtk_settings.set_prefers_dark(prefers_dark)
90
+ self.gtk_settings.reload_theme()
91
+
92
+
93
+ def install_desktop_appearance_follower(application: QApplication, platform_name: str = "", environment_by_name: Mapping[str, str] = os.environ,
94
+ connect: Callable[[], QDBusConnection] = QDBusConnection.sessionBus,
95
+ load_gtk_settings: Callable[[], GtkSettingsLibrary | None] = GtkSettingsLibrary.loaded) -> DesktopAppearanceFollower | None:
96
+ """Pass the portal's preference on to GTK before the first window, and keep doing it at each change, when this session needs it.
97
+
98
+ Called once the application exists, so GTK is loaded and Qt's own portal listeners are subscribed before
99
+ GitPane's. Answers nothing, without reading or loading anything, when the session does not follow the portal this
100
+ way or when GTK is not in the process. ``platform_name`` is the Qt platform, read from the application when not
101
+ given; ``connect`` opens the bus the portal is on and ``load_gtk_settings`` finds GTK's settings: tests give a
102
+ private bus and a stand-in for GTK. Calling it again answers the follower already installed. A portal that gives no
103
+ preference at start, or does not answer, changes nothing in GTK.
104
+ """
105
+ if not follows_portal_appearance(platform_name or QGuiApplication.platformName(), environment_by_name):
106
+ return None
107
+ for child in application.children():
108
+ if isinstance(child, DesktopAppearanceFollower):
109
+ return child
110
+ gtk_settings = load_gtk_settings()
111
+ if gtk_settings is None:
112
+ return None
113
+ connection = connect()
114
+ follower = DesktopAppearanceFollower(gtk_settings, PortalColorSchemeWatcher(lambda: connection), application)
115
+ scheme = read_portal_color_scheme(connection, PORTAL_SETTING_READ_TIMEOUT_MS)
116
+ if scheme != PortalColorScheme.NO_PREFERENCE:
117
+ follower.apply(scheme)
118
+ return follower
@@ -0,0 +1,21 @@
1
+ """Give monospace text the size of the application's body text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtGui import QFont, QFontDatabase
6
+ from PySide6.QtWidgets import QApplication
7
+
8
+
9
+ def body_sized_fixed_font() -> QFont:
10
+ """Return the system's monospace font at the size of the application font, so an output or a hash reads like the text around it.
11
+
12
+ The system's monospace font has a size of its own, often smaller than the body text. An application font set in
13
+ pixels has no point size (Qt answers -1 and ignores that value), so its pixel size is given instead.
14
+ """
15
+ body_font = QApplication.font()
16
+ fixed_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
17
+ if body_font.pointSizeF() > 0:
18
+ fixed_font.setPointSizeF(body_font.pointSizeF())
19
+ else:
20
+ fixed_font.setPixelSize(body_font.pixelSize())
21
+ return fixed_font