monocoat 1.2.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 (416) hide show
  1. monocoat/__init__.py +9 -0
  2. monocoat/__main__.py +4 -0
  3. monocoat/acceptance/__init__.py +21 -0
  4. monocoat/acceptance/__main__.py +51 -0
  5. monocoat/acceptance/gtk3_review.py +204 -0
  6. monocoat/acceptance/known_failures.py +81 -0
  7. monocoat/acceptance/palettes.py +154 -0
  8. monocoat/acceptance/preset_switch.py +634 -0
  9. monocoat/acceptance/report.py +86 -0
  10. monocoat/acceptance/safety.py +121 -0
  11. monocoat/acceptance/shots.py +156 -0
  12. monocoat/acceptance/surfaces.py +668 -0
  13. monocoat/acceptance/world.py +392 -0
  14. monocoat/appchoices.py +171 -0
  15. monocoat/appids.py +127 -0
  16. monocoat/appscan.py +524 -0
  17. monocoat/appsedit.py +150 -0
  18. monocoat/backdrop.py +3400 -0
  19. monocoat/backup.py +1250 -0
  20. monocoat/bundleprepare.py +90 -0
  21. monocoat/bundles.py +554 -0
  22. monocoat/capabilities.py +253 -0
  23. monocoat/change.py +706 -0
  24. monocoat/cli.py +2381 -0
  25. monocoat/commands/__init__.py +0 -0
  26. monocoat/commands/backup.py +145 -0
  27. monocoat/commands/daynight.py +79 -0
  28. monocoat/commands/demo.py +159 -0
  29. monocoat/commands/import_mydockfinder.py +392 -0
  30. monocoat/commands/init.py +389 -0
  31. monocoat/commands/migrate.py +569 -0
  32. monocoat/commands/perf.py +110 -0
  33. monocoat/commands/update.py +30 -0
  34. monocoat/commands/wallpaper.py +380 -0
  35. monocoat/config.py +86 -0
  36. monocoat/corners.py +164 -0
  37. monocoat/cursors.py +162 -0
  38. monocoat/cursorthemes.py +175 -0
  39. monocoat/daemons.py +1805 -0
  40. monocoat/data/hackneyed/ATTRIBUTION +9 -0
  41. monocoat/data/hackneyed/LICENSE +24 -0
  42. monocoat/data/hackneyed/hotspots/X_cursor.in +1 -0
  43. monocoat/data/hackneyed/hotspots/alias.in +1 -0
  44. monocoat/data/hackneyed/hotspots/all-scroll.in +1 -0
  45. monocoat/data/hackneyed/hotspots/based_arrow_down.in +1 -0
  46. monocoat/data/hackneyed/hotspots/based_arrow_up.in +1 -0
  47. monocoat/data/hackneyed/hotspots/bottom_tee.in +1 -0
  48. monocoat/data/hackneyed/hotspots/center_ptr.in +1 -0
  49. monocoat/data/hackneyed/hotspots/closedhand.in +1 -0
  50. monocoat/data/hackneyed/hotspots/coffee_mug.in +1 -0
  51. monocoat/data/hackneyed/hotspots/color-picker.in +1 -0
  52. monocoat/data/hackneyed/hotspots/context-menu.in +1 -0
  53. monocoat/data/hackneyed/hotspots/copy.in +1 -0
  54. monocoat/data/hackneyed/hotspots/crosshair.in +1 -0
  55. monocoat/data/hackneyed/hotspots/default.in +1 -0
  56. monocoat/data/hackneyed/hotspots/dnd-move.in +1 -0
  57. monocoat/data/hackneyed/hotspots/draft.in +1 -0
  58. monocoat/data/hackneyed/hotspots/draped_box.in +1 -0
  59. monocoat/data/hackneyed/hotspots/e-resize.in +1 -0
  60. monocoat/data/hackneyed/hotspots/ew-resize.in +1 -0
  61. monocoat/data/hackneyed/hotspots/exchange.in +1 -0
  62. monocoat/data/hackneyed/hotspots/help.in +1 -0
  63. monocoat/data/hackneyed/hotspots/left_tee.in +1 -0
  64. monocoat/data/hackneyed/hotspots/ll_angle.in +1 -0
  65. monocoat/data/hackneyed/hotspots/lr_angle.in +1 -0
  66. monocoat/data/hackneyed/hotspots/move.in +1 -0
  67. monocoat/data/hackneyed/hotspots/n-resize.in +1 -0
  68. monocoat/data/hackneyed/hotspots/ne-resize.in +1 -0
  69. monocoat/data/hackneyed/hotspots/nesw-resize.in +1 -0
  70. monocoat/data/hackneyed/hotspots/no-drop.in +1 -0
  71. monocoat/data/hackneyed/hotspots/not-allowed.in +1 -0
  72. monocoat/data/hackneyed/hotspots/ns-resize.in +1 -0
  73. monocoat/data/hackneyed/hotspots/nw-resize.in +1 -0
  74. monocoat/data/hackneyed/hotspots/nwse-resize.in +1 -0
  75. monocoat/data/hackneyed/hotspots/openhand.in +1 -0
  76. monocoat/data/hackneyed/hotspots/pencil.in +1 -0
  77. monocoat/data/hackneyed/hotspots/pirate.in +1 -0
  78. monocoat/data/hackneyed/hotspots/plus.in +1 -0
  79. monocoat/data/hackneyed/hotspots/pointer.in +1 -0
  80. monocoat/data/hackneyed/hotspots/progress.in +1 -0
  81. monocoat/data/hackneyed/hotspots/right_ptr.in +1 -0
  82. monocoat/data/hackneyed/hotspots/right_tee.in +1 -0
  83. monocoat/data/hackneyed/hotspots/s-resize.in +1 -0
  84. monocoat/data/hackneyed/hotspots/sb_down_arrow.in +1 -0
  85. monocoat/data/hackneyed/hotspots/sb_left_arrow.in +1 -0
  86. monocoat/data/hackneyed/hotspots/sb_right_arrow.in +1 -0
  87. monocoat/data/hackneyed/hotspots/sb_up_arrow.in +1 -0
  88. monocoat/data/hackneyed/hotspots/se-resize.in +1 -0
  89. monocoat/data/hackneyed/hotspots/split_h.in +1 -0
  90. monocoat/data/hackneyed/hotspots/split_v.in +1 -0
  91. monocoat/data/hackneyed/hotspots/sw-resize.in +1 -0
  92. monocoat/data/hackneyed/hotspots/text.in +1 -0
  93. monocoat/data/hackneyed/hotspots/top_tee.in +1 -0
  94. monocoat/data/hackneyed/hotspots/ul_angle.in +1 -0
  95. monocoat/data/hackneyed/hotspots/ur_angle.in +1 -0
  96. monocoat/data/hackneyed/hotspots/vertical-text.in +1 -0
  97. monocoat/data/hackneyed/hotspots/w-resize.in +1 -0
  98. monocoat/data/hackneyed/hotspots/wait.in +1 -0
  99. monocoat/data/hackneyed/hotspots/wayland-cursor.in +1 -0
  100. monocoat/data/hackneyed/hotspots/zoom-in.in +1 -0
  101. monocoat/data/hackneyed/hotspots/zoom-out.in +1 -0
  102. monocoat/data/hackneyed/hotspots/zoom.in +1 -0
  103. monocoat/data/hackneyed/svg/X_cursor.svg +224 -0
  104. monocoat/data/hackneyed/svg/alias.svg +263 -0
  105. monocoat/data/hackneyed/svg/all-scroll.svg +235 -0
  106. monocoat/data/hackneyed/svg/based_arrow_down.svg +226 -0
  107. monocoat/data/hackneyed/svg/based_arrow_up.svg +226 -0
  108. monocoat/data/hackneyed/svg/bottom_tee.svg +226 -0
  109. monocoat/data/hackneyed/svg/center_ptr.svg +230 -0
  110. monocoat/data/hackneyed/svg/closedhand.svg +252 -0
  111. monocoat/data/hackneyed/svg/coffee_mug.svg +289 -0
  112. monocoat/data/hackneyed/svg/color-picker.svg +261 -0
  113. monocoat/data/hackneyed/svg/context-menu.svg +304 -0
  114. monocoat/data/hackneyed/svg/copy.svg +258 -0
  115. monocoat/data/hackneyed/svg/crosshair.svg +225 -0
  116. monocoat/data/hackneyed/svg/default.svg +252 -0
  117. monocoat/data/hackneyed/svg/dnd-move.svg +260 -0
  118. monocoat/data/hackneyed/svg/draft.svg +278 -0
  119. monocoat/data/hackneyed/svg/draped_box.svg +241 -0
  120. monocoat/data/hackneyed/svg/e-resize.svg +226 -0
  121. monocoat/data/hackneyed/svg/ew-resize.svg +226 -0
  122. monocoat/data/hackneyed/svg/exchange.svg +262 -0
  123. monocoat/data/hackneyed/svg/help.svg +292 -0
  124. monocoat/data/hackneyed/svg/left_tee.svg +226 -0
  125. monocoat/data/hackneyed/svg/ll_angle.svg +226 -0
  126. monocoat/data/hackneyed/svg/lr_angle.svg +226 -0
  127. monocoat/data/hackneyed/svg/move.svg +224 -0
  128. monocoat/data/hackneyed/svg/n-resize.svg +226 -0
  129. monocoat/data/hackneyed/svg/ne-resize.svg +206 -0
  130. monocoat/data/hackneyed/svg/nesw-resize.svg +333 -0
  131. monocoat/data/hackneyed/svg/no-drop.svg +258 -0
  132. monocoat/data/hackneyed/svg/not-allowed.svg +230 -0
  133. monocoat/data/hackneyed/svg/ns-resize.svg +226 -0
  134. monocoat/data/hackneyed/svg/nw-resize.svg +208 -0
  135. monocoat/data/hackneyed/svg/nwse-resize.svg +226 -0
  136. monocoat/data/hackneyed/svg/openhand.svg +252 -0
  137. monocoat/data/hackneyed/svg/pencil.svg +256 -0
  138. monocoat/data/hackneyed/svg/pirate.svg +252 -0
  139. monocoat/data/hackneyed/svg/plus.svg +223 -0
  140. monocoat/data/hackneyed/svg/pointer.svg +252 -0
  141. monocoat/data/hackneyed/svg/progress-1.svg +282 -0
  142. monocoat/data/hackneyed/svg/progress-10.svg +327 -0
  143. monocoat/data/hackneyed/svg/progress-11.svg +327 -0
  144. monocoat/data/hackneyed/svg/progress-12.svg +327 -0
  145. monocoat/data/hackneyed/svg/progress-13.svg +327 -0
  146. monocoat/data/hackneyed/svg/progress-14.svg +327 -0
  147. monocoat/data/hackneyed/svg/progress-15.svg +332 -0
  148. monocoat/data/hackneyed/svg/progress-16.svg +327 -0
  149. monocoat/data/hackneyed/svg/progress-2.svg +283 -0
  150. monocoat/data/hackneyed/svg/progress-3.svg +283 -0
  151. monocoat/data/hackneyed/svg/progress-4.svg +283 -0
  152. monocoat/data/hackneyed/svg/progress-5.svg +280 -0
  153. monocoat/data/hackneyed/svg/progress-6.svg +281 -0
  154. monocoat/data/hackneyed/svg/progress-7.svg +327 -0
  155. monocoat/data/hackneyed/svg/progress-8.svg +327 -0
  156. monocoat/data/hackneyed/svg/progress-9.svg +327 -0
  157. monocoat/data/hackneyed/svg/right_ptr.svg +256 -0
  158. monocoat/data/hackneyed/svg/right_tee.svg +226 -0
  159. monocoat/data/hackneyed/svg/s-resize.svg +226 -0
  160. monocoat/data/hackneyed/svg/sb_down_arrow.svg +237 -0
  161. monocoat/data/hackneyed/svg/sb_left_arrow.svg +237 -0
  162. monocoat/data/hackneyed/svg/sb_right_arrow.svg +237 -0
  163. monocoat/data/hackneyed/svg/sb_up_arrow.svg +196 -0
  164. monocoat/data/hackneyed/svg/se-resize.svg +206 -0
  165. monocoat/data/hackneyed/svg/split_h.svg +237 -0
  166. monocoat/data/hackneyed/svg/split_v.svg +230 -0
  167. monocoat/data/hackneyed/svg/sw-resize.svg +207 -0
  168. monocoat/data/hackneyed/svg/text.svg +231 -0
  169. monocoat/data/hackneyed/svg/top_tee.svg +226 -0
  170. monocoat/data/hackneyed/svg/ul_angle.svg +226 -0
  171. monocoat/data/hackneyed/svg/ur_angle.svg +226 -0
  172. monocoat/data/hackneyed/svg/vertical-text.svg +229 -0
  173. monocoat/data/hackneyed/svg/w-resize.svg +226 -0
  174. monocoat/data/hackneyed/svg/wait-1.svg +289 -0
  175. monocoat/data/hackneyed/svg/wait-10.svg +288 -0
  176. monocoat/data/hackneyed/svg/wait-11.svg +288 -0
  177. monocoat/data/hackneyed/svg/wait-12.svg +288 -0
  178. monocoat/data/hackneyed/svg/wait-13.svg +288 -0
  179. monocoat/data/hackneyed/svg/wait-14.svg +288 -0
  180. monocoat/data/hackneyed/svg/wait-15.svg +288 -0
  181. monocoat/data/hackneyed/svg/wait-16.svg +368 -0
  182. monocoat/data/hackneyed/svg/wait-2.svg +288 -0
  183. monocoat/data/hackneyed/svg/wait-3.svg +288 -0
  184. monocoat/data/hackneyed/svg/wait-4.svg +288 -0
  185. monocoat/data/hackneyed/svg/wait-5.svg +287 -0
  186. monocoat/data/hackneyed/svg/wait-6.svg +288 -0
  187. monocoat/data/hackneyed/svg/wait-7.svg +288 -0
  188. monocoat/data/hackneyed/svg/wait-8.svg +288 -0
  189. monocoat/data/hackneyed/svg/wait-9.svg +288 -0
  190. monocoat/data/hackneyed/svg/wayland-cursor.svg +244 -0
  191. monocoat/data/hackneyed/svg/zoom-in.svg +272 -0
  192. monocoat/data/hackneyed/svg/zoom-out.svg +273 -0
  193. monocoat/data/hackneyed/svg/zoom.svg +267 -0
  194. monocoat/data/hackneyed/symlinks +46 -0
  195. monocoat/data/monocoat-bar-ink.glsl +47 -0
  196. monocoat/data/monocoat-glass.glsl +106 -0
  197. monocoat/data/monocoat-liquid-glass.glsl +110 -0
  198. monocoat/data/monocoat-seamless.glsl +268 -0
  199. monocoat/data/unihan/LICENSE +39 -0
  200. monocoat/data/unihan/han-readings.txt +714 -0
  201. monocoat/daynight.py +115 -0
  202. monocoat/diff.py +271 -0
  203. monocoat/dockapps.py +542 -0
  204. monocoat/dockcatalog.py +137 -0
  205. monocoat/dockclasses.py +35 -0
  206. monocoat/dockconfig.py +1261 -0
  207. monocoat/dockd.py +473 -0
  208. monocoat/dockdraw.py +661 -0
  209. monocoat/dockfolder.py +640 -0
  210. monocoat/dockfoldergtk.py +1363 -0
  211. monocoat/dockgtk.py +5746 -0
  212. monocoat/dockhotcorners.py +811 -0
  213. monocoat/dockipc.py +114 -0
  214. monocoat/dockitems.py +550 -0
  215. monocoat/dockitemsgtk.py +58 -0
  216. monocoat/docklauncher.py +204 -0
  217. monocoat/docklaunchhelper.py +43 -0
  218. monocoat/docklaunchpadgtk.py +1520 -0
  219. monocoat/docklock.py +108 -0
  220. monocoat/dockmedia.py +746 -0
  221. monocoat/dockmediaart.py +442 -0
  222. monocoat/dockmediagtk.py +369 -0
  223. monocoat/dockminimizegtk.py +1496 -0
  224. monocoat/dockminimizex11.py +185 -0
  225. monocoat/dockmsgtips.py +589 -0
  226. monocoat/dockpinfile.py +285 -0
  227. monocoat/dockpins.py +980 -0
  228. monocoat/dockpreview.py +129 -0
  229. monocoat/dockpreviewgtk.py +2035 -0
  230. monocoat/dockpreviewlock.py +188 -0
  231. monocoat/dockpreviewx11.py +372 -0
  232. monocoat/dockscroll.py +125 -0
  233. monocoat/docktrack.py +429 -0
  234. monocoat/dockx11.py +842 -0
  235. monocoat/dockxwatch.py +79 -0
  236. monocoat/doctor.py +1986 -0
  237. monocoat/editor.py +47 -0
  238. monocoat/edittarget.py +151 -0
  239. monocoat/exit_codes.py +17 -0
  240. monocoat/fileio.py +46 -0
  241. monocoat/firefox.py +637 -0
  242. monocoat/follow.py +127 -0
  243. monocoat/frame.py +159 -0
  244. monocoat/fsbirth.py +53 -0
  245. monocoat/glass.py +586 -0
  246. monocoat/gtk3theme.py +125 -0
  247. monocoat/gui/__init__.py +45 -0
  248. monocoat/gui/app.py +56 -0
  249. monocoat/gui/apps.py +440 -0
  250. monocoat/gui/captions.py +227 -0
  251. monocoat/gui/colorpicker.py +203 -0
  252. monocoat/gui/controls.py +394 -0
  253. monocoat/gui/engine.py +435 -0
  254. monocoat/gui/firstrun.py +169 -0
  255. monocoat/gui/fontpicker.py +130 -0
  256. monocoat/gui/history.py +214 -0
  257. monocoat/gui/looks.py +411 -0
  258. monocoat/gui/m_backups.py +452 -0
  259. monocoat/gui/m_colourmatch.py +215 -0
  260. monocoat/gui/m_dock.py +916 -0
  261. monocoat/gui/m_firstrun.py +237 -0
  262. monocoat/gui/m_glass.py +630 -0
  263. monocoat/gui/m_health.py +655 -0
  264. monocoat/gui/m_notify.py +242 -0
  265. monocoat/gui/m_pointer.py +299 -0
  266. monocoat/gui/m_update.py +60 -0
  267. monocoat/gui/m_wallpaper.py +517 -0
  268. monocoat/gui/m_winstate.py +74 -0
  269. monocoat/gui/minidesktop.py +188 -0
  270. monocoat/gui/model.py +589 -0
  271. monocoat/gui/page_apps.py +568 -0
  272. monocoat/gui/page_backups.py +407 -0
  273. monocoat/gui/page_colours.py +666 -0
  274. monocoat/gui/page_dock.py +950 -0
  275. monocoat/gui/page_fonts.py +259 -0
  276. monocoat/gui/page_glass.py +371 -0
  277. monocoat/gui/page_health.py +580 -0
  278. monocoat/gui/page_notify.py +322 -0
  279. monocoat/gui/page_pointer.py +584 -0
  280. monocoat/gui/page_presets.py +762 -0
  281. monocoat/gui/page_wallpaper.py +491 -0
  282. monocoat/gui/pages.py +222 -0
  283. monocoat/gui/scheduler.py +205 -0
  284. monocoat/gui/widgets.py +914 -0
  285. monocoat/gui/window.py +1722 -0
  286. monocoat/iconthemes.py +296 -0
  287. monocoat/immersive.py +570 -0
  288. monocoat/inherit.py +93 -0
  289. monocoat/kittyconf.py +151 -0
  290. monocoat/lastwrites.py +254 -0
  291. monocoat/launcherindex.py +366 -0
  292. monocoat/layers/__init__.py +72 -0
  293. monocoat/layers/bundle.py +161 -0
  294. monocoat/layers/cursor.py +233 -0
  295. monocoat/layers/deep_recolor.py +1672 -0
  296. monocoat/layers/dock.py +106 -0
  297. monocoat/layers/dockd.py +69 -0
  298. monocoat/layers/effects.py +1598 -0
  299. monocoat/layers/fonts.py +629 -0
  300. monocoat/layers/gtk.py +230 -0
  301. monocoat/layers/launcher.py +241 -0
  302. monocoat/layers/libadwaita.py +148 -0
  303. monocoat/layers/notifications.py +88 -0
  304. monocoat/layers/palette.py +1206 -0
  305. monocoat/layers/panel.py +88 -0
  306. monocoat/layers/portal.py +272 -0
  307. monocoat/layers/qt.py +343 -0
  308. monocoat/layers/remote.py +169 -0
  309. monocoat/layers/settings_broadcast.py +103 -0
  310. monocoat/layers/wallpaper.py +438 -0
  311. monocoat/layers/window_manager.py +78 -0
  312. monocoat/liquidglass.py +578 -0
  313. monocoat/locks.py +230 -0
  314. monocoat/lockscreen.py +500 -0
  315. monocoat/lookgc.py +198 -0
  316. monocoat/manifest.py +568 -0
  317. monocoat/notifydnd.py +47 -0
  318. monocoat/out.py +125 -0
  319. monocoat/paths.py +228 -0
  320. monocoat/perfeffects.py +112 -0
  321. monocoat/perfprobe.py +304 -0
  322. monocoat/perftier.py +247 -0
  323. monocoat/picom.py +1424 -0
  324. monocoat/plank.py +125 -0
  325. monocoat/polybarlive.py +110 -0
  326. monocoat/presetedit.py +423 -0
  327. monocoat/presets.py +260 -0
  328. monocoat/procs.py +190 -0
  329. monocoat/registry.py +313 -0
  330. monocoat/reloaddebt.py +106 -0
  331. monocoat/remote_display.py +100 -0
  332. monocoat/remotesession.py +281 -0
  333. monocoat/rendercache.py +133 -0
  334. monocoat/schema.py +1878 -0
  335. monocoat/schema_doc.py +817 -0
  336. monocoat/scheme.py +197 -0
  337. monocoat/scssgen.py +138 -0
  338. monocoat/seamless.py +146 -0
  339. monocoat/selfcli.py +29 -0
  340. monocoat/session/__init__.py +340 -0
  341. monocoat/session/backends/__init__.py +3 -0
  342. monocoat/session/backends/keys.py +41 -0
  343. monocoat/session/backends/notify_dunst.py +126 -0
  344. monocoat/session/backends/notify_fdo.py +76 -0
  345. monocoat/session/backends/notify_gnome.py +24 -0
  346. monocoat/session/backends/notify_mako.py +88 -0
  347. monocoat/session/backends/notify_none.py +29 -0
  348. monocoat/session/backends/notify_plasma.py +63 -0
  349. monocoat/session/backends/notify_xfce.py +38 -0
  350. monocoat/session/backends/protocols.py +43 -0
  351. monocoat/session/backends/settings_gsettings.py +39 -0
  352. monocoat/session/backends/settings_kdeglobals.py +399 -0
  353. monocoat/session/backends/settings_xfconf.py +92 -0
  354. monocoat/session/backends/settings_xsettingsd.py +158 -0
  355. monocoat/session/backends/wm_kwin.py +105 -0
  356. monocoat/session/backends/wm_none.py +24 -0
  357. monocoat/session/backends/wm_xfwm4.py +50 -0
  358. monocoat/session/detect.py +445 -0
  359. monocoat/strikes.py +139 -0
  360. monocoat/style.py +884 -0
  361. monocoat/surfaces/__init__.py +6 -0
  362. monocoat/surfaces/adaptive.py +648 -0
  363. monocoat/surfaces/appcolors.py +200 -0
  364. monocoat/surfaces/appkind.py +281 -0
  365. monocoat/surfaces/backuparchive.py +163 -0
  366. monocoat/surfaces/bindings.py +125 -0
  367. monocoat/surfaces/buttonlayout.py +60 -0
  368. monocoat/surfaces/cssgen.py +75 -0
  369. monocoat/surfaces/csspin.py +245 -0
  370. monocoat/surfaces/cursorgen.py +178 -0
  371. monocoat/surfaces/desktopfile.py +157 -0
  372. monocoat/surfaces/dockbadge.py +178 -0
  373. monocoat/surfaces/dockedge.py +192 -0
  374. monocoat/surfaces/dockmath.py +842 -0
  375. monocoat/surfaces/dockminimize.py +605 -0
  376. monocoat/surfaces/dockmsg.py +331 -0
  377. monocoat/surfaces/dockpin.py +84 -0
  378. monocoat/surfaces/dockpreview.py +158 -0
  379. monocoat/surfaces/dockvisibility.py +113 -0
  380. monocoat/surfaces/dockwin.py +378 -0
  381. monocoat/surfaces/firefoxprefs.py +69 -0
  382. monocoat/surfaces/framegovernor.py +133 -0
  383. monocoat/surfaces/geometry.py +524 -0
  384. monocoat/surfaces/glassshape.py +60 -0
  385. monocoat/surfaces/hotcorners.py +252 -0
  386. monocoat/surfaces/inibind.py +70 -0
  387. monocoat/surfaces/kvantumgeom.py +366 -0
  388. monocoat/surfaces/launchpad.py +611 -0
  389. monocoat/surfaces/mydockfinder.py +1584 -0
  390. monocoat/surfaces/palettemath.py +284 -0
  391. monocoat/surfaces/perfbudget.py +196 -0
  392. monocoat/surfaces/pngcodec.py +186 -0
  393. monocoat/surfaces/polybarini.py +75 -0
  394. monocoat/surfaces/randr.py +167 -0
  395. monocoat/surfaces/remap.py +62 -0
  396. monocoat/surfaces/rofigrid.py +171 -0
  397. monocoat/surfaces/terminals.py +126 -0
  398. monocoat/surfaces/tomlarray.py +721 -0
  399. monocoat/surfaces/translit.py +158 -0
  400. monocoat/surfaces/usercss.py +268 -0
  401. monocoat/surfaces/xcursor.py +191 -0
  402. monocoat/tilemark.py +750 -0
  403. monocoat/tomlout.py +53 -0
  404. monocoat/trayprobe.py +162 -0
  405. monocoat/updater.py +815 -0
  406. monocoat/verify.py +421 -0
  407. monocoat/wallpaper_palette.py +259 -0
  408. monocoat/wallpaper_pipeline.py +940 -0
  409. monocoat/watcher.py +1412 -0
  410. monocoat/x11probe.py +596 -0
  411. monocoat/xroot.py +112 -0
  412. monocoat-1.2.0.dist-info/METADATA +386 -0
  413. monocoat-1.2.0.dist-info/RECORD +416 -0
  414. monocoat-1.2.0.dist-info/WHEEL +4 -0
  415. monocoat-1.2.0.dist-info/entry_points.txt +2 -0
  416. monocoat-1.2.0.dist-info/licenses/LICENSE +21 -0
