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.
- monocoat/__init__.py +9 -0
- monocoat/__main__.py +4 -0
- monocoat/acceptance/__init__.py +21 -0
- monocoat/acceptance/__main__.py +51 -0
- monocoat/acceptance/gtk3_review.py +204 -0
- monocoat/acceptance/known_failures.py +81 -0
- monocoat/acceptance/palettes.py +154 -0
- monocoat/acceptance/preset_switch.py +634 -0
- monocoat/acceptance/report.py +86 -0
- monocoat/acceptance/safety.py +121 -0
- monocoat/acceptance/shots.py +156 -0
- monocoat/acceptance/surfaces.py +668 -0
- monocoat/acceptance/world.py +392 -0
- monocoat/appchoices.py +171 -0
- monocoat/appids.py +127 -0
- monocoat/appscan.py +524 -0
- monocoat/appsedit.py +150 -0
- monocoat/backdrop.py +3400 -0
- monocoat/backup.py +1250 -0
- monocoat/bundleprepare.py +90 -0
- monocoat/bundles.py +554 -0
- monocoat/capabilities.py +253 -0
- monocoat/change.py +706 -0
- monocoat/cli.py +2381 -0
- monocoat/commands/__init__.py +0 -0
- monocoat/commands/backup.py +145 -0
- monocoat/commands/daynight.py +79 -0
- monocoat/commands/demo.py +159 -0
- monocoat/commands/import_mydockfinder.py +392 -0
- monocoat/commands/init.py +389 -0
- monocoat/commands/migrate.py +569 -0
- monocoat/commands/perf.py +110 -0
- monocoat/commands/update.py +30 -0
- monocoat/commands/wallpaper.py +380 -0
- monocoat/config.py +86 -0
- monocoat/corners.py +164 -0
- monocoat/cursors.py +162 -0
- monocoat/cursorthemes.py +175 -0
- monocoat/daemons.py +1805 -0
- monocoat/data/hackneyed/ATTRIBUTION +9 -0
- monocoat/data/hackneyed/LICENSE +24 -0
- monocoat/data/hackneyed/hotspots/X_cursor.in +1 -0
- monocoat/data/hackneyed/hotspots/alias.in +1 -0
- monocoat/data/hackneyed/hotspots/all-scroll.in +1 -0
- monocoat/data/hackneyed/hotspots/based_arrow_down.in +1 -0
- monocoat/data/hackneyed/hotspots/based_arrow_up.in +1 -0
- monocoat/data/hackneyed/hotspots/bottom_tee.in +1 -0
- monocoat/data/hackneyed/hotspots/center_ptr.in +1 -0
- monocoat/data/hackneyed/hotspots/closedhand.in +1 -0
- monocoat/data/hackneyed/hotspots/coffee_mug.in +1 -0
- monocoat/data/hackneyed/hotspots/color-picker.in +1 -0
- monocoat/data/hackneyed/hotspots/context-menu.in +1 -0
- monocoat/data/hackneyed/hotspots/copy.in +1 -0
- monocoat/data/hackneyed/hotspots/crosshair.in +1 -0
- monocoat/data/hackneyed/hotspots/default.in +1 -0
- monocoat/data/hackneyed/hotspots/dnd-move.in +1 -0
- monocoat/data/hackneyed/hotspots/draft.in +1 -0
- monocoat/data/hackneyed/hotspots/draped_box.in +1 -0
- monocoat/data/hackneyed/hotspots/e-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/ew-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/exchange.in +1 -0
- monocoat/data/hackneyed/hotspots/help.in +1 -0
- monocoat/data/hackneyed/hotspots/left_tee.in +1 -0
- monocoat/data/hackneyed/hotspots/ll_angle.in +1 -0
- monocoat/data/hackneyed/hotspots/lr_angle.in +1 -0
- monocoat/data/hackneyed/hotspots/move.in +1 -0
- monocoat/data/hackneyed/hotspots/n-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/ne-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/nesw-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/no-drop.in +1 -0
- monocoat/data/hackneyed/hotspots/not-allowed.in +1 -0
- monocoat/data/hackneyed/hotspots/ns-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/nw-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/nwse-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/openhand.in +1 -0
- monocoat/data/hackneyed/hotspots/pencil.in +1 -0
- monocoat/data/hackneyed/hotspots/pirate.in +1 -0
- monocoat/data/hackneyed/hotspots/plus.in +1 -0
- monocoat/data/hackneyed/hotspots/pointer.in +1 -0
- monocoat/data/hackneyed/hotspots/progress.in +1 -0
- monocoat/data/hackneyed/hotspots/right_ptr.in +1 -0
- monocoat/data/hackneyed/hotspots/right_tee.in +1 -0
- monocoat/data/hackneyed/hotspots/s-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/sb_down_arrow.in +1 -0
- monocoat/data/hackneyed/hotspots/sb_left_arrow.in +1 -0
- monocoat/data/hackneyed/hotspots/sb_right_arrow.in +1 -0
- monocoat/data/hackneyed/hotspots/sb_up_arrow.in +1 -0
- monocoat/data/hackneyed/hotspots/se-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/split_h.in +1 -0
- monocoat/data/hackneyed/hotspots/split_v.in +1 -0
- monocoat/data/hackneyed/hotspots/sw-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/text.in +1 -0
- monocoat/data/hackneyed/hotspots/top_tee.in +1 -0
- monocoat/data/hackneyed/hotspots/ul_angle.in +1 -0
- monocoat/data/hackneyed/hotspots/ur_angle.in +1 -0
- monocoat/data/hackneyed/hotspots/vertical-text.in +1 -0
- monocoat/data/hackneyed/hotspots/w-resize.in +1 -0
- monocoat/data/hackneyed/hotspots/wait.in +1 -0
- monocoat/data/hackneyed/hotspots/wayland-cursor.in +1 -0
- monocoat/data/hackneyed/hotspots/zoom-in.in +1 -0
- monocoat/data/hackneyed/hotspots/zoom-out.in +1 -0
- monocoat/data/hackneyed/hotspots/zoom.in +1 -0
- monocoat/data/hackneyed/svg/X_cursor.svg +224 -0
- monocoat/data/hackneyed/svg/alias.svg +263 -0
- monocoat/data/hackneyed/svg/all-scroll.svg +235 -0
- monocoat/data/hackneyed/svg/based_arrow_down.svg +226 -0
- monocoat/data/hackneyed/svg/based_arrow_up.svg +226 -0
- monocoat/data/hackneyed/svg/bottom_tee.svg +226 -0
- monocoat/data/hackneyed/svg/center_ptr.svg +230 -0
- monocoat/data/hackneyed/svg/closedhand.svg +252 -0
- monocoat/data/hackneyed/svg/coffee_mug.svg +289 -0
- monocoat/data/hackneyed/svg/color-picker.svg +261 -0
- monocoat/data/hackneyed/svg/context-menu.svg +304 -0
- monocoat/data/hackneyed/svg/copy.svg +258 -0
- monocoat/data/hackneyed/svg/crosshair.svg +225 -0
- monocoat/data/hackneyed/svg/default.svg +252 -0
- monocoat/data/hackneyed/svg/dnd-move.svg +260 -0
- monocoat/data/hackneyed/svg/draft.svg +278 -0
- monocoat/data/hackneyed/svg/draped_box.svg +241 -0
- monocoat/data/hackneyed/svg/e-resize.svg +226 -0
- monocoat/data/hackneyed/svg/ew-resize.svg +226 -0
- monocoat/data/hackneyed/svg/exchange.svg +262 -0
- monocoat/data/hackneyed/svg/help.svg +292 -0
- monocoat/data/hackneyed/svg/left_tee.svg +226 -0
- monocoat/data/hackneyed/svg/ll_angle.svg +226 -0
- monocoat/data/hackneyed/svg/lr_angle.svg +226 -0
- monocoat/data/hackneyed/svg/move.svg +224 -0
- monocoat/data/hackneyed/svg/n-resize.svg +226 -0
- monocoat/data/hackneyed/svg/ne-resize.svg +206 -0
- monocoat/data/hackneyed/svg/nesw-resize.svg +333 -0
- monocoat/data/hackneyed/svg/no-drop.svg +258 -0
- monocoat/data/hackneyed/svg/not-allowed.svg +230 -0
- monocoat/data/hackneyed/svg/ns-resize.svg +226 -0
- monocoat/data/hackneyed/svg/nw-resize.svg +208 -0
- monocoat/data/hackneyed/svg/nwse-resize.svg +226 -0
- monocoat/data/hackneyed/svg/openhand.svg +252 -0
- monocoat/data/hackneyed/svg/pencil.svg +256 -0
- monocoat/data/hackneyed/svg/pirate.svg +252 -0
- monocoat/data/hackneyed/svg/plus.svg +223 -0
- monocoat/data/hackneyed/svg/pointer.svg +252 -0
- monocoat/data/hackneyed/svg/progress-1.svg +282 -0
- monocoat/data/hackneyed/svg/progress-10.svg +327 -0
- monocoat/data/hackneyed/svg/progress-11.svg +327 -0
- monocoat/data/hackneyed/svg/progress-12.svg +327 -0
- monocoat/data/hackneyed/svg/progress-13.svg +327 -0
- monocoat/data/hackneyed/svg/progress-14.svg +327 -0
- monocoat/data/hackneyed/svg/progress-15.svg +332 -0
- monocoat/data/hackneyed/svg/progress-16.svg +327 -0
- monocoat/data/hackneyed/svg/progress-2.svg +283 -0
- monocoat/data/hackneyed/svg/progress-3.svg +283 -0
- monocoat/data/hackneyed/svg/progress-4.svg +283 -0
- monocoat/data/hackneyed/svg/progress-5.svg +280 -0
- monocoat/data/hackneyed/svg/progress-6.svg +281 -0
- monocoat/data/hackneyed/svg/progress-7.svg +327 -0
- monocoat/data/hackneyed/svg/progress-8.svg +327 -0
- monocoat/data/hackneyed/svg/progress-9.svg +327 -0
- monocoat/data/hackneyed/svg/right_ptr.svg +256 -0
- monocoat/data/hackneyed/svg/right_tee.svg +226 -0
- monocoat/data/hackneyed/svg/s-resize.svg +226 -0
- monocoat/data/hackneyed/svg/sb_down_arrow.svg +237 -0
- monocoat/data/hackneyed/svg/sb_left_arrow.svg +237 -0
- monocoat/data/hackneyed/svg/sb_right_arrow.svg +237 -0
- monocoat/data/hackneyed/svg/sb_up_arrow.svg +196 -0
- monocoat/data/hackneyed/svg/se-resize.svg +206 -0
- monocoat/data/hackneyed/svg/split_h.svg +237 -0
- monocoat/data/hackneyed/svg/split_v.svg +230 -0
- monocoat/data/hackneyed/svg/sw-resize.svg +207 -0
- monocoat/data/hackneyed/svg/text.svg +231 -0
- monocoat/data/hackneyed/svg/top_tee.svg +226 -0
- monocoat/data/hackneyed/svg/ul_angle.svg +226 -0
- monocoat/data/hackneyed/svg/ur_angle.svg +226 -0
- monocoat/data/hackneyed/svg/vertical-text.svg +229 -0
- monocoat/data/hackneyed/svg/w-resize.svg +226 -0
- monocoat/data/hackneyed/svg/wait-1.svg +289 -0
- monocoat/data/hackneyed/svg/wait-10.svg +288 -0
- monocoat/data/hackneyed/svg/wait-11.svg +288 -0
- monocoat/data/hackneyed/svg/wait-12.svg +288 -0
- monocoat/data/hackneyed/svg/wait-13.svg +288 -0
- monocoat/data/hackneyed/svg/wait-14.svg +288 -0
- monocoat/data/hackneyed/svg/wait-15.svg +288 -0
- monocoat/data/hackneyed/svg/wait-16.svg +368 -0
- monocoat/data/hackneyed/svg/wait-2.svg +288 -0
- monocoat/data/hackneyed/svg/wait-3.svg +288 -0
- monocoat/data/hackneyed/svg/wait-4.svg +288 -0
- monocoat/data/hackneyed/svg/wait-5.svg +287 -0
- monocoat/data/hackneyed/svg/wait-6.svg +288 -0
- monocoat/data/hackneyed/svg/wait-7.svg +288 -0
- monocoat/data/hackneyed/svg/wait-8.svg +288 -0
- monocoat/data/hackneyed/svg/wait-9.svg +288 -0
- monocoat/data/hackneyed/svg/wayland-cursor.svg +244 -0
- monocoat/data/hackneyed/svg/zoom-in.svg +272 -0
- monocoat/data/hackneyed/svg/zoom-out.svg +273 -0
- monocoat/data/hackneyed/svg/zoom.svg +267 -0
- monocoat/data/hackneyed/symlinks +46 -0
- monocoat/data/monocoat-bar-ink.glsl +47 -0
- monocoat/data/monocoat-glass.glsl +106 -0
- monocoat/data/monocoat-liquid-glass.glsl +110 -0
- monocoat/data/monocoat-seamless.glsl +268 -0
- monocoat/data/unihan/LICENSE +39 -0
- monocoat/data/unihan/han-readings.txt +714 -0
- monocoat/daynight.py +115 -0
- monocoat/diff.py +271 -0
- monocoat/dockapps.py +542 -0
- monocoat/dockcatalog.py +137 -0
- monocoat/dockclasses.py +35 -0
- monocoat/dockconfig.py +1261 -0
- monocoat/dockd.py +473 -0
- monocoat/dockdraw.py +661 -0
- monocoat/dockfolder.py +640 -0
- monocoat/dockfoldergtk.py +1363 -0
- monocoat/dockgtk.py +5746 -0
- monocoat/dockhotcorners.py +811 -0
- monocoat/dockipc.py +114 -0
- monocoat/dockitems.py +550 -0
- monocoat/dockitemsgtk.py +58 -0
- monocoat/docklauncher.py +204 -0
- monocoat/docklaunchhelper.py +43 -0
- monocoat/docklaunchpadgtk.py +1520 -0
- monocoat/docklock.py +108 -0
- monocoat/dockmedia.py +746 -0
- monocoat/dockmediaart.py +442 -0
- monocoat/dockmediagtk.py +369 -0
- monocoat/dockminimizegtk.py +1496 -0
- monocoat/dockminimizex11.py +185 -0
- monocoat/dockmsgtips.py +589 -0
- monocoat/dockpinfile.py +285 -0
- monocoat/dockpins.py +980 -0
- monocoat/dockpreview.py +129 -0
- monocoat/dockpreviewgtk.py +2035 -0
- monocoat/dockpreviewlock.py +188 -0
- monocoat/dockpreviewx11.py +372 -0
- monocoat/dockscroll.py +125 -0
- monocoat/docktrack.py +429 -0
- monocoat/dockx11.py +842 -0
- monocoat/dockxwatch.py +79 -0
- monocoat/doctor.py +1986 -0
- monocoat/editor.py +47 -0
- monocoat/edittarget.py +151 -0
- monocoat/exit_codes.py +17 -0
- monocoat/fileio.py +46 -0
- monocoat/firefox.py +637 -0
- monocoat/follow.py +127 -0
- monocoat/frame.py +159 -0
- monocoat/fsbirth.py +53 -0
- monocoat/glass.py +586 -0
- monocoat/gtk3theme.py +125 -0
- monocoat/gui/__init__.py +45 -0
- monocoat/gui/app.py +56 -0
- monocoat/gui/apps.py +440 -0
- monocoat/gui/captions.py +227 -0
- monocoat/gui/colorpicker.py +203 -0
- monocoat/gui/controls.py +394 -0
- monocoat/gui/engine.py +435 -0
- monocoat/gui/firstrun.py +169 -0
- monocoat/gui/fontpicker.py +130 -0
- monocoat/gui/history.py +214 -0
- monocoat/gui/looks.py +411 -0
- monocoat/gui/m_backups.py +452 -0
- monocoat/gui/m_colourmatch.py +215 -0
- monocoat/gui/m_dock.py +916 -0
- monocoat/gui/m_firstrun.py +237 -0
- monocoat/gui/m_glass.py +630 -0
- monocoat/gui/m_health.py +655 -0
- monocoat/gui/m_notify.py +242 -0
- monocoat/gui/m_pointer.py +299 -0
- monocoat/gui/m_update.py +60 -0
- monocoat/gui/m_wallpaper.py +517 -0
- monocoat/gui/m_winstate.py +74 -0
- monocoat/gui/minidesktop.py +188 -0
- monocoat/gui/model.py +589 -0
- monocoat/gui/page_apps.py +568 -0
- monocoat/gui/page_backups.py +407 -0
- monocoat/gui/page_colours.py +666 -0
- monocoat/gui/page_dock.py +950 -0
- monocoat/gui/page_fonts.py +259 -0
- monocoat/gui/page_glass.py +371 -0
- monocoat/gui/page_health.py +580 -0
- monocoat/gui/page_notify.py +322 -0
- monocoat/gui/page_pointer.py +584 -0
- monocoat/gui/page_presets.py +762 -0
- monocoat/gui/page_wallpaper.py +491 -0
- monocoat/gui/pages.py +222 -0
- monocoat/gui/scheduler.py +205 -0
- monocoat/gui/widgets.py +914 -0
- monocoat/gui/window.py +1722 -0
- monocoat/iconthemes.py +296 -0
- monocoat/immersive.py +570 -0
- monocoat/inherit.py +93 -0
- monocoat/kittyconf.py +151 -0
- monocoat/lastwrites.py +254 -0
- monocoat/launcherindex.py +366 -0
- monocoat/layers/__init__.py +72 -0
- monocoat/layers/bundle.py +161 -0
- monocoat/layers/cursor.py +233 -0
- monocoat/layers/deep_recolor.py +1672 -0
- monocoat/layers/dock.py +106 -0
- monocoat/layers/dockd.py +69 -0
- monocoat/layers/effects.py +1598 -0
- monocoat/layers/fonts.py +629 -0
- monocoat/layers/gtk.py +230 -0
- monocoat/layers/launcher.py +241 -0
- monocoat/layers/libadwaita.py +148 -0
- monocoat/layers/notifications.py +88 -0
- monocoat/layers/palette.py +1206 -0
- monocoat/layers/panel.py +88 -0
- monocoat/layers/portal.py +272 -0
- monocoat/layers/qt.py +343 -0
- monocoat/layers/remote.py +169 -0
- monocoat/layers/settings_broadcast.py +103 -0
- monocoat/layers/wallpaper.py +438 -0
- monocoat/layers/window_manager.py +78 -0
- monocoat/liquidglass.py +578 -0
- monocoat/locks.py +230 -0
- monocoat/lockscreen.py +500 -0
- monocoat/lookgc.py +198 -0
- monocoat/manifest.py +568 -0
- monocoat/notifydnd.py +47 -0
- monocoat/out.py +125 -0
- monocoat/paths.py +228 -0
- monocoat/perfeffects.py +112 -0
- monocoat/perfprobe.py +304 -0
- monocoat/perftier.py +247 -0
- monocoat/picom.py +1424 -0
- monocoat/plank.py +125 -0
- monocoat/polybarlive.py +110 -0
- monocoat/presetedit.py +423 -0
- monocoat/presets.py +260 -0
- monocoat/procs.py +190 -0
- monocoat/registry.py +313 -0
- monocoat/reloaddebt.py +106 -0
- monocoat/remote_display.py +100 -0
- monocoat/remotesession.py +281 -0
- monocoat/rendercache.py +133 -0
- monocoat/schema.py +1878 -0
- monocoat/schema_doc.py +817 -0
- monocoat/scheme.py +197 -0
- monocoat/scssgen.py +138 -0
- monocoat/seamless.py +146 -0
- monocoat/selfcli.py +29 -0
- monocoat/session/__init__.py +340 -0
- monocoat/session/backends/__init__.py +3 -0
- monocoat/session/backends/keys.py +41 -0
- monocoat/session/backends/notify_dunst.py +126 -0
- monocoat/session/backends/notify_fdo.py +76 -0
- monocoat/session/backends/notify_gnome.py +24 -0
- monocoat/session/backends/notify_mako.py +88 -0
- monocoat/session/backends/notify_none.py +29 -0
- monocoat/session/backends/notify_plasma.py +63 -0
- monocoat/session/backends/notify_xfce.py +38 -0
- monocoat/session/backends/protocols.py +43 -0
- monocoat/session/backends/settings_gsettings.py +39 -0
- monocoat/session/backends/settings_kdeglobals.py +399 -0
- monocoat/session/backends/settings_xfconf.py +92 -0
- monocoat/session/backends/settings_xsettingsd.py +158 -0
- monocoat/session/backends/wm_kwin.py +105 -0
- monocoat/session/backends/wm_none.py +24 -0
- monocoat/session/backends/wm_xfwm4.py +50 -0
- monocoat/session/detect.py +445 -0
- monocoat/strikes.py +139 -0
- monocoat/style.py +884 -0
- monocoat/surfaces/__init__.py +6 -0
- monocoat/surfaces/adaptive.py +648 -0
- monocoat/surfaces/appcolors.py +200 -0
- monocoat/surfaces/appkind.py +281 -0
- monocoat/surfaces/backuparchive.py +163 -0
- monocoat/surfaces/bindings.py +125 -0
- monocoat/surfaces/buttonlayout.py +60 -0
- monocoat/surfaces/cssgen.py +75 -0
- monocoat/surfaces/csspin.py +245 -0
- monocoat/surfaces/cursorgen.py +178 -0
- monocoat/surfaces/desktopfile.py +157 -0
- monocoat/surfaces/dockbadge.py +178 -0
- monocoat/surfaces/dockedge.py +192 -0
- monocoat/surfaces/dockmath.py +842 -0
- monocoat/surfaces/dockminimize.py +605 -0
- monocoat/surfaces/dockmsg.py +331 -0
- monocoat/surfaces/dockpin.py +84 -0
- monocoat/surfaces/dockpreview.py +158 -0
- monocoat/surfaces/dockvisibility.py +113 -0
- monocoat/surfaces/dockwin.py +378 -0
- monocoat/surfaces/firefoxprefs.py +69 -0
- monocoat/surfaces/framegovernor.py +133 -0
- monocoat/surfaces/geometry.py +524 -0
- monocoat/surfaces/glassshape.py +60 -0
- monocoat/surfaces/hotcorners.py +252 -0
- monocoat/surfaces/inibind.py +70 -0
- monocoat/surfaces/kvantumgeom.py +366 -0
- monocoat/surfaces/launchpad.py +611 -0
- monocoat/surfaces/mydockfinder.py +1584 -0
- monocoat/surfaces/palettemath.py +284 -0
- monocoat/surfaces/perfbudget.py +196 -0
- monocoat/surfaces/pngcodec.py +186 -0
- monocoat/surfaces/polybarini.py +75 -0
- monocoat/surfaces/randr.py +167 -0
- monocoat/surfaces/remap.py +62 -0
- monocoat/surfaces/rofigrid.py +171 -0
- monocoat/surfaces/terminals.py +126 -0
- monocoat/surfaces/tomlarray.py +721 -0
- monocoat/surfaces/translit.py +158 -0
- monocoat/surfaces/usercss.py +268 -0
- monocoat/surfaces/xcursor.py +191 -0
- monocoat/tilemark.py +750 -0
- monocoat/tomlout.py +53 -0
- monocoat/trayprobe.py +162 -0
- monocoat/updater.py +815 -0
- monocoat/verify.py +421 -0
- monocoat/wallpaper_palette.py +259 -0
- monocoat/wallpaper_pipeline.py +940 -0
- monocoat/watcher.py +1412 -0
- monocoat/x11probe.py +596 -0
- monocoat/xroot.py +112 -0
- monocoat-1.2.0.dist-info/METADATA +386 -0
- monocoat-1.2.0.dist-info/RECORD +416 -0
- monocoat-1.2.0.dist-info/WHEEL +4 -0
- monocoat-1.2.0.dist-info/entry_points.txt +2 -0
- 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,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)
|