pragma Singleton

// Keeps everything that draws a window agreeing about the active theme.
//
// The shell repaints itself from Theme.qml the moment a preference changes,
// because every token there is a binding. Three other things draw on this
// desktop and do not read Panama's store:
//
//   GTK applications          read gsettings color-scheme and gtk-theme
//   the compositor            draws window borders and shadows
//   everything else           carries its own palette file, rendered by
//                             scripts/panama-theme-apps
//
// A toolbar or a window border still wearing the previous theme is more jarring
// than any theme on its own, which is why this exists rather than the setting
// simply being a Theme property.
//
// It watches three things, not one: the scheme, the nearest curated accent
// NAME (which is all GNOME's enum can take), and the active theme's palette.
// The third is the reason editing a custom theme's background reaches kitty --
// the id does not change across an edit, and the accent name often does not
// change across a theme selection either.

import Quickshell
import Quickshell.Io
import QtQuick
import qs.config

Singleton {
    id: root

    readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"

    readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"

    // Theme owns which colors; this owns the form the compositor wants them in.
    // The alphas are the shipped ones for each border role.
    function hyprColor(value: var, alpha: string): string {
        // Qt gives "#rrggbb" -- or "#aarrggbb" if the color ever carries an
        // alpha channel. slice(-6) keeps the trailing rrggbb either way;
        // slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
        // call it RGB. Hyprland wants rgba(rrggbbaa).
        return "rgba(" + String(value).replace("#", "").slice(-6) + alpha + ")";
    }

    // A neutral contrast role, not an accent. The focused Prism border below
    // belongs to the accent and must remain untouched when this role changes.
    //
    // It is the active theme's gutter -- the palette's own neutral surface --
    // rather than the two Tokyo Night literals that used to be written here.
    // Those were right for exactly two of the ten shipped themes and for no
    // custom one.
    readonly property string inactiveBorder: root.hyprColor(Theme.gutter, "99")

    // The focused border follows the chosen accent.
    readonly property string accentBorderStart: root.hyprColor(Theme.accent, "ee")
    readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary, "ee")
    property string lastError: ""

    // A cheap fingerprint of what the render pipeline actually renders from.
    //
    // The theme id alone is not enough: editing a custom theme's background
    // keeps the same id, and the shell would repaint from the binding while
    // kitty, GTK and the lock screen kept the old colours -- the change would
    // look like it had only half landed. The values are in the key too, so any
    // edit to the active palette is a change worth pushing.
    readonly property string palettePrint: {
        const palette = ThemeProfiles.activePalette;
        if (!palette)
            return "";
        return Object.keys(palette).sort().map(token => palette[token]).join(",");
    }

    // What the last push actually sent, so apply() can tell an accent-only
    // change apart from a scheme or theme change and skip the steps that do
    // not depend on whichever did not move. Their starting values do not
    // matter: the first apply() always runs with force set, which ignores all
    // of them.
    property bool appliedDark: false
    property string appliedAccentName: ""
    property string appliedThemeId: ""
    property string appliedPalettePrint: ""

    // Applied one command at a time: Process runs a single command, and several
    // of these are separate programs.
    property var pending: []

    Process {
        id: runner
        onExited: (exitCode, exitStatus) => {
            if (exitCode !== 0)
                root.lastError = "The color scheme could not be applied everywhere.";
            root.drain();
        }
    }

    function drain(): void {
        if (runner.running || root.pending.length === 0)
            return;
        const next = root.pending[0];
        root.pending = root.pending.slice(1);
        runner.exec(next);
    }

    function enqueue(commands: var): void {
        root.pending = root.pending.concat(commands);
        root.drain();
    }

    // Pushed once at startup as well as on change: gsettings and the compositor
    // do not read Panama's store, so a scheme chosen in a previous session is
    // only in effect for the shell until this runs.
    Component.onCompleted: settle.restart()

    Timer {
        id: settle
        interval: 1200
        onTriggered: root.apply(true)
    }

    Connections {
        target: DesktopPreferences
        function onRevisionChanged(): void { coalesce.restart(); }
    }

    // Profile selection and custom edits both update preferences, but binding
    // directly to the effective profile also documents the in-shell ownership
    // boundary and keeps this propagation reactive if that storage changes.
    Connections {
        target: ThemeProfiles
        function onActiveProfileChanged(): void { coalesce.restart(); }
    }

    Timer {
        id: coalesce
        interval: 250
        onTriggered: root.apply(false)
    }

    // `force` pushes every step regardless of what moved -- startup needs
    // that, because gsettings and the compositor keep their own state and
    // have no way to know a previous Quickshell session already told them the
    // answer. Everywhere else this reacts to ANY preference changing (see
    // onRevisionChanged above), so most calls have nothing to do with either
    // scheme or accent; only the steps whose inputs actually moved since the
    // last push run, which keeps sampling accent swatches from also rewriting
    // gsettings and re-running the whole app-theming script on every sample.
    function apply(force: bool): void {
        root.lastError = "";

        const accentName = DesktopPreferences.get("accentName") || "blue";
        const themeId = ThemeProfiles.activeId;
        const palettePrint = root.palettePrint;

        const schemeChanged = force || root.dark !== root.appliedDark;
        const accentChanged = force || accentName !== root.appliedAccentName;
        // A theme change is a palette change: selecting another theme, or
        // editing the active one. accentName is the NEAREST CURATED name, so
        // it can hold still across both -- Moon to Moon Rose keeps "blue" and
        // moves every other colour.
        const themeChanged = force || themeId !== root.appliedThemeId
            || palettePrint !== root.appliedPalettePrint;

        if (!schemeChanged && !accentChanged && !themeChanged)
            return;

        const commands = [];

        if (schemeChanged) {
            const scheme = root.dark ? "prefer-dark" : "prefer-light";

            // adw-gtk3, not Adwaita. This is the bug that made dark mode look
            // broken while light mode looked fine:
            //
            // Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
            // 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
            // not exist makes GTK fall back to its built-in default, which is
            // LIGHT. So asking for light accidentally worked, asking for dark
            // silently produced light, and applications that take their cue from
            // the GTK theme rather than the portal -- Chromium and Electron, when
            // built against GTK -- stayed light no matter what the portal said.
            //
            // gtk-theme-contract asserts these names are actually installed,
            // because the failure mode is silent in exactly this way.
            const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";

            commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
            commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
        }

        if (schemeChanged || themeChanged) {
            // Unfocused window borders need scheme-relative contrast, and stay
            // this service's to own. The FOCUSED border below is the accent
            // role and belongs to the theme -- see modules/settings/README.md.
            //
            // It is drawn from the palette's gutter, so it moves with the THEME
            // as well as with the scheme; an accent-only change still never
            // needs to restate it.
            commands.push(["hyprctl", "eval",
                `hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
        }

        if (schemeChanged || accentChanged || themeChanged) {
            // Each accent carries a separate pair for light and dark, so this
            // runs on either kind of change: a scheme flip restates the same
            // accent's other pair, and an accent change restates the same
            // scheme's other colors.
            //
            // A two-stop gradient at the shipped angle. Written as a Lua TABLE:
            // the string form of a gradient carries only one stop, and passing
            // "rgba(a) rgba(b) 115deg" as a string is accepted and silently
            // keeps the previous value. Multi-stop must be the table form --
            // built by the same serializeValue() SystemSettings uses for every
            // other gradient, rather than a second hand-rolled copy of that
            // escaping here.
            const activeBorder = SystemSettings.serializeValue({
                colors: [root.accentBorderStart, root.accentBorderEnd],
                angle: 115
            });
            commands.push(["hyprctl", "eval",
                `hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);

            // Applications that predate org.freedesktop.appearance and carry
            // their own palettes -- terminals, GTK's css, the lock screen, the
            // launcher, the browser chrome. Portal readers (GTK4, Qt6, Chromium,
            // Electron) already have their SCHEME from the gsettings write
            // above, but no portal can tell them which THEME.
            //
            // The script resolves the palette itself, from settings.json and
            // the catalog, so the two arguments here are only what it cannot
            // read: nothing, and the nearest curated accent NAME, which GNOME's
            // accent-color enum needs because it takes a name rather than a
            // color. It runs on a theme change even when neither scheme nor
            // accentName moved -- that is what selecting Nord over Moon looks
            // like from here.
            commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
        }

        root.appliedDark = root.dark;
        root.appliedAccentName = accentName;
        root.appliedThemeId = themeId;
        root.appliedPalettePrint = palettePrint;

        root.enqueue(commands);
    }
}
