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
File without changes
@@ -0,0 +1,106 @@
1
+ """Show that GitPane is still working while it waits on something outside of it, such as the user's browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer
8
+ from PySide6.QtGui import QColor, QPainter, QPalette, QPen
9
+ from PySide6.QtWidgets import QSizePolicy, QWidget
10
+
11
+ from gitpane.config import QT_ANGLE_UNITS_PER_DEGREE
12
+
13
+ if TYPE_CHECKING:
14
+ from PySide6.QtGui import QPaintEvent
15
+
16
+ # One step of the turning arc: a smooth turn without keeping the event loop busy.
17
+ FRAME_INTERVAL_MS = 50
18
+ ANGLE_STEP_DEGREES = 30
19
+ FULL_TURN_DEGREES = 360
20
+ # Length of the drawn arc, and its line width as a share of the indicator's side.
21
+ ARC_SPAN_DEGREES = 270
22
+ LINE_WIDTH_RATIO = 0.14
23
+ MINIMUM_LINE_WIDTH = 1.5
24
+ # Divides a length in two: half the line width keeps the arc inside the square, half the free space centres it.
25
+ HALVING_DIVISOR = 2
26
+
27
+
28
+ def paint_busy_arc(painter: QPainter, square: QRectF, angle: int, color: QColor) -> None:
29
+ """Draw the turning arc at its angle inside the given square, its line as thick as a share of the square's side.
30
+
31
+ The busy indicator and the "checking" rows of the recent list both draw it, so the arc and its look are defined once.
32
+ The painter's state is left as it was.
33
+ """
34
+ side = min(square.width(), square.height())
35
+ line_width = max(MINIMUM_LINE_WIDTH, side * LINE_WIDTH_RATIO)
36
+ inset = line_width / HALVING_DIVISOR
37
+ pen = QPen(color)
38
+ pen.setWidthF(line_width)
39
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
40
+ painter.save()
41
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
42
+ painter.setPen(pen)
43
+ painter.drawArc(square.adjusted(inset, inset, -inset, -inset), -angle * QT_ANGLE_UNITS_PER_DEGREE, ARC_SPAN_DEGREES * QT_ANGLE_UNITS_PER_DEGREE)
44
+ painter.restore()
45
+
46
+
47
+ def next_busy_angle(angle: int) -> int:
48
+ """Return the arc's angle one step further clockwise."""
49
+ return (angle + ANGLE_STEP_DEGREES) % FULL_TURN_DEGREES
50
+
51
+
52
+ class BusyIndicator(QWidget):
53
+ """A small turning arc, the size of a line of text and in the theme's text colour.
54
+
55
+ It is hidden while stopped, and its timer only runs while it turns, so a stopped indicator costs nothing.
56
+ """
57
+
58
+ def __init__(self, parent: QWidget | None = None) -> None:
59
+ """Build a stopped, hidden indicator."""
60
+ super().__init__(parent)
61
+ self.angle = 0
62
+ self.timer = QTimer(self)
63
+ self.timer.setInterval(FRAME_INTERVAL_MS)
64
+ self.timer.timeout.connect(self._advance)
65
+ self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
66
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
67
+ self.hide()
68
+
69
+ def start(self) -> None:
70
+ """Show the indicator and start turning it."""
71
+ self.show()
72
+ self.timer.start()
73
+
74
+ def stop(self) -> None:
75
+ """Stop turning and hide the indicator."""
76
+ self.timer.stop()
77
+ self.hide()
78
+
79
+ def sizeHint(self) -> QSize:
80
+ """Take the height of a line of the current font, so the indicator sits beside its text at any scale."""
81
+ side = self.fontMetrics().height()
82
+ return QSize(side, side)
83
+
84
+ def minimumSizeHint(self) -> QSize:
85
+ """Never shrink below the size of a line of text."""
86
+ return self.sizeHint()
87
+
88
+ def changeEvent(self, event: QEvent) -> None:
89
+ """Follow a new font size, and repaint in the new text colour when the theme changes."""
90
+ super().changeEvent(event)
91
+ if event.type() == QEvent.Type.FontChange:
92
+ self.updateGeometry()
93
+ self.update()
94
+
95
+ def paintEvent(self, event: QPaintEvent) -> None:
96
+ """Draw the arc at its current angle, inside a square centred in the widget."""
97
+ side = min(self.width(), self.height())
98
+ square = QRectF((self.width() - side) / HALVING_DIVISOR, (self.height() - side) / HALVING_DIVISOR, side, side)
99
+ painter = QPainter(self)
100
+ paint_busy_arc(painter, square, self.angle, self.palette().color(QPalette.ColorRole.WindowText))
101
+ painter.end()
102
+
103
+ def _advance(self) -> None:
104
+ """Turn the arc one step clockwise."""
105
+ self.angle = next_busy_angle(self.angle)
106
+ self.update()
@@ -0,0 +1,55 @@
1
+ """Write the dates of a commit the way the history rows show them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, tzinfo
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from gitpane.history.repository_value import Commit
10
+
11
+
12
+ MINUTE_SECONDS, HOUR_SECONDS, DAY_SECONDS = 60, 3600, 86400
13
+ MONTH_SECONDS, YEAR_SECONDS = 30 * DAY_SECONDS, 365 * DAY_SECONDS
14
+
15
+
16
+ def format_relative_date(timestamp: int, now: datetime) -> str:
17
+ """Keep elapsed time compact for history rows and share it with commit details."""
18
+ difference = int(now.timestamp() - timestamp)
19
+ duration = abs(difference)
20
+ if duration < MINUTE_SECONDS:
21
+ return "just now" if difference >= 0 else "in a few seconds"
22
+ if duration >= MONTH_SECONDS:
23
+ divisor, unit = (YEAR_SECONDS, "year") if duration >= YEAR_SECONDS else (MONTH_SECONDS, "month")
24
+ whole_days = duration // DAY_SECONDS
25
+ days_per_unit = divisor // DAY_SECONDS
26
+ count = round(whole_days / days_per_unit)
27
+ unit += "s" if count != 1 else ""
28
+ return f"{count} {unit} ago" if difference >= 0 else f"in {count} {unit}"
29
+ divisor, unit = (DAY_SECONDS, "d") if duration >= DAY_SECONDS else (HOUR_SECONDS, "h") if duration >= HOUR_SECONDS else (MINUTE_SECONDS, "min")
30
+ return f"{duration // divisor} {unit} ago" if difference >= 0 else f"in {duration // divisor} {unit}"
31
+
32
+
33
+ DATE_UNAVAILABLE_TEXT = "Date unavailable"
34
+
35
+
36
+ def format_commit_time(commit: Commit, timestamp: int, now: datetime, *, relative_only: bool = False) -> str:
37
+ """Write one of the dates of a commit, or say it is unavailable when Git gave no readable time or no details for it."""
38
+ if commit.time_unavailable or commit.detail_error:
39
+ return DATE_UNAVAILABLE_TEXT
40
+ return format_commit_date(timestamp, now, relative_only=relative_only)
41
+
42
+
43
+ def format_commit_date(timestamp: int, now: datetime, timezone: tzinfo | None = None, *, relative_only: bool = False) -> str:
44
+ """Validate a date and retain its absolute form for details and table tooltips."""
45
+ try:
46
+ # Windows cannot reliably convert naive local dates near the Unix epoch; start with an aware UTC date.
47
+ date = datetime.fromtimestamp(timestamp, UTC).astimezone(timezone)
48
+ except (ValueError, OverflowError, OSError):
49
+ return "Date unavailable (timestamp out of range)"
50
+ relative = format_relative_date(timestamp, now)
51
+ if relative_only:
52
+ return relative
53
+ offset = date.strftime("%z")
54
+ offset_hour_end = 3
55
+ return f"{relative} ({date:%Y-%m-%d %H:%M:%S} {offset[:offset_hour_end]}:{offset[offset_hour_end:]})"
@@ -0,0 +1,262 @@
1
+ """Follow a window's content with its height after the window opened, never below the height it opened at or the user set.
2
+
3
+ The window opens at a starting height, fitted once to the rows it starts with after its first show is laid out. It then
4
+ grows each time its content needs more height than the window gives it (a long path, a message, a failure written after
5
+ the window opened would otherwise be cut under its bottom edge), and takes back a smaller height when that content goes,
6
+ never below its reference height: the height it opened at once fitted, then the last height the user set by hand. Its
7
+ whole frame stays inside the usable area of its screen and never grows past three quarters of its usable height, the
8
+ content scrolling beyond that. Where the compositor keeps the top it placed the window at (Wayland), the window does not
9
+ grow past the room under that top either, estimated when Qt sees neither the desktop's panels nor the title bar. Where
10
+ the compositor answers a window's show with a size of its own (Wayland), the opening fit and each change of size wait
11
+ for that answer, which would otherwise overwrite them for good.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from enum import Enum
17
+ from typing import TYPE_CHECKING
18
+
19
+ from PySide6.QtCore import QCoreApplication, QEvent, Qt, QTimer
20
+ from shiboken6 import isValid
21
+
22
+ from gitpane.config import fit_window, window_placement
23
+ from gitpane.ui.widget.dialog_layout import capped_growth_height, keep_frame_inside_usable_area, resize_window_height_on_screen, window_height_on_screen
24
+ from gitpane.ui.window_control.compositor_answer import after_compositor_answer
25
+
26
+ if TYPE_CHECKING:
27
+ from PySide6.QtCore import QObject
28
+ from PySide6.QtGui import QResizeEvent, QShowEvent
29
+
30
+ from gitpane.ui.widget.content_measure import ContentMeasure
31
+
32
+
33
+ class OpeningFit(Enum):
34
+ """How a window fits its height, once, to the rows it opens with."""
35
+
36
+ GROW_ONLY = "grow-only"
37
+ EXACT = "exact"
38
+
39
+ def missing_height(self, measure: ContentMeasure) -> int:
40
+ """Return the height the window lacks to show its starting rows whole, negative when it has height to give back.
41
+
42
+ ``GROW_ONLY`` keeps the opening height as a floor and grows only by what the content hides: a form that opens with
43
+ room under its rows keeps that room for a message that comes later. For a form that scrolls, what is hidden is the
44
+ vertical scroll bar's range, what such a form has always grown by, to the pixel, at every text size. ``EXACT``
45
+ takes the exact height the content needs at the width it has, so a window opened taller than its rows gets
46
+ smaller, and one opened shorter grows until nothing is hidden.
47
+ """
48
+ if self is OpeningFit.GROW_ONLY:
49
+ return measure.hidden_height()
50
+ return measure.missing_height()
51
+
52
+
53
+ class ContentGrowthMixin:
54
+ """Height rule of a window whose content changes after it opened, mixed in front of the window's ``QDialog`` class.
55
+
56
+ A mixin rather than an object the window owns, so the state of the rule stays on the window, where the window's own
57
+ code, its tests and the window gallery read it (``starting_height``, ``first_show_fitted``, ``reference_height``,
58
+ ``growth_measure``) without a relay. The window gives the rule the measure of its content, by its scroll area or by
59
+ its own layout, when it calls ``install_content_growth`` once that content is in place; each layout request of the
60
+ content then measures what it needs and gives the window the height the rule aims at.
61
+ """
62
+
63
+ def install_content_growth(self, measure: ContentMeasure, opening_fit: OpeningFit, starting_height: int) -> None:
64
+ """Start following the content ``measure`` measures, the window opening at ``starting_height`` then fitted as ``opening_fit`` says.
65
+
66
+ ``starting_height`` only stands for the opening height until the first show: the opening fit then replaces it
67
+ with the height the window really has, which is smaller than the given one when the window was reduced to fit a
68
+ small screen, so a test or the window gallery that compares the window with it never reads a height it never had.
69
+ """
70
+ self.growth_measure = measure
71
+ self.opening_fit = opening_fit
72
+ # The height the form opened with: the given one until the opening fit, then the window's height once that fit is made.
73
+ self.starting_height = starting_height
74
+ # The client height the window had when the window system placed it, which bounds a growth below the top the
75
+ # compositor keeps: the given starting height until the opening fit reads the height the window really has.
76
+ self.placed_height = starting_height
77
+ self.starting_height_fitted = False
78
+ # True only while the first show waits for the compositor's answer: where nothing waits, the fit is asked in that call.
79
+ self.showing_first_time = False
80
+ # True once the first show has fitted the form to its starting rows, when they needed it.
81
+ self.first_show_fitted = False
82
+ # The height content that leaves brings the window back to: the opening height once fitted, then the last height
83
+ # the user set by hand. The window height its content needed when that reference was taken tells a content that
84
+ # asks for no more than then (the reference stays, even under its content) from one that asks for more.
85
+ self.reference_height = starting_height
86
+ self.reference_needed_height = starting_height
87
+ # Sizes the application gave the window since the reference was taken (the rule's resizes, Qt growing the window
88
+ # to its layout's minimum, and where the compositor answers activations, the reference itself): a resize from the
89
+ # window system to one of them is the window system repeating a size it was given, not the user.
90
+ self.application_given_size_set: set[tuple[int, int]] = set()
91
+ # The window's size its last resize event brought: a look that finds the window at another size is made while
92
+ # that resize is still being laid out, before its event reaches the window, and comes from the resize rather
93
+ # than from a change of content.
94
+ self.resized_size: tuple[int, int] | None = None
95
+ # The window's height when the rule last looked at it, or when the user last resized it: a window found taller at
96
+ # the next look was grown by the application, mostly by Qt to its layout's new minimum, keeping its top where it
97
+ # was, and its frame is brought back inside the usable area as for a growth of the rule.
98
+ self.looked_height = starting_height
99
+ # True while the rule itself resizes the window: the looks that resize causes are not a change of content.
100
+ self.content_growing = False
101
+ measure.watched_widget().installEventFilter(self)
102
+ range_changed = measure.range_changed()
103
+ if range_changed is not None:
104
+ range_changed.connect(self._follow_content)
105
+
106
+ def showEvent(self, event: QShowEvent) -> None:
107
+ """Fit the form to its starting rows once its first show is laid out, after the compositor's answer where that answer would overwrite the size.
108
+
109
+ Under Wayland the compositor answers the first show with the size it knew then, and Qt applies it: a height asked
110
+ before that answer would be lost for good. The wait starts here, at the show, so an activation the window gets
111
+ before the next turn of the event loop is seen and the fit still waits for the configure that follows it.
112
+ """
113
+ super().showEvent(event)
114
+ if self.starting_height_fitted:
115
+ return
116
+ self.starting_height_fitted = True
117
+ self.showing_first_time = True
118
+ try:
119
+ after_compositor_answer(self, self._fit_starting_height)
120
+ finally:
121
+ self.showing_first_time = False
122
+
123
+ def _fit_starting_height(self) -> None:
124
+ """Fit the form to its starting rows: a turn after the show where nothing waits, at once when the compositor's answer comes."""
125
+ if self.showing_first_time:
126
+ # The rows only take their final heights once the layout requests posted by the show are handled.
127
+ QTimer.singleShot(0, self, self._fit_starting_height_now)
128
+ return
129
+ self._fit_starting_height_now()
130
+
131
+ def _fit_starting_height_now(self) -> None:
132
+ """Fit the form once by the height its starting rows lack, or have in excess for the exact fit, so it opens without scrolling.
133
+
134
+ The starting height shows the starting rows at the text size it was chosen for, so nothing changes there. A larger
135
+ system font (Ubuntu's 11 pt) makes the same rows taller: the form then grows by the height they lack. The window
136
+ is checked again because the wait for the compositor's answer can end, at its bound, on a window hidden meanwhile.
137
+ Like any growth of the rule, the fit stops at the growth cap, three quarters of the usable height, the rows
138
+ beyond it scrolling. Where the compositor keeps the window's top, the window's height at this point is the one the
139
+ compositor placed it with, since the fit waits for its answer: the fit and every later growth then also stop at
140
+ the room under that top, estimated when Qt sees neither panel nor title bar. The fitted height becomes the
141
+ reference content that leaves brings the window back to.
142
+ """
143
+ if not isValid(self) or not self.isVisible():
144
+ return
145
+ # Where the compositor places the window, this is the size it placed, since the opening fit waits for its answer;
146
+ # elsewhere the rule does not use it.
147
+ self.placed_height = self.height()
148
+ self.first_show_fitted = True
149
+ missing_height = self.opening_fit.missing_height(self.growth_measure)
150
+ if missing_height != 0:
151
+ self.content_growing = True
152
+ try:
153
+ self.resize(self.width(), capped_growth_height(self, self.height() + missing_height, placed_height=self.placed_height))
154
+ fit_window(self, 0, 0)
155
+ finally:
156
+ self.content_growing = False
157
+ self.starting_height = self.height()
158
+ self._take_reference()
159
+
160
+ def resizeEvent(self, event: QResizeEvent) -> None:
161
+ """Take a resize made through the window system as the user's height, the one content that leaves brings the window back to.
162
+
163
+ A resize the window system reports is the user's: those of the rule, of Qt growing the window to its layout's
164
+ minimum and of the fit to the screen are asked by the application, and never spontaneous. A spontaneous resize
165
+ to a size the application gave since the reference was taken is the window system repeating it, not the user:
166
+ under Wayland, Mutter answers the activation of a window (a form closing over it) with a configure that carries
167
+ the size it knew before the window's last change, and Qt applies it. That older size can be another one than the
168
+ content calls for now, so the rule looks at the content again, at the next turn of the event loop, and gives the
169
+ window its height back. A maximised or full-screen window keeps the reference it had before, which restoring it
170
+ brings back.
171
+ """
172
+ super().resizeEvent(event)
173
+ self.resized_size = (self.width(), self.height())
174
+ if event.spontaneous():
175
+ self.looked_height = self.height()
176
+ if not event.spontaneous() or self.content_growing or not self.first_show_fitted or self._maximized_or_full_screen():
177
+ return
178
+ if (event.size().width(), event.size().height()) in self.application_given_size_set:
179
+ # Posted rather than done here: the rule resizes the window, which is better not done inside its resize event.
180
+ QCoreApplication.postEvent(self.growth_measure.watched_widget(), QEvent(QEvent.Type.LayoutRequest))
181
+ return
182
+ self._take_reference()
183
+
184
+ def _take_reference(self) -> None:
185
+ """Make the window's current height the reference, with the height its content needs now at this width.
186
+
187
+ Where the compositor answers an activation with a size it knew before (Wayland), the reference size counts as
188
+ one the application gave: Mutter brings it back on its own after the content grew the window, which is not the
189
+ user. Elsewhere only the user brings it back, by hand. Under Wayland a user who drags the window to exactly that
190
+ height while the content needs more sees the content's height come back: the two cannot be told apart.
191
+ """
192
+ self.reference_height = self.height()
193
+ self.looked_height = self.height()
194
+ self.reference_needed_height = self.height() + self.growth_measure.missing_height()
195
+ self.application_given_size_set = set()
196
+ if window_placement().size_waits_for_compositor_answer:
197
+ self.application_given_size_set.add((self.width(), self.height()))
198
+
199
+ def _follow_content(self, *_arguments: object) -> None:
200
+ """Ask for the height the content now calls for, once the compositor has answered the show where that answer would overwrite it.
201
+
202
+ A look made while a resize of the window is still being laid out comes from that resize, not from new content: the
203
+ window lays out its children, and the scroll area updates its scroll bar, before the resize event reaches the
204
+ window, so a user's resize would otherwise be undone before the window could take it as the user's height.
205
+ """
206
+ if not isValid(self) or self.content_growing or not self.isVisible() or not self.first_show_fitted or self._maximized_or_full_screen():
207
+ return
208
+ if (self.width(), self.height()) != self.resized_size:
209
+ return
210
+ after_compositor_answer(self, self._follow_content_now)
211
+
212
+ def _follow_content_now(self) -> None:
213
+ """Give the window the height its content calls for now: what it needs, never below the reference height.
214
+
215
+ The content's need is measured when the change is made, not when it was asked. While it asks for no more than
216
+ when the reference was taken, the window takes the reference height, even one the user set under its content;
217
+ otherwise it takes the larger of the reference and the need. Each look computes the target again: a second look
218
+ at the same content (the late layout pass of a row that has a layout of its own, a scroll bar that shows) gives
219
+ the same target or what is still missing, and a target already reached changes nothing. The flag that keeps the
220
+ rule's own resize from counting as a change of content is set here, in the change the compositor's answer may
221
+ defer, never around the request.
222
+
223
+ A window whose layout sets its minimum size is grown by Qt itself, keeping its top, as soon as the new content
224
+ shows, before the rule looks: the target is then already reached, and the frame, which can now end under the
225
+ usable area, comes back up into it the way the rule's own growth does, and its size counts as one the application
226
+ gave, so the window system repeating it is not taken for the user's. A height the user set through the window
227
+ system is never taken for such a growth, and where the compositor places windows nothing moves.
228
+ """
229
+ if not isValid(self) or not self.isVisible() or self._maximized_or_full_screen():
230
+ return
231
+ grown_since_last_look = self.height() > self.looked_height
232
+ needed_height = self.height() + self.growth_measure.missing_height()
233
+ target_height = self.reference_height if needed_height <= self.reference_needed_height else max(self.reference_height, needed_height)
234
+ if grown_since_last_look:
235
+ self.application_given_size_set.add((self.width(), self.height()))
236
+ if window_height_on_screen(self, target_height, placed_height=self.placed_height) == self.height():
237
+ if grown_since_last_look:
238
+ keep_frame_inside_usable_area(self)
239
+ self.looked_height = self.height()
240
+ return
241
+ self.content_growing = True
242
+ try:
243
+ resize_window_height_on_screen(self, target_height, placed_height=self.placed_height)
244
+ finally:
245
+ self.content_growing = False
246
+ self.looked_height = self.height()
247
+ self.application_given_size_set.add((self.width(), self.height()))
248
+
249
+ def _maximized_or_full_screen(self) -> bool:
250
+ """Tell whether the window is maximised or full screen, where the rule leaves its size to the window system."""
251
+ state = self.windowState()
252
+ return bool(state & Qt.WindowState.WindowMaximized) or bool(state & Qt.WindowState.WindowFullScreen)
253
+
254
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
255
+ """Look at the content's layout requests, which follow each change of its texts, and follow the content with the window's height."""
256
+ if event.type() != QEvent.Type.LayoutRequest:
257
+ return super().eventFilter(watched, event)
258
+ watched_widget = self.growth_measure.watched_widget()
259
+ if watched_widget is None or not isValid(watched_widget) or watched is not watched_widget:
260
+ return super().eventFilter(watched, event)
261
+ self._follow_content()
262
+ return False
@@ -0,0 +1,99 @@
1
+ """Measure the height a window's content lacks, on its scroll area or on its own layout.
2
+
3
+ A window whose height follows its content hands the growth rule one of these measures: the content of a form that
4
+ scrolls is the scroll area's widget, measured at the width the viewport gives it; the content of a form without a
5
+ scroll area is the window's own layout, measured at the window's width, with each text that scrolls inside it counted at
6
+ its whole height. Each measure also names the widget whose layout requests tell that the content changed, and the
7
+ signal, if any, that tells an overflow changed without one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from gitpane.ui.widget.dialog_layout import scrolled_content_missing_height
15
+ from gitpane.ui.widget.scrolled_text import ScrolledText
16
+
17
+ if TYPE_CHECKING:
18
+ from PySide6.QtCore import SignalInstance
19
+ from PySide6.QtWidgets import QScrollArea, QWidget
20
+
21
+
22
+ def layout_minimum_height(window: QWidget) -> int:
23
+ """Return the smallest height the window's layout needs at the window's current width, wrapped labels included.
24
+
25
+ The layout is activated first, so a widget just shown or hidden counts. The minimum is measured and never the
26
+ preferred height: a table that stretches prefers more rows than it needs, and a window sized by that preference
27
+ would grow again at each look.
28
+ """
29
+ layout = window.layout()
30
+ layout.activate()
31
+ if layout.hasHeightForWidth():
32
+ return layout.totalMinimumHeightForWidth(window.width())
33
+ return layout.totalMinimumSize().height()
34
+
35
+
36
+ class ScrolledContentMeasure:
37
+ """Measure the content of a window whose form scrolls: the scroll area's widget, at the width its viewport gives it."""
38
+
39
+ def __init__(self, scroll_area: QScrollArea) -> None:
40
+ """Measure the content of ``scroll_area``, which already holds it."""
41
+ self.scroll_area = scroll_area
42
+
43
+ def watched_widget(self) -> QWidget:
44
+ """Return the scrolled content, whose layout requests follow each change of its texts."""
45
+ return self.scroll_area.widget()
46
+
47
+ def missing_height(self) -> int:
48
+ """Return the height the viewport lacks to show the whole content without scrolling, negative when it has room."""
49
+ return scrolled_content_missing_height(self.scroll_area)
50
+
51
+ def hidden_height(self) -> int:
52
+ """Return the height the vertical scroll bar hides, zero when nothing scrolls."""
53
+ return max(0, self.scroll_area.verticalScrollBar().maximum())
54
+
55
+ def range_changed(self) -> SignalInstance | None:
56
+ """Return the scroll bar's range signal, which tells an overflow changed even without a layout request of the content."""
57
+ return self.scroll_area.verticalScrollBar().rangeChanged
58
+
59
+
60
+ def scrolled_text_extra_height(window: QWidget) -> int:
61
+ """Return the height the window's shown scrolled texts need beyond what its layout counts for them.
62
+
63
+ The layout counts such a text at most at a few lines, so a long output never makes Qt grow the window past the
64
+ screen; the window's content still needs the whole text, which the growth rule gives it up to its cap.
65
+ """
66
+ extra_height = 0
67
+ for scrolled_text in window.findChildren(ScrolledText):
68
+ if scrolled_text.window() is not window or not scrolled_text.isVisibleTo(window):
69
+ continue
70
+ extra_height += max(0, scrolled_text.needed_height() - scrolled_text.minimumSizeHint().height())
71
+ return extra_height
72
+
73
+
74
+ class LayoutContentMeasure:
75
+ """Measure the content of a window without a scroll area for its whole form: its own layout at the window's width, each scrolled text at its whole height."""
76
+
77
+ def __init__(self, window: QWidget) -> None:
78
+ """Measure the layout of ``window``, which already has it."""
79
+ self.window = window
80
+
81
+ def watched_widget(self) -> QWidget:
82
+ """Return the window itself, whose layout requests follow each change of its rows."""
83
+ return self.window
84
+
85
+ def missing_height(self) -> int:
86
+ """Return the height the window lacks for its layout's minimum at its width and its scrolled texts' whole height, negative when it has room."""
87
+ return layout_minimum_height(self.window) + scrolled_text_extra_height(self.window) - self.window.height()
88
+
89
+ def hidden_height(self) -> int:
90
+ """Return the height the window lacks for its whole content, zero when it has room: what its layout lacks is cut, what its scrolled texts lack scrolls."""
91
+ return max(0, self.missing_height())
92
+
93
+ def range_changed(self) -> SignalInstance | None:
94
+ """Return nothing: without a scroll area, the window's layout requests are the only sign that its content changed."""
95
+ return None
96
+
97
+
98
+ ContentMeasure = ScrolledContentMeasure | LayoutContentMeasure
99
+ """The measure a window gives the growth rule: by its scroll area, or by its own layout."""