pragma Singleton

// ─────────────────────────────────────────────────────────────────────────────
// The schema is the single source of truth for every user-changeable setting.
//
// One entry describes a setting completely: its name, type, default, valid
// range, which group it belongs to, and how to label it. Persistence,
// validation, reset, and (from Stage 3) the settings UI itself are all derived
// from these entries rather than restated.
//
// Adding a setting means adding one entry here. It does not mean editing a
// property alias, a JSON adapter, a change handler, and a reset function --
// which is what it used to mean, and why the settings app stalled at sixteen
// knobs while forty comparable values stayed hardcoded one file away.
//
// Entry fields
//   key      unique identifier; also the JSON key on disk
//   type     "bool" | "int" | "real" | "string" | "enum" | "json"
//   def      shipped default, used when the file is absent or a value is invalid
//   min/max  inclusive bounds for int and real; values outside are clamped
//   step     UI increment for int and real
//   options  for "enum": [{ value, label }], the only accepted values
//   group    grouping id, used to build settings pages
//   label    short UI name
//   detail   one line explaining what changing it does
//   internal true for state the shell keeps but the user never edits directly
//   pattern  for "string": a regular expression the value must match in full
//
// "json" holds a structured value -- a list or an object -- that the schema
// stores and resets but does not validate field by field. It exists so that
// settings like the dock's pinned applications live in the same file, and are
// covered by the same reset, as everything else rather than growing a fourth
// preference store. The service that owns such a value is responsible for
// validating it; see services/Dock-related consumers.
//   hypr     present when the setting maps onto an Hyprland option:
//              path    the hl.config table path, e.g. ["decoration","blur","size"]
//              option  the getoption path used to read the value back
//              readAs  which field getoption returns it in -- "int", "bool",
//                      "float", "str", or "css" (gaps, returned as a box)
//
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
// keeps the shipped value as its fallback, so the config still works with no
// settings file at all.
// ─────────────────────────────────────────────────────────────────────────────

import Quickshell
import QtQuick