monocoat/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """monocoat — one TOML, every Linux theming surface.
2
+
3
+ One config drives every visual layer (GTK 2/3/4, settings broadcast, window
4
+ manager, Qt/Kvantum, notifications, cursor, fonts, palette, terminals,
5
+ wallpaper, panel, launcher, dock, remote overlay). The TOML is canon; every
6
+ layer is re-derived from it, idempotently, with drift detection.
7
+ """
8
+
9
+ __version__ = "1.2.0"
monocoat/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from monocoat.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,21 @@
1
+ """Acceptance tools — opt-in, isolated from the live desktop.
2
+
3
+ `python -m monocoat.acceptance` runs the preset-switch acceptance test (see
4
+ docs/acceptance/preset-switch.md): it builds a throwaway world (temp HOME,
5
+ every XDG dir, a private Xvfb + D-Bus session inside a fresh PID namespace),
6
+ switches that world to a generated preset, starts the themed surfaces, and
7
+ classifies screenshots for pixels still holding the old preset's colours.
8
+
9
+ Nothing in this package touches the live session: the runner refuses to start
10
+ unless HOME/XDG_*/DISPLAY/DBUS_SESSION_BUS_ADDRESS already point into the temp
11
+ world, and re-executes itself under `bwrap --unshare-pid` before anything
12
+ starts. It is never imported by the monocoat CLI; it is run as its own tool.
13
+
14
+ The pure pieces (palettes, known_failures, safety) have unit tests in
15
+ tests/test_acceptance_*.py; the world/session/screenshot machinery is exercised
16
+ by the opt-in run only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ __all__ = ["palettes", "known_failures", "safety"]
@@ -0,0 +1,51 @@
1
+ """python -m monocoat.acceptance — the preset-switch acceptance test entry.
2
+
3
+ The tool re-executes itself twice (see preset_switch.py):
4
+ plain build the world, then re-exec under bwrap
5
+ --run WORLD inside bwrap: safety gates + dbus-run-session
6
+ --runner WORLD inside dbus-run-session: the actual run
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from monocoat import exit_codes as rc
15
+ from monocoat.acceptance import preset_switch
16
+ from monocoat.acceptance.shots import ShotError
17
+
18
+ _MODE_FLAGS = ("--run", "--runner")
19
+
20
+
21
+ def main() -> int:
22
+ argv = sys.argv[1:]
23
+ for flag in _MODE_FLAGS:
24
+ if flag in argv:
25
+ idx = argv.index(flag)
26
+ if idx + 1 >= len(argv):
27
+ print(f"{flag} needs the world path argument", file=sys.stderr)
28
+ return rc.USAGE
29
+ world = Path(argv[idx + 1])
30
+ rest = [a for a in argv if a != flag and a != str(world)]
31
+ args = preset_switch.parse_args(rest)
32
+ # A setup error anywhere inside the inner modes (missing
33
+ # Xvfb/xauth, a bad --image, an unreadable known-failures
34
+ # file, a failed capture, a missing dbus-run-session) must be
35
+ # the documented exit-3 refusal, never a traceback: exit 1 is
36
+ # reserved for unexpected FAILs, so a scripted --json consumer
37
+ # could not tell a refusal from a real failure run. The
38
+ # runner's own finally already terminated the daemons during
39
+ # unwinding; the bwrap/dbus wrappers reap the rest.
40
+ try:
41
+ if flag == "--run":
42
+ return preset_switch._run_mode(world, args)
43
+ return preset_switch._runner(world, args)
44
+ except (preset_switch.WorldError, ShotError, ValueError, OSError) as e:
45
+ print(f"preset-switch: {e}", file=sys.stderr)
46
+ return rc.CONFIG_ERROR
47
+ return preset_switch.main(argv)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ raise SystemExit(main())
@@ -0,0 +1,204 @@
1
+ """Opt-in old/new Settings pixel review, inside the certified switch world.
2
+
3
+ Uses the shipped Settings screenshot driver with its fake engine: no UI
4
+ operation can apply a look. Only this runner's isolated CLI writes the world.
5
+ The capability is added to COPIED manifests, never to the owner's bundle.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import runpy
12
+ import subprocess
13
+ import sys
14
+ import tomllib
15
+
16
+ from monocoat import exit_codes as rc
17
+ from monocoat.acceptance import preset_switch as ps
18
+ from monocoat.acceptance.surfaces import GTK3_APP, load_preset_cfg
19
+ from monocoat.acceptance.world import WorldError
20
+
21
+
22
+ def run(world, display, out, generated):
23
+ from tests import _apps_world, _theme_world
24
+
25
+ folder = out / "gtk3-review"
26
+ folder.mkdir()
27
+ apps = _apps_world.mockup_world(_apps_world.make_apps_world(world.root / "review-apps"))
28
+ roots_file = world.root / "review-apps.json"
29
+ roots_file.write_text(_apps_world.roots_json(apps.roots))
30
+ icons = world.root / "review-icons"
31
+ _theme_world.build(icons)
32
+ driver = runpy.run_path(str(ps.REPO / "scripts/settings-screenshots.py"))["CHILD"]
33
+ steps = [("fonts", "win.show_page('fonts')", 900)]
34
+ env = world.env(display) | {"PYTHONPATH": str(ps.REPO / "src") + ":" + str(ps.REPO),
35
+ "MONOCOAT_SHOTS_APPS_ROOTS": str(roots_file),
36
+ "MONOCOAT_SHOTS_ICON_ROOTS": str(icons), "NO_AT_BRIDGE": "1"}
37
+ results = []
38
+ raw = tomllib.loads((world.config_home / "monocoat/monocoat.toml").read_text())
39
+ base = ((raw.get("meta") or {}).get("generated") or {}).get("base", "adaptive")
40
+ for name in (base, generated):
41
+ code, output, error = ps._monocoat(world, display, "set", name, "--json")
42
+ if code not in (rc.OK, rc.PARTIAL):
43
+ raise WorldError(f"GTK3 review could not set {name}: {error or output}")
44
+ # Resolve through the same child config path as all acceptance surfaces.
45
+ preset = world.config_home / "monocoat/presets" / f"{name}.toml"
46
+ cfg = load_preset_cfg(preset)
47
+ # Generated bundles use the default path; Adaptive can carry assets_dir.
48
+ from monocoat.paths import expand
49
+ assets = expand((cfg.get("paths") or {}).get("assets_dir")
50
+ or str(world.data_home / "monocoat/themes" / name))
51
+ manifest = assets / "monocoat-bindings.toml"
52
+ shots = []
53
+ for mode in ("before", "after"):
54
+ if mode == "after":
55
+ text = manifest.read_text()
56
+ data = tomllib.loads(text)
57
+ capabilities = sorted(set(data.get("capabilities", [])) | {"gtk3-theme-palette"})
58
+ line = "capabilities = " + json.dumps(capabilities)
59
+ if "capabilities" in data:
60
+ text = re.sub(r"(?ms)^capabilities\s*=\s*\[.*?\]", line, text, count=1)
61
+ else:
62
+ text = line + "\n" + text
63
+ manifest.write_text(text)
64
+ code, output, error = ps._monocoat(world, display, "apply", "--json")
65
+ if code not in (rc.OK, rc.PARTIAL):
66
+ raise WorldError(f"GTK3 review apply failed: {error or output}")
67
+ from monocoat.gtk3theme import STABLE_MARKER
68
+ if STABLE_MARKER not in (world.config_home / "gtk-3.0/gtk.css").read_text():
69
+ raise WorldError(f"GTK3 review did not migrate {name}: {output}")
70
+ prefix = f"{name}-{mode}"
71
+ proc = subprocess.run([sys.executable, "-c", driver.replace("%STEPS%", repr(steps)),
72
+ str(folder), prefix], env=env, capture_output=True,
73
+ text=True, timeout=45)
74
+ (folder / f"{prefix}.log").write_text(proc.stdout + proc.stderr)
75
+ image = folder / f"{prefix}-fonts.png"
76
+ if proc.returncode or not image.is_file():
77
+ raise WorldError(f"GTK3 review screenshot failed: {proc.stderr[-1000:]}")
78
+ shots.append(image)
79
+ # Settings has its own provider; sample ordinary GTK3 widgets too.
80
+ widget = folder / f"{prefix}-widgets.png"
81
+ save = """
82
+ def save():
83
+ import sys
84
+ pixbuf = Gdk.pixbuf_get_from_window(w.get_window(), 0, 0, 680, 400)
85
+ pixbuf.savev(sys.argv[1], 'png', [], [])
86
+ Gtk.main_quit()
87
+ return False
88
+ """
89
+ code = GTK3_APP.replace("GLib.timeout_add(1500, pop)",
90
+ save + "\nGLib.timeout_add(900, save)")
91
+ proc = subprocess.run([sys.executable, "-c", code, str(widget)], env=env,
92
+ capture_output=True, text=True, timeout=15)
93
+ if proc.returncode or not widget.exists():
94
+ raise WorldError(f"GTK3 widget capture failed: {proc.stderr}")
95
+ for surface, pair_shots in (("settings", shots),
96
+ ("widgets", [folder / f"{name}-{mode}-widgets.png"
97
+ for mode in ("before", "after")])):
98
+ diff = folder / f"{name}-{surface}-difference.png"
99
+ pair = folder / f"{name}-{surface}-comparison.png"
100
+ for argv in (("magick", *map(str, pair_shots), "-compose", "difference", "-composite", str(diff)),
101
+ ("magick", *map(str, pair_shots), "+append", str(pair))):
102
+ subprocess.run(argv, env=env, check=True, capture_output=True)
103
+ metric = subprocess.run(["magick", "compare", "-metric", "AE", *map(str, pair_shots),
104
+ "null:"], env=env, capture_output=True, text=True)
105
+ if metric.returncode not in (0, 1):
106
+ raise WorldError(f"GTK3 pixel comparison failed: {metric.stderr}")
107
+ results.append({"preset": name, "surface": surface, "before": str(pair_shots[0]), "after": str(pair_shots[1]),
108
+ "comparison": str(pair), "difference": str(diff),
109
+ "changed_pixels": metric.stderr.strip()})
110
+ (folder / "review.json").write_text(json.dumps(results, indent=2) + "\n")
111
+ live_apps(world, display, folder, base, generated)
112
+ return results
113
+
114
+
115
+ def live_apps(world, display, folder, base, generated):
116
+ """A settled switch with GTK3 and Thunar ALREADY RUNNING, no new apps."""
117
+ import dataclasses
118
+ import shutil
119
+ import time
120
+
121
+ from monocoat.acceptance import palettes, shots
122
+
123
+ ps._monocoat(world, display, "set", base)
124
+ env = world.env(display) | {"NO_AT_BRIDGE": "1", "GIO_USE_VFS": "local"}
125
+ state_path = world.root / "gtk3-live-state.json"
126
+ state_code = '''
127
+ def record_named():
128
+ import json, os
129
+ from pathlib import Path
130
+ colors = {}
131
+ for name in ('theme_bg_color', 'theme_selected_bg_color'):
132
+ found, rgba = w.get_style_context().lookup_color(name)
133
+ colors[name] = ('#%02x%02x%02x' % tuple(round(c * 255) for c in
134
+ (rgba.red, rgba.green, rgba.blue))) if found else None
135
+ target = Path(os.environ['MC_GTK_STATE'])
136
+ temp = target.with_suffix('.tmp')
137
+ temp.write_text(json.dumps(colors))
138
+ os.replace(temp, target)
139
+ return True
140
+ GLib.timeout_add(100, record_named)
141
+ '''
142
+ code = GTK3_APP.replace("GLib.timeout_add(1500, pop)", state_code)
143
+ env["MC_GTK_STATE"] = str(state_path)
144
+ procs = []
145
+ rows = []
146
+ try:
147
+ procs.append(subprocess.Popen([sys.executable, "-c", code], env=env,
148
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
149
+ windows = {"gtk3": shots.wait_window(env, "--name", "^mc-psw gtk3$")}
150
+ if shutil.which("thunar"):
151
+ child = subprocess.Popen(["thunar", "--window", str(world.home)], env=env,
152
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
153
+ procs.append(child)
154
+ windows["thunar"] = shots.wait_window(env, "--class", "Thunar")
155
+ time.sleep(.8)
156
+ old = palettes.palette_tokens((world.config_home / "monocoat/presets" / f"{base}.toml").read_text())
157
+ new = palettes.palette_tokens((world.config_home / "monocoat/presets" / f"{generated}.toml").read_text())
158
+ before = {}
159
+ for label, wid in windows.items():
160
+ if not wid:
161
+ raise WorldError(f"live GTK3 review missing {label} window")
162
+ png = folder / f"live-{label}-before.png"
163
+ subprocess.run(["import", "-window", wid, str(png)], env=env, check=True,
164
+ capture_output=True)
165
+ before[label] = dataclasses.asdict(palettes.analyze(
166
+ palettes.parse_histogram(shots.histogram(env, png)), old, new))
167
+ named_before = json.loads(state_path.read_text())
168
+ pids = {label: shots.run(env, "xprop", "-id", wid, "_NET_WM_PID")
169
+ for label, wid in windows.items()}
170
+ started = time.perf_counter()
171
+ code, output, error = ps._monocoat(world, display, "set", generated, "--json")
172
+ cost = (time.perf_counter() - started) * 1000
173
+ if code not in (rc.OK, rc.PARTIAL):
174
+ raise WorldError(f"live GTK3 switch failed: {error or output}")
175
+ time.sleep(.8)
176
+ named_after = json.loads(state_path.read_text())
177
+ if named_after['theme_selected_bg_color'] != new['accent'].lower():
178
+ raise WorldError(f"running GTK3 app kept stale named colours: {named_after}")
179
+ for label, wid in windows.items():
180
+ png = folder / f"live-{label}-after.png"
181
+ subprocess.run(["import", "-window", wid, str(png)], env=env, check=True,
182
+ capture_output=True)
183
+ pid_after = shots.run(env, "xprop", "-id", wid, "_NET_WM_PID")
184
+ if not re.search(r"= [0-9]+", pids[label]) or pid_after != pids[label]:
185
+ raise WorldError(f"running {label} PID changed during switch")
186
+ after = palettes.analyze(palettes.parse_histogram(shots.histogram(env, png)), old, new)
187
+ rows.append({"app": label, "pid_property": pid_after.strip(), "switch_ms": cost,
188
+ "before": before[label], "after": dataclasses.asdict(after),
189
+ "recoloured": ((named_before != named_after) if label == "gtk3" else
190
+ before[label]['stale'] > 0 and not palettes.failed(after)),
191
+ "verification": "named colours" if label == "gtk3" else "pixel classifier"})
192
+ payload = {"apps": rows, "named_before": named_before, "named_after": named_after,
193
+ "apply": json.loads(output)}
194
+ (folder / "live-apps.json").write_text(json.dumps(payload, indent=2) + "\n")
195
+ return payload
196
+ finally:
197
+ for child in reversed(procs):
198
+ if child.poll() is None:
199
+ child.terminate()
200
+ try:
201
+ child.wait(timeout=5)
202
+ except subprocess.TimeoutExpired:
203
+ child.kill()
204
+ child.wait(timeout=5)
@@ -0,0 +1,81 @@
1
+ """Expected-failures bookkeeping for the preset-switch acceptance test.
2
+
3
+ The owner maintains docs/acceptance/preset-switch-known.toml listing the
4
+ surfaces KNOWN to keep old-preset colours until in-flight work lands (user
5
+ gtk.css ownership, raster recolor). A failed surface that matches an entry
6
+ prints as XFAIL and does not fail the run; an unexpected FAIL, or an
7
+ unexpected PASS of an XFAIL entry (the gap healed but the file still lists
8
+ it), is reported loudly. The run exits 0 only when there are no unexpected
9
+ failures.
10
+
11
+ Format:
12
+
13
+ version = 1
14
+
15
+ [[expected-failures]]
16
+ surface = "gtk3-*" # fnmatch pattern against the surface name
17
+ reason = "user gtk.css hard-codes the old palette hexes (ownership in flight)"
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fnmatch
23
+ import tomllib
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Entry:
30
+ surface: str
31
+ reason: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class KnownFailures:
36
+ entries: tuple[Entry, ...] = ()
37
+
38
+ def match(self, name: str) -> Entry | None:
39
+ for entry in self.entries:
40
+ if fnmatch.fnmatchcase(name, entry.surface):
41
+ return entry
42
+ return None
43
+
44
+
45
+ def load(path: Path | str) -> KnownFailures:
46
+ """Read the known-failures TOML. A missing file is an empty list (nothing
47
+ is known to fail); an unreadable or malformed one raises ValueError with
48
+ the file name — a broken expectation file must fail the run loudly."""
49
+ p = Path(path)
50
+ if not p.exists():
51
+ return KnownFailures()
52
+ try:
53
+ raw = tomllib.loads(p.read_text())
54
+ except (OSError, tomllib.TOMLDecodeError) as e:
55
+ raise ValueError(f"{p}: cannot read expected-failures file: {e}") from e
56
+ entries = []
57
+ for item in raw.get("expected-failures", []):
58
+ if not isinstance(item, dict) or not item.get("surface"):
59
+ raise ValueError(f"{p}: expected-failures entry without a surface")
60
+ entries.append(Entry(str(item["surface"]), str(item.get("reason", ""))))
61
+ return KnownFailures(tuple(entries))
62
+
63
+
64
+ def verdict(status: str, entry: Entry | None) -> str:
65
+ """Turn a raw surface status into its reported verdict.
66
+
67
+ FAIL + known entry → XFAIL; PASS + known entry → XPASS (reported loudly:
68
+ the entry should be removed); anything else passes through."""
69
+ if entry is None:
70
+ return status
71
+ if status == "FAIL":
72
+ return "XFAIL"
73
+ if status == "PASS":
74
+ return "XPASS"
75
+ return status
76
+
77
+
78
+ def exit_code(verdicts: list[str]) -> int:
79
+ """0 when nothing failed unexpectedly, 1 otherwise (XPASS is loud but not
80
+ an unexpected failure — see the module docstring)."""
81
+ return 1 if "FAIL" in verdicts else 0
@@ -0,0 +1,154 @@
1
+ """Pure pixel classification for the preset-switch acceptance test.
2
+
3
+ A screenshot is reduced to a weighted color histogram (by the runner, via
4
+ ImageMagick); this module decides which pixels "stayed on the old preset".
5
+ The rule (owner-specified):
6
+
7
+ a pixel is STALE when its OKLab distance to the nearest OLD palette token
8
+ is <= 0.04, its distance to the nearest NEW token is > 0.08, and it is
9
+ not near-neutral (OKLCH chroma > 0.03 — eggshell, greys and black don't
10
+ count). A surface FAILS when stale pixels exceed a small share of its
11
+ area (default 0.5%) or 50 pixels.
12
+
13
+ No I/O, no subprocess; the OKLab math lives in surfaces.palettemath (pure).
14
+ The histogram text format is ImageMagick's `%c histogram:info:-`:
15
+
16
+ 1234: ( 23, 45, 67) #172D43 srgb(23,45,67)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import tomllib
23
+ from dataclasses import dataclass
24
+
25
+ from monocoat.surfaces.palettemath import delta_e, parse_hex, to_oklch
26
+
27
+ RGB = tuple[int, int, int]
28
+
29
+ # Classification thresholds (owner-specified; see module docstring).
30
+ STALE_DELTA_OLD = 0.04
31
+ STALE_DELTA_NEW = 0.08
32
+ NEUTRAL_CHROMA = 0.03
33
+ # Failure thresholds: share of the surface area / absolute pixel count.
34
+ FAIL_SHARE = 0.005
35
+ FAIL_PIXELS = 50
36
+
37
+ _HIST_LINE = re.compile(r"^\s*(\d+):\s*\([^)]*\)\s*(#[0-9A-Fa-f]{6})\b", re.M)
38
+
39
+
40
+ def palette_tokens(preset_text: str) -> dict[str, str]:
41
+ """The `[palette]` hex tokens of a preset TOML file, {token: "#RRGGBB"}.
42
+
43
+ Raises ValueError for invalid TOML; a missing [palette] table (or missing
44
+ file content) yields {}. Non-hex values are skipped — they cannot match a
45
+ screenshot pixel."""
46
+ raw = tomllib.loads(preset_text)
47
+ pal = raw.get("palette")
48
+ if not isinstance(pal, dict):
49
+ return {}
50
+ return {k: v for k, v in pal.items() if isinstance(v, str) and _is_hex(v)}
51
+
52
+
53
+ def _is_hex(value: str) -> bool:
54
+ try:
55
+ parse_hex(value)
56
+ except ValueError:
57
+ return False
58
+ return True
59
+
60
+
61
+ def parse_histogram(text: str) -> list[tuple[RGB, int]]:
62
+ """Parse ImageMagick `%c histogram:info:-` output into [(rgb, count), ...].
63
+
64
+ Unparseable lines are skipped; the counts are the caller's pixel weights."""
65
+ out: list[tuple[RGB, int]] = []
66
+ for count, hexv in _HIST_LINE.findall(text):
67
+ try:
68
+ out.append((parse_hex(hexv), int(count)))
69
+ except ValueError:
70
+ continue
71
+ return out
72
+
73
+
74
+ def nearest(pixel: RGB, tokens: dict[str, str]) -> tuple[float, str]:
75
+ """(distance, token name) of the nearest token in OKLab, or (inf, "")."""
76
+ if not tokens:
77
+ return float("inf"), ""
78
+ best, name = float("inf"), ""
79
+ for token, value in tokens.items():
80
+ d = delta_e(value, _to_hex(pixel))
81
+ if d < best:
82
+ best, name = d, token
83
+ return best, name
84
+
85
+
86
+ def _to_hex(pixel: RGB) -> str:
87
+ return f"#{pixel[0]:02x}{pixel[1]:02x}{pixel[2]:02x}"
88
+
89
+
90
+ def is_stale(pixel: RGB, old: dict[str, str], new: dict[str, str], *,
91
+ old_delta: float = STALE_DELTA_OLD,
92
+ new_delta: float = STALE_DELTA_NEW,
93
+ neutral_chroma: float = NEUTRAL_CHROMA) -> bool:
94
+ """The single-pixel rule: close to an old token, far from every new one,
95
+ and colourful enough that greys/eggshell/black are not counted."""
96
+ d_old, _ = nearest(pixel, old)
97
+ if d_old > old_delta:
98
+ return False
99
+ d_new, _ = nearest(pixel, new)
100
+ if d_new <= new_delta:
101
+ return False
102
+ _, chroma, _ = to_oklch(_to_hex(pixel))
103
+ return chroma > neutral_chroma
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class StaleAnalysis:
108
+ """The stale-pixel verdict for one surface region."""
109
+
110
+ total: int # pixels classified (sum of histogram counts)
111
+ stale: int # stale pixels
112
+ share: float # stale / total (0.0 when total == 0)
113
+ dominant: str | None # the most common stale colour, "#rrggbb"
114
+ matched_token: str | None # the old token that colour is nearest to
115
+
116
+
117
+ def analyze(histogram: list[tuple[RGB, int]], old: dict[str, str],
118
+ new: dict[str, str], **thresholds) -> StaleAnalysis:
119
+ """Classify a weighted histogram against the old and new palettes.
120
+
121
+ `thresholds` forwards to is_stale (old_delta/new_delta/neutral_chroma)."""
122
+ total = sum(count for _, count in histogram)
123
+ stale_px = 0
124
+ dominant: tuple[int, str, str] | None = None # (count, hex, token)
125
+ for pixel, count in histogram:
126
+ if not is_stale(pixel, old, new, **thresholds):
127
+ continue
128
+ stale_px += count
129
+ d, token = nearest(pixel, old)
130
+ if dominant is None or count > dominant[0]:
131
+ dominant = (count, _to_hex(pixel), token)
132
+ share = stale_px / total if total else 0.0
133
+ if dominant is None:
134
+ return StaleAnalysis(total, 0, share, None, None)
135
+ _, hexv, token = dominant
136
+ return StaleAnalysis(total, stale_px, share, hexv, token)
137
+
138
+
139
+ def failed(analysis: StaleAnalysis, *, fail_share: float = FAIL_SHARE,
140
+ fail_pixels: int = FAIL_PIXELS) -> bool:
141
+ """Whether a StaleAnalysis counts as a surface failure."""
142
+ return (analysis.total > 0
143
+ and (analysis.share > fail_share or analysis.stale > fail_pixels))
144
+
145
+
146
+ def has_chroma(histogram: list[tuple[RGB, int]],
147
+ *, limit: float = NEUTRAL_CHROMA) -> bool:
148
+ """Whether any histogram colour is colourful (OKLCH chroma > limit).
149
+
150
+ A crop of a blank root is all-neutral: the desktop-backdrop surface
151
+ must SKIP it (the renderer drew nothing) rather than PASS it — the
152
+ same false-PASS class the menu probes exist to prevent."""
153
+ return any(to_oklch(_to_hex(pixel))[1] > limit
154
+ for pixel, count in histogram if count > 0)