Singleton {
    id: root

    readonly property var entries: [
        // ── Clock ───────────────────────────────────────────────────────────
        {
            key: "use24Hour", type: "bool", def: false, group: "clock",
            label: "24-hour time",
            detail: "Use 18:30 instead of 6:30 PM"
        },
        {
            key: "showSeconds", type: "bool", def: true, group: "clock",
            label: "Show seconds",
            detail: "Keep a precise clock in the center of the bar"
        },
        {
            key: "showWeekday", type: "bool", def: true, group: "clock",
            label: "Show weekday",
            detail: "Include the abbreviated weekday before the date"
        },

        // ── Vitals ──────────────────────────────────────────────────────────
        {
            key: "showCpu", type: "bool", def: true, group: "vitals",
            label: "Processor",
            detail: "Show processor usage beside the workspace indicator"
        },
        {
            key: "showMemory", type: "bool", def: true, group: "vitals",
            label: "Memory",
            detail: "Show memory usage beside the workspace indicator"
        },
        {
            key: "showGpu", type: "bool", def: true, group: "vitals",
            label: "Graphics",
            detail: "Show graphics usage beside the workspace indicator"
        },

        // ── Dock ────────────────────────────────────────────────────────────
        {
            key: "dockAutohide", type: "bool", def: true, group: "dock",
            label: "Automatically hide the Dock",
            detail: "Reveal it at the bottom edge when a workspace is occupied"
        },
        {
            key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
            unit: "ms",
            group: "dock",
            label: "Reveal delay",
            detail: "Zero reveals the Dock the instant the pointer reaches the edge"
        },
        {
            key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
            unit: "ms",
            group: "dock",
            label: "Hide delay",
            detail: "Prevents flicker when crossing between icons"
        },

        // ── Focus ───────────────────────────────────────────────────────────
        {
            key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
            unit: "min",
            group: "focus",
            label: "Focus session length",
            detail: "How long a focus session runs before it ends itself"
        },

        // ── Display policy ──────────────────────────────────────────────────
        // These three are written to the compositor and verified by read-back.
        // See services/SystemSettings.qml for why the exit code cannot be
        // trusted for either hyprctl keyword or hyprctl eval.
        {
            key: "autoHdr", type: "bool", def: true, group: "display",
            label: "Game-aware HDR",
            detail: "Hand HDR to fullscreen games while the desktop stays SDR",
            hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
        },
        {
            key: "vrrPolicy", type: "enum", def: 3, group: "display",
            label: "Variable refresh rate",
            detail: "Content-aware matches the display to what is on screen",
            options: [
                { value: 0, label: "Off" },
                { value: 3, label: "Content-aware" }
            ],
            hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
        },
        {
            key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
            label: "Direct scanout",
            detail: "Lets fullscreen content bypass compositing",
            options: [
                { value: 0, label: "Off" },
                { value: 2, label: "Automatic" }
            ],
            hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
        },

        // ── Window appearance ───────────────────────────────────────────────
        // These adjust the parameters of the Prism identity -- how much space,
        // how soft, how much motion -- rather than replacing it. Shipped values
        // are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
        // the Hyprland config still stands on its own.
        {
            key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
            unit: "px",
            group: "windows",
            label: "Inner gaps",
            detail: "Space between neighbouring tiled windows",
            hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
        },
        {
            key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
            unit: "px",
            group: "windows",
            label: "Outer gaps",
            detail: "Space between the tiled area and the screen edge",
            hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
        },
        {
            key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
            unit: "px",
            group: "windows",
            label: "Border width",
            detail: "Thickness of the gradient border on the focused window",
            hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
        },
        {
            key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
            unit: "px",
            group: "windows",
            label: "Corner radius",
            detail: "Matches the shell's popover radius so windows and panels agree",
            hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
        },
        {
            key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
            group: "windows",
            label: "Unfocused window opacity",
            detail: "Fade windows that do not have focus",
            hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
        },

        // ── Effects ─────────────────────────────────────────────────────────
        {
            key: "blurEnabled", type: "bool", def: true, group: "effects",
            label: "Blur",
            detail: "Blur the desktop behind translucent surfaces",
            hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
        },
        {
            key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
            group: "effects",
            label: "Blur radius",
            detail: "Larger is softer and costs more frame time",
            hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
        },
        {
            key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
            group: "effects",
            label: "Blur passes",
            detail: "More passes look smoother and cost more frame time",
            hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
        },
        {
            key: "shadowEnabled", type: "bool", def: true, group: "effects",
            label: "Window shadows",
            detail: "Lift windows off the wallpaper with a soft shadow",
            hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
        },
        {
            key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
            unit: "px",
            group: "effects",
            label: "Shadow size",
            detail: "How far the shadow spreads from the window edge",
            hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
        },
        {
            key: "glowEnabled", type: "bool", def: true, group: "effects",
            label: "Focus glow",
            detail: "A faint halo behind the focused window",
            hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
        },
        {
            key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
            unit: "px",
            group: "effects",
            label: "Glow size",
            detail: "Kept small deliberately: the gradient border is the signature",
            hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
        },
        {
            key: "animationsEnabled", type: "bool", def: true, group: "effects",
            label: "Animations",
            detail: "Window, workspace, and panel motion",
            hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
        },

        // ── Input ───────────────────────────────────────────────────────────
        {
            key: "keyboardLayout", type: "string", def: "us", group: "input",
            // Reaches an hl.config string, so it is constrained to the shape of
            // an XKB layout list and nothing else.
            pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
            label: "Keyboard layout",
            detail: "XKB layout name, or a comma-separated list to switch between",
            hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
        },
        {
            key: "numlockByDefault", type: "bool", def: true, group: "input",
            label: "Num Lock on login",
            detail: "Turn Num Lock on when the session starts",
            hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
        },
        {
            key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
            unit: "ms",
            group: "input",
            label: "Repeat delay",
            detail: "How long a key is held before it starts repeating",
            hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
        },
        {
            key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
            unit: "/s",
            group: "input",
            label: "Repeat rate",
            detail: "How many characters a second a held key produces",
            hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
        },
        {
            key: "followMouse", type: "enum", def: 1, group: "input",
            label: "Focus follows pointer",
            detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
            options: [
                { value: 0, label: "Never" },
                { value: 1, label: "Click to focus" },
                { value: 2, label: "Sloppy focus" }
            ],
            hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
        },
        {
            key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
            group: "input",
            label: "Pointer speed",
            detail: "Zero is flat, unaccelerated response",
            hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
        },
        {
            key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
            unit: "s",
            group: "input",
            label: "Hide pointer after",
            detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
            // Reported as a float even though it is only ever set to whole
            // seconds. `readAs` describes what getoption answers with, not what
            // the setting means -- getting this wrong makes every write to it
            // look rejected.
            hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
        },

        // ── Night light ─────────────────────────────────────────────────────
        {
            key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
            label: "Night Light",
            detail: "Shift the display warmer to reduce blue light"
        },
        {
            key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
            label: "Schedule automatically",
            detail: "Turn Night Light on and off at the scheduled hours"
        },
        {
            key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
            unit: "K",
            group: "nightLight",
            label: "Color temperature",
            detail: "Lower is warmer"
        },

        // ── Desktop background ──────────────────────────────────────────────
        // Applied through hyprpaper's IPC. Not an hl.config option, so it has
        // no `hypr` block; services/Wallpaper.qml owns applying it.
        {
            key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
            // Reaches hyprpaper as the "<output>,<path>" argument form, so a
            // comma would split it into a different request. Absolute paths
            // only, no commas, no newlines.
            pattern: "^(|/[^,\\n]+)$",
            label: "Wallpaper",
            detail: "Shown on every output"
        },

        // ── Idle, lock, and sleep ───────────────────────────────────────────
        // Written into a generated hypridle config; see scripts/panama-idle.
        // Zero means never for all three.
        {
            key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1,
            unit: "min", group: "idle",
            label: "Turn the screen off after",
            detail: "Blanks the display; nothing is locked yet"
        },
        {
            key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1,
            unit: "min", group: "idle",
            label: "Lock the screen after",
            detail: "Counted from when the session went idle, not from blanking"
        },
        {
            key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5,
            unit: "min", group: "idle",
            label: "Suspend after",
            detail: "This is a desktop, so Panama ships with automatic suspend off"
        },
        {
            key: "lockOnSleep", type: "bool", def: true, group: "idle",
            label: "Lock before sleeping",
            detail: "Requires your password when the machine wakes"
        },

        // ── Night light schedule ────────────────────────────────────────────
        // Hours as decimals, so 17.5 is half past five. Wrapping past midnight
        // is normal here and is what the shipped values do: on at 17:00, off at
        // 10:00 the following morning.
        {
            key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
            group: "nightLight",
            label: "Turns on at",
            detail: "Only used when Night Light follows a schedule"
        },
        {
            key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
            group: "nightLight",
            label: "Turns off at",
            detail: "A time earlier than the start simply means the next morning"
        },

        // ── Colour scheme ───────────────────────────────────────────────────
        // Light is Tokyo Night Day, the official light variant, rather than a
        // palette invented to merely not be dark. Both share the same hues at
        // different lightness, which is what keeps the Prism identity intact
        // across the switch.
        //
        // services/ColorScheme.qml pushes the choice to GTK and to the
        // compositor's border colours, because an application toolbar or a
        // window border still wearing the other scheme is more jarring than
        // either scheme on its own.
        {
            key: "colorScheme", type: "enum", def: "dark", group: "appearance",
            label: "Appearance",
            detail: "Light and dark share one identity, not two themes",
            options: [
                { value: "dark", label: "Dark" },
                { value: "light", label: "Light" }
            ]
        },

        // ── Typography ──────────────────────────────────────────────────────
        // The single largest thing in this desktop that used to be changeable
        // only by editing Theme.qml.
        //
        // interfaceFont is every piece of text a person reads. iconFont is used
        // ONLY to draw glyphs -- workspace pills, the status cluster, search
        // icons -- so it must carry Nerd Font glyphs; a plain monospace family
        // there replaces every icon in the shell with tofu, which is why the
        // picker offers them as a separate, filtered list.
        {
            key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
            // A family name, matched by fontconfig. Constrained because it also
            // reaches hl.config as a string in hypr/looks.lua.
            pattern: "^[A-Za-z0-9 ._-]{1,64}$",
            label: "Interface font",
            detail: "Used for every piece of text in the shell"
        },
        {
            key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
            pattern: "^[A-Za-z0-9 ._-]{1,64}$",
            label: "Icon font",
            detail: "Draws the shell's glyphs, so it must be a Nerd Font"
        },
        {
            key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
            unit: "px", group: "typography",
            label: "Interface text size",
            detail: "The base size the rest of the shell's type scales from"
        },

        // ── Accessibility ───────────────────────────────────────────────────
        // Backed by gsettings so GTK applications agree with the shell, and
        // pushed to the compositor as well where it has its own notion.
        {
            key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4,
            unit: "px", group: "accessibility",
            label: "Pointer size",
            detail: "Applies to the compositor and to applications"
        },
        {
            key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05,
            group: "accessibility",
            label: "Text size",
            detail: "Scales interface text everywhere; 1.00 is the design size"
        },

        // ── Weather ─────────────────────────────────────────────────────────
        {
            key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
            label: "Temperature unit",
            detail: "Choose Fahrenheit or Celsius for the weather card",
            options: [
                { value: "fahrenheit", label: "Fahrenheit" },
                { value: "celsius", label: "Celsius" }
            ]
        },
        {
            key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
            unit: "min", group: "weather",
            label: "Weather refresh",
            detail: "How often Panama updates the current conditions"
        },

        // ── Which GPU the vitals readout tracks ─────────────────────────────
        // A sysfs path rather than a card number, because the number is neither
        // stable across machines nor meaningful. Constrained to the one shape
        // that can be read for utilisation; VitalsWidget hides itself when the
        // path is unreadable, so a stale value degrades to no readout rather
        // than a wrong one.
        {
            key: "gpuBusyPath", type: "string",
            def: "/sys/class/drm/card1/device/gpu_busy_percent",
            group: "vitals", internal: true,
            pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
            label: "Graphics device",
            detail: "Which GPU the graphics readout in the bar measures"
        },

        // ── Weather location ────────────────────────────────────────────────
        // Coordinates rather than a place name, because that is what Open-Meteo
        // takes and it needs no API key. weatherLocation is only the label shown
        // in the UI; it is never sent anywhere, so it can say whatever makes the
        // reading recognisable.
        {
            key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
            group: "weather", internal: true,
            label: "Latitude",
            detail: "Set by choosing a location"
        },
        {
            key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
            group: "weather", internal: true,
            label: "Longitude",
            detail: "Set by choosing a location"
        },
        {
            key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
            internal: true,
            // Display only -- never sent to the weather service.
            pattern: "^[^\\n]{1,64}$",
            label: "Weather location",
            detail: "The place the weather reading is for"
        },

        // ── Vitals refresh ──────────────────────────────────────────────────
        {
            key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
            unit: "ms", group: "vitals",
            label: "Vitals refresh",
            detail: "How often processor, memory, and graphics usage update"
        },

        // ── Notifications ───────────────────────────────────────────────────
        {
            key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
            unit: "ms", group: "notifications",
            label: "Notification duration",
            detail: "How long ordinary notification banners remain visible"
        },
        {
            key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
            unit: "ms", group: "notifications",
            label: "Critical notification duration",
            detail: "Zero keeps critical notification banners visible until dismissed"
        },
        {
            key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
            group: "notifications",
            label: "Notification history",
            detail: "Maximum notifications retained in the notification center"
        },
        {
            key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
            group: "notifications",
            label: "Visible banners",
            detail: "Maximum notification banners shown at once"
        },

        // ── Capture ─────────────────────────────────────────────────────────
        // Directories and encoder arguments are enums rather than free text:
        // both are handed to a recorder process, and an arbitrary string there
        // is a much larger surface than a settings page needs to expose.
        {
            key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
            label: "Screenshot folder",
            detail: "Folder under your home directory for screenshots",
            options: [
                { value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
                { value: "Pictures", label: "Pictures" },
                { value: "Desktop", label: "Desktop" }
            ]
        },
        {
            key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
            label: "Recording folder",
            detail: "Folder under your home directory for screen recordings",
            options: [
                { value: "Videos/Recordings", label: "Videos / Recordings" },
                { value: "Videos", label: "Videos" },
                { value: "Desktop", label: "Desktop" }
            ]
        },
        {
            key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
            group: "capture",
            label: "Recording encoder",
            detail: "Hardware encoding keeps recording off the processor while gaming",
            options: [
                { value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
                { value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
                { value: "-c libx264", label: "CPU x264" }
            ]
        },

        // ── Dock contents ───────────────────────────────────────────────────
        // A "json" value: the ordered list of desktop entry ids pinned to the
        // dock. Kept in the shared store so that reordering the dock is covered
        // by Restore defaults like everything else, rather than living in its
        // own file. The shipped order is the GNOME dash it replaced, with one
        // deliberate substitution: Panama Settings takes the first slot rather
        // than GNOME Settings. Panama now covers what GNOME Settings did for
        // this desktop and delegates the remainder to it by name, so pinning
        // the thing it delegates TO put the fallback in front of the real one.
        // GNOME Settings stays installed and searchable in the launcher.
        {
            key: "dockPinned", type: "json", group: "dock",
            label: "Pinned applications",
            detail: "Applications that stay in the Dock whether or not they are running",
            def: [
                "panama-settings", "kitty", "org.gnome.Nautilus",
                "com.bitwarden.desktop", "org.gnome.Software", "helium",
                "org.mozilla.thunderbird_esr", "com.slack.Slack",
                "app.bluebubbles.BlueBubbles", "rustdesk",
                "io.podman_desktop.PodmanDesktop", "claude-desktop",
                "codex-desktop", "md.obsidian.Obsidian",
                "com.obsproject.Studio", "steam"
            ]
        },

        // ── Keyboard shortcut overrides ─────────────────────────────────────
        // { "<bind description>": "<chord>" }. Only the chord is stored: the
        // action always comes from hypr/keybinds.lua, so an override can move a
        // shortcut but can never make one do something else. The Lua validates
        // each chord and falls back to the shipped one, so a hand-edited file
        // cannot cost you a keymap.
        //
        // Edited through the Input & Shortcuts page rather than as a row, hence
        // internal.
        {
            key: "keybindOverrides", type: "json", def: ({}), group: "input",
            internal: true,
            label: "Keyboard shortcut overrides",
            detail: "Shortcuts you have moved from their shipped chord"
        },

        // ── Display configuration ───────────────────────────────────────────
        // { "<output>": { mode, scale, transform } }, applied by
        // hypr/monitors.lua on top of the shipped values. Colour management and
        // bit depth are deliberately not here: those carry a documented
        // screencopy tradeoff that a settings page cannot explain at the moment
        // you would be changing it.
        {
            key: "displays", type: "json", def: ({}), group: "display",
            internal: true,
            label: "Display configuration",
            detail: "Resolution, scale, and rotation per connected display"
        },

        // ── Per-application notification rules ──────────────────────────────
        // { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
        //
        // Absent means "no rule", which is not the same as a rule that allows
        // everything: a new application must be able to notify without needing
        // an entry written for it first. services/Notifs.qml treats a missing
        // entry as permissive and Do Not Disturb remains an override on top,
        // rather than being duplicated per application.
        {
            key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
            internal: true,
            label: "Application notification rules",
            detail: "Per-application notification and lock-screen visibility preferences"
        },

        // ── Internal ────────────────────────────────────────────────────────
        {
            key: "lastPage", type: "string", def: "home", group: "internal",
            internal: true,
            label: "Last settings page",
            detail: "Restores the page Settings was left on"
        }
    ]

    // key -> entry, built once. Every lookup below goes through this rather than
    // scanning `entries`, since get/set are called from bindings.
    readonly property var byKey: {
        const index = {};
        for (const entry of root.entries)
            index[entry.key] = entry;
        return index;
    }

    readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)

    function spec(key: string): var {
        return root.byKey[key] ?? null;
    }

    function has(key: string): bool {
        return root.byKey[key] !== undefined;
    }

    function defaultFor(key: string): var {
        const entry = root.byKey[key];
        return entry ? entry.def : undefined;
    }

    function defaults(): var {
        const out = {};
        for (const entry of root.entries)
            out[entry.key] = entry.def;
        return out;
    }

    function inGroup(group: string): var {
        return root.entries.filter(entry => entry.group === group && !entry.internal);
    }

    // Entries the compositor owns, used to build one hl.config{} payload.
    function hyprEntries(): var {
        return root.entries.filter(entry => entry.hypr !== undefined);
    }

    // Returns the value coerced into the entry's type and range, or `undefined`
    // if it cannot be represented at all. Out-of-range numbers are clamped
    // rather than rejected: a stale file with a since-narrowed bound should
    // still yield a usable desktop.
    function coerce(key: string, value: var): var {
        const entry = root.byKey[key];
        if (!entry || value === undefined || value === null)
            return undefined;

        switch (entry.type) {
        case "bool":
            if (typeof value === "boolean") return value;
            if (value === "true") return true;
            if (value === "false") return false;
            return undefined;

        case "int":
        case "real": {
            const numeric = Number(value);
            if (!isFinite(numeric)) return undefined;
            const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
            const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
            return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
        }

        case "enum":
            return entry.options.some(option => option.value === value) ? value : undefined;

        case "json":
            // Accepted as-is. Anything JSON.parse produced is representable,
            // and per-field meaning belongs to the owning service rather than
            // here. A scalar is rejected so a corrupt file falls back to the
            // default instead of handing a list-shaped consumer a number.
            return (typeof value === "object") ? value : undefined;

        case "string": {
            const text = typeof value === "string" ? value : String(value);
            // A constrained string is rejected rather than sanitised. Several
            // of these are serialised into an hl.config payload, and quietly
            // stripping characters would turn a typo into a different setting
            // instead of an error the user can see.
            if (entry.pattern && !new RegExp(entry.pattern).test(text))
                return undefined;
            return text;
        }
        }
        return undefined;
    }
}
