From 32b43bdbb3a5835b9599834a80e21faa3d8dcef9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 24 Sep 2026 13:35:20 +0200 Subject: [PATCH] quickshell fixes --- alacritty/catppuccin-mocha.toml | 6 +- fish/completions/claude-account.fish | 100 +++++ fish/config.fish | 3 - hypr/lua/binds/shell.lua | 1 + hypr/lua/env.lua | 7 + hypr/lua/look.lua | 23 +- mimeapps.list | 1 + quickshell/README.md | 182 +++++--- quickshell/ask.json | 11 + quickshell/ask/AskPanel.qml | 416 ++++++++++++++++++ quickshell/bar/ActiveWindow.qml | 4 +- quickshell/bar/Bar.qml | 2 +- quickshell/bar/BarPill.qml | 6 +- quickshell/bar/Clock.qml | 4 +- quickshell/bar/NotifButton.qml | 72 --- quickshell/bar/PowerButton.qml | 42 -- quickshell/bar/Rail.qml | 12 +- quickshell/bar/RailIcon.qml | 40 +- quickshell/bar/Resources.qml | 4 +- quickshell/bar/Sigil.qml | 2 +- quickshell/bar/Status.qml | 117 ----- quickshell/bar/StatusIcon.qml | 108 ----- quickshell/bar/Tray.qml | 33 +- quickshell/bar/Workspaces.qml | 41 +- quickshell/components/Gauge.qml | 120 ----- quickshell/components/ListRow.qml | 118 +++++ quickshell/components/MetricRow.qml | 2 - quickshell/components/NotifCard.qml | 21 +- quickshell/components/P5Backdrop.qml | 6 +- quickshell/components/P5Button.qml | 13 +- quickshell/components/P5Panel.qml | 6 +- quickshell/components/P5Slider.qml | 7 +- quickshell/components/SectionRule.qml | 35 ++ quickshell/components/SplitText.qml | 99 +++-- quickshell/components/TintEffect.qml | 10 +- quickshell/config/Theme.qml | 43 +- quickshell/launcher/ResultRow.qml | 2 +- quickshell/lock/LockFace.qml | 2 +- quickshell/overlays/Osd.qml | 18 +- quickshell/overlays/PowerMenu.qml | 4 +- quickshell/overlays/Toasts.qml | 11 +- quickshell/popouts/AudioInputBand.qml | 4 +- quickshell/popouts/AudioOutputHero.qml | 2 +- quickshell/popouts/AudioPopout.qml | 98 +---- quickshell/popouts/BatteryPopout.qml | 6 +- quickshell/popouts/BtPopout.qml | 134 ++---- quickshell/popouts/CalendarPopout.qml | 18 +- quickshell/popouts/MediaPopout.qml | 26 +- quickshell/popouts/NetPopout.qml | 104 ++--- quickshell/popouts/NotifPopout.qml | 2 +- quickshell/popouts/PopoutHeader.qml | 8 +- quickshell/popouts/PopoutSurface.qml | 6 +- quickshell/popouts/TrayMenuList.qml | 2 +- quickshell/services/Actions.qml | 26 ++ quickshell/services/Ask.qml | 248 +++++++++++ quickshell/services/Battery.qml | 4 +- quickshell/shell.qml | 8 + systemd/user/claude-account-router.service | 12 + systemd/user/claude-headroom@.service | 13 + .../claude-account-router.service | 1 + .../claude-headroom@personal.service | 1 + .../claude-headroom@work.service | 1 + .../enqs-headroom.service | 1 - .../headroom-private.service | 1 - .../headroom-work.service | 1 - .../user/default.target.wants/ydotool.service | 1 + systemd/user/headroom-private.service | 13 - systemd/user/headroom-work.service | 13 - 68 files changed, 1461 insertions(+), 1047 deletions(-) create mode 100644 fish/completions/claude-account.fish create mode 100644 quickshell/ask.json create mode 100644 quickshell/ask/AskPanel.qml delete mode 100644 quickshell/bar/NotifButton.qml delete mode 100644 quickshell/bar/PowerButton.qml delete mode 100644 quickshell/bar/Status.qml delete mode 100644 quickshell/bar/StatusIcon.qml delete mode 100644 quickshell/components/Gauge.qml create mode 100644 quickshell/components/ListRow.qml create mode 100644 quickshell/components/SectionRule.qml create mode 100644 quickshell/services/Ask.qml create mode 100644 systemd/user/claude-account-router.service create mode 100644 systemd/user/claude-headroom@.service create mode 120000 systemd/user/default.target.wants/claude-account-router.service create mode 120000 systemd/user/default.target.wants/claude-headroom@personal.service create mode 120000 systemd/user/default.target.wants/claude-headroom@work.service delete mode 120000 systemd/user/default.target.wants/enqs-headroom.service delete mode 120000 systemd/user/default.target.wants/headroom-private.service delete mode 120000 systemd/user/default.target.wants/headroom-work.service create mode 120000 systemd/user/default.target.wants/ydotool.service delete mode 100644 systemd/user/headroom-private.service delete mode 100644 systemd/user/headroom-work.service diff --git a/alacritty/catppuccin-mocha.toml b/alacritty/catppuccin-mocha.toml index 08a7e3a..62b1881 100644 --- a/alacritty/catppuccin-mocha.toml +++ b/alacritty/catppuccin-mocha.toml @@ -4,9 +4,11 @@ foreground = "#cdd6f4" dim_foreground = "#7f849c" bright_foreground = "#cdd6f4" +# Cursor in the shell accent (Mocha blue) rather than stock rosewater, so the +# terminal caret matches the focus colour everywhere else on the desktop. [colors.cursor] -text = "#1e1e2e" -cursor = "#f5e0dc" +text = "#11111b" +cursor = "#89b4fa" [colors.vi_mode_cursor] text = "#1e1e2e" diff --git a/fish/completions/claude-account.fish b/fish/completions/claude-account.fish new file mode 100644 index 0000000..03e73c2 --- /dev/null +++ b/fish/completions/claude-account.fish @@ -0,0 +1,100 @@ +# Print an optspec for argparse to handle cmd's options that are independent of any subcommand. +function __fish_claude_account_global_optspecs + string join \n h/help V/version +end + +function __fish_claude_account_needs_command + # Figure out if the current invocation already has a command. + set -l cmd (commandline -opc) + set -e cmd[1] + argparse -s (__fish_claude_account_global_optspecs) -- $cmd 2>/dev/null + or return + if set -q argv[1] + # Also print the command, so this can be used to figure out what it is. + echo $argv[1] + return 1 + end + return 0 +end + +function __fish_claude_account_using_subcommand + set -l cmd (__fish_claude_account_needs_command) + test -z "$cmd" + and return 1 + contains -- $cmd[1] $argv +end + +complete -c claude-account -n "__fish_claude_account_needs_command" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_needs_command" -s V -l version -d 'Print version' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "status" -d 'Show every account, every proxy, every name and everything that is wrong' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "list" -d 'List the accounts, one per line' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "switch" -d 'Make an account the one `claude` runs as' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "add" -d 'Register another account, with a profile and a proxy of its own' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "remove" -d 'Forget an account. Its profile directory is left where it is' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "current" -d 'Print the name of the active account' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "dir" -d 'Print the profile directory of an account, for `CLAUDE_CONFIG_DIR`' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "env" -d 'Print the shell lines that put this shell into an account' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "url" -d 'Print the address a name resolves to' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "setup" -d 'Install everything at once: the wrapper, a proxy per account, the router' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "adopt" -d 'Take over the proxy services that were written by hand' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "doctor" -d 'Report every problem and exit non-zero when there is one' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "shim" -d 'The `claude` wrapper in ~/.local/bin' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "headroom" -d 'The proxy of one account' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "router" -d 'The router that resolves .headroom.localhost' +complete -c claude-account -n "__fish_claude_account_needs_command" -f -a "completions" -d 'Tab completion, with the account names in it' +complete -c claude-account -n "__fish_claude_account_using_subcommand status" -s v -l verbose -d 'Add the last lines each proxy logged' +complete -c claude-account -n "__fish_claude_account_using_subcommand status" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand list" -l names -d 'Print the names alone, for scripts and for tab completion' +complete -c claude-account -n "__fish_claude_account_using_subcommand list" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand switch" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand add" -l dir -d 'A profile directory elsewhere' -r -F +complete -c claude-account -n "__fish_claude_account_using_subcommand add" -l port -d 'A port for its proxy; the next free one by default' -r +complete -c claude-account -n "__fish_claude_account_using_subcommand add" -l switch -d 'Make it the account `claude` runs as straight away' +complete -c claude-account -n "__fish_claude_account_using_subcommand add" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand remove" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand current" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand dir" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand env" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand url" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand setup" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand adopt" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand doctor" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand shim; and not __fish_seen_subcommand_from install uninstall" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand shim; and not __fish_seen_subcommand_from install uninstall" -f -a "install" -d 'Write ~/.local/bin/claude' +complete -c claude-account -n "__fish_claude_account_using_subcommand shim; and not __fish_seen_subcommand_from install uninstall" -f -a "uninstall" -d 'Remove it again' +complete -c claude-account -n "__fish_claude_account_using_subcommand shim; and __fish_seen_subcommand_from install" -l force -d 'Overwrite a `claude` that this tool did not write' +complete -c claude-account -n "__fish_claude_account_using_subcommand shim; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand shim; and __fish_seen_subcommand_from uninstall" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "install" -d 'Write the service for an account and start it' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "start" -d 'Start the proxy of an account' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "stop" -d 'Stop it' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "restart" -d 'Stop and start it' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "uninstall" -d 'Stop it and remove its service' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "port" -d 'Give an account another port, and write it everywhere it is named' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and not __fish_seen_subcommand_from install start stop restart uninstall port logs" -f -a "logs" -d 'Print what one proxy logged' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from start" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from stop" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from restart" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from uninstall" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from port" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from logs" -s n -l lines -d 'How many lines' -r +complete -c claude-account -n "__fish_claude_account_using_subcommand headroom; and __fish_seen_subcommand_from logs" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and not __fish_seen_subcommand_from serve install uninstall port list" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and not __fish_seen_subcommand_from serve install uninstall port list" -f -a "serve" -d 'Serve the names until stopped' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and not __fish_seen_subcommand_from serve install uninstall port list" -f -a "install" -d 'Install the router as a user service' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and not __fish_seen_subcommand_from serve install uninstall port list" -f -a "uninstall" -d 'Stop it and remove its service' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and not __fish_seen_subcommand_from serve install uninstall port list" -f -a "port" -d 'Move the router to another port' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and not __fish_seen_subcommand_from serve install uninstall port list" -f -a "list" -d 'List the names that resolve' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and __fish_seen_subcommand_from serve" -l port -r +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and __fish_seen_subcommand_from serve" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and __fish_seen_subcommand_from uninstall" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and __fish_seen_subcommand_from port" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand router; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help' +complete -c claude-account -n "__fish_claude_account_using_subcommand completions" -l install -d 'Write the file where the shell reads it, instead of printing it' +complete -c claude-account -n "__fish_claude_account_using_subcommand completions" -s h -l help -d 'Print help' + +# The accounts as they are right now, asked for when Tab is pressed. +complete -c claude-account -n '__fish_seen_subcommand_from switch dir env url remove start stop restart logs install uninstall port' -f -a '(claude-account list --names)' diff --git a/fish/config.fish b/fish/config.fish index d52835a..da47782 100644 --- a/fish/config.fish +++ b/fish/config.fish @@ -38,6 +38,3 @@ if status is-login && test -z "$DISPLAY" -a -z "$WAYLAND_DISPLAY" end end -# Claude environments -alias claude="CLAUDE_CONFIG_DIR=~/.claude command claude" -alias claude-work="CLAUDE_CONFIG_DIR=~/.claude-work command claude" diff --git a/hypr/lua/binds/shell.lua b/hypr/lua/binds/shell.lua index b58cbf8..6ec3d27 100644 --- a/hypr/lua/binds/shell.lua +++ b/hypr/lua/binds/shell.lua @@ -8,6 +8,7 @@ end shell("lock", { key = mod .. " + CTRL + L", description = "lock screen" }) shell("power", { key = mod .. " + P", description = "power menu" }) +shell("ask", { key = mod .. " + A", description = "ask claude" }) shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" }) shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" }) shell("reloadConfig", { key = mod .. " + SHIFT + R", description = "reload shell config" }) diff --git a/hypr/lua/env.lua b/hypr/lua/env.lua index 87fcf9f..d544ed0 100644 --- a/hypr/lua/env.lua +++ b/hypr/lua/env.lua @@ -29,3 +29,10 @@ hl.env("EDITOR", "nvim") -- hl.env("INTEL_DEBUG", "noccs") -- hl.env("WLR_DRM_NO_ATOMIC", "1") -- hl.env("debug:full_cm_proto", "true") + + +-- headroom + +hl.env("HEADROOM_ROLLOUT_CHANNEL", "beta") + +hl.env("HEADROOM_OUTPUT_SHAPER", "1") diff --git a/hypr/lua/look.lua b/hypr/lua/look.lua index 005c041..2df09f5 100644 --- a/hypr/lua/look.lua +++ b/hypr/lua/look.lua @@ -1,4 +1,5 @@ --- Takemi — Persona 5 flavoured theme, matched to ~/.config/quickshell. +-- Persona × Mocha — Persona 5 shapes on the Catppuccin Mocha palette, matched +-- to ~/.config/quickshell. -- -- Everything static about how the desktop is painted. Motion lives next door -- in lua/animations.lua. @@ -15,7 +16,10 @@ hl.config({ col = { active_border = c.activeBorder, - inactive_border = c.outline, + -- Surface-level, not outline: an idle window's frame is structure, + -- and the outline grey was bright enough to compete with the + -- focused border. + inactive_border = c.surfaceAlt, }, resize_on_border = false, @@ -35,13 +39,16 @@ hl.config({ active_opacity = 1.0, inactive_opacity = 1.0, + -- A hard crust offset, the same printed drop the shell's panels + -- carry, rather than a soft blur. Sharp with a 1px range leaves a + -- clean band down the right and bottom edges only. shadow = { enabled = true, - range = 18, - render_power = 3, - offset = { 0, 4 }, - color = "rgba(000000ee)", - color_inactive = "rgba(00000088)", + sharp = true, + range = 1, + offset = { 4, 4 }, + color = c.ink, + color_inactive = c.ink, }, -- Off. Blur is the signature of the glass-and-frost look, which is the @@ -66,7 +73,7 @@ hl.config({ group = { col = { border_active = c.activeBorder, - border_inactive = c.outline, + border_inactive = c.surfaceAlt, }, groupbar = { diff --git a/mimeapps.list b/mimeapps.list index 633a0e4..9f9e870 100644 --- a/mimeapps.list +++ b/mimeapps.list @@ -22,6 +22,7 @@ x-scheme-handler/msteams=teams-for-linux.desktop x-scheme-handler/slack=slack.desktop audio/flac=mpv.desktop x-scheme-handler/codex=chatgpt.desktop +x-scheme-handler/proton-inbox=proton-mail.desktop [Added Associations] application/pdf=org.pwmt.zathura-pdf-poppler.desktop; diff --git a/quickshell/README.md b/quickshell/README.md index 9ac12c1..32e6f97 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -1,20 +1,21 @@ -# Takemi +# Takemi — Persona × Mocha -A Persona 5 flavoured Quickshell desktop for Hyprland. Crimson, black and white, -with no gradients anywhere. Everything leans by the same 14°, corners are -chopped rather than rounded, and every surface is one continuous slab divided by -hairlines rather than a cluster of floating cards. +A Persona 5 flavoured Quickshell desktop for Hyprland, painted in Catppuccin +Mocha. Blue for anything live, mauve for anything wrong, the Mocha greys for +everything else, and no gradients anywhere. Everything leans by the same 14°, +corners are chopped rather than rounded, and every surface is one continuous +slab divided by hairlines rather than a cluster of floating cards. Replaces Waybar, dunst and hyprlock. ## Running ```sh -qs # starts the shell (Hyprland does this via autostart.conf) +qs # starts the shell (Hyprland does this via autostart.lua) qs -p lockpreview.qml # the lock screen in an ordinary window, for styling ``` -Hyprland starts it from `~/.config/hypr/modules/autostart.conf`. +Hyprland starts it from `~/.config/hypr/lua/autostart.lua`. ## Layout @@ -41,8 +42,8 @@ cost 4.4% of the height on top of what the bar already takes. Each permanent surface is a single continuous `ChromeSurface` with modules divided by sheared hairlines. `BarPill` is chromeless by default — a module is -*content inside* a surface, not a surface of its own — and marks hover with a -crimson underline. Pass `standalone: true` for the rare thing that really is its +*content inside* a surface, not a surface of its own — and marks hover with an +accent underline. Pass `standalone: true` for the rare thing that really is its own panel. The shell uses three deliberate surface tiers. **Chrome** (`ChromeSurface`) is @@ -55,7 +56,7 @@ it is never used as decoration inside ordinary chrome or popouts. ``` shell.qml entrypoint — bar, rail, power menu, toasts, OSD per screen config/ palette, type scale, geometry, motion curves -components/ the P5 primitives: panels, gauges, sliders, split-ink text +components/ the P5 primitives: panels, sliders, list rows, print-shadowed text services/ data sources, all singletons bar/ the two surfaces and their modules popouts/ hover panels hanging off rail and bar modules @@ -106,12 +107,13 @@ dismisses the screen. ## Keybinds -Set in `~/.config/hypr/modules/keybinds.conf`: +Set in `~/.config/hypr/lua/binds/shell.lua`: - `Super+Ctrl+L` — lock - `Super+P` — power menu - `Super+Shift+N` — toggle do not disturb - `Super+Ctrl+Shift+N` — clear notifications +- `Super+A` — ask panel - `Super+Shift+R` — reload the shell All of these go through Quickshell's IPC, so they work from any script: @@ -120,10 +122,52 @@ All of these go through Quickshell's IPC, so they work from any script: qs ipc call shell lock qs ipc call shell power qs ipc call shell toggleDnd +qs ipc call shell ask +qs ipc call shell askAbout "why is the sky blue" qs ipc call shell clearNotifications qs ipc call shell reloadConfig ``` +## Ask + +`Super+A` drops a panel off the top edge and hands whatever you type to +`claude -p`, streamed token by token. It is the subscription that answers — +the CLI already holds the credentials, so nothing here has an API key and +nothing here has to be given one. + +The panel is a chat, not a coding session. Settings sources, MCP servers and +project files are switched off by default, which keeps a question asked from +the desktop from arriving with the last project's hooks, `CLAUDE.md` and tone +attached. The thread survives closing the panel; `Ctrl+L` starts a new one. + +| Key | Does | +|---|---| +| `Enter` | ask | +| `Shift+Enter` | newline | +| `Ctrl+M` | next model, written back to `ask.json` | +| `Ctrl+Y` | copy the last answer | +| `Ctrl+L` | new conversation | +| `Ctrl+S` | stop the answer | +| `Escape` | hide, keeping the thread | + +Configured in `ask.json`, watched and reloaded live: + +```json +{ + "model": "sonnet", + "systemPrompt": "…", + "keepContext": true, + "settingSources": "", + "allowedTools": ["WebSearch", "WebFetch"], + "disallowedTools": ["Bash", "Edit", "Write"], + "workingDirectory": "/home/narl", + "maxTurns": 6 +} +``` + +`settingSources` is the escape hatch: set it to `"user,project,local"` and the +panel becomes your ordinary Claude Code, hooks and all. + ## Data sources Hardware telemetry comes from `fluxo`, whose hardware modules are configured to @@ -165,69 +209,81 @@ The display face is Archivo Black, set italic almost everywhere. Change ## Palette discipline -Crimson, black, white. That is the whole palette. +Catppuccin Mocha, spent on two hues. **Hue is meaning**; nothing gets a colour +for decoration. -**Changing the accent.** The colour is written in exactly three places, one per -program: +- **Blue (`accent`, Mocha blue)** means live: the focused workspace, the + selected row, the hovered control, a channel that is on, an unread badge. +- **Mauve (`alarm`, Mocha mauve)** means wrong or destructive: critical battery, + an urgent workspace, a critical notification, reboot and shut down. +- **Grey** is everything else, and that includes *off*. A muted speaker, a + muted mic and Do Not Disturb drop to `muted` grey with their off glyph. They + used to be painted in the accent, which made a silenced channel look the most + alive thing on the rail. +- **Crust (`ink`)** is structure: the hard keyline, the offset under display + type and panels, the text on an accent slab. -| File | Line | Covers | -|---|---|---| -| `quickshell/config/Theme.qml` | `readonly property color accent: "#ff2d40"` | the shell | -| `hypr/modules/takemi.conf` | `$accent = rgb(ff2d40)` | window borders, groups | -| `alacritty/takemi.toml` | `red`, cursor, selection | the terminal | +**Selection is an inverted slab.** Focus and selection fill with the accent and +print their text in crust (about 9:1). Hover stays on the surface tone with an +accent mark. Destructive-active is the same slab in mauve. The `treatment*` +tokens in `Theme.qml` encode all of this; use them rather than picking colours +per widget. -Everything else derives. `accentDim` and `accentSoft` come off `accent` via -`Qt.darker`/`Qt.lighter`, `glow` (the hover highlight) *is* `accent`, and every -Hyprland border, group indicator and focus state references `$accent`. Change -those lines and the whole setup recolours — swap in Persona 4's yellow or -Persona 3's blue and nothing else needs touching. +**Print, not blur.** Display text (`SplitText`) carries one hard crust offset. +The blue and mauve ghosts appear only while `glitch()` runs. Drawn permanently +1.6px off the body, they read as a focus problem on Mocha's pastels rather than +as misregistered print. The lock screen and the power menu are the exceptions; +they are the cinematic surfaces and keep their split. + +**Texture is tiered.** Chrome (bar, rail, tooltips) and transient panels +(popouts, OSD, toasts) are flat. Stripes and halftone live only in +`P5Backdrop`, behind the lock screen and the power menu. + +**Type has a floor.** Nothing renders below `Theme.fsMicro` (10px). + +**Changing the palette.** The colours are written once per program: + +| File | Covers | +|---|---| +| `quickshell/config/Theme.qml` | the shell: `accent`, `alarm` and the neutral ramp | +| `hypr/lua/palette.lua` | window borders, groups, shadows | +| `alacritty/catppuccin-mocha.toml` | the terminal (stock Mocha, cursor in the accent) | + +Everything else derives. `accentDim` and `accentSoft` come off `accent`, `glow` +*is* `accent`, and `primary` is plain `text`. `Theme.heat()` is a continuous +text → blue → mauve ramp, so a CPU figure that has gone mauve means the same +thing as a battery that has gone mauve. Tray icons are flattened to one colour +(subtext, the accent on hover) for the same reason: app brand colours wreck the +palette faster than anything else in the shell. nm-applet and blueman are +hidden from the tray, since the rail already shows network and bluetooth, but +keep running as the secret and pairing agents. ## The rest of the desktop -- **Alacritty** — `alacritty/takemi.toml`, selected by the `import` in - `alacritty.toml`. The Catppuccin files are left in place, just unreferenced. - Near-black ground, white text, crimson cursor and selection. The sixteen ANSI - slots keep their hues because syntax highlighters need to tell them apart, but - every one is desaturated so nothing competes with the accent. -- **Neovim** — `nvim/lua/takemi.lua`, loaded through `nvim/colors/takemi.lua` - so `:colorscheme takemi` and `require("takemi").lualine()` both work. Same - palette, same rule: crimson is spent only on keywords, types and errors, - functions are plain white-bold, and `terminal_color_0..15` mirror - `takemi.toml` slot for slot so `:terminal` matches the shell around it. -- **Fish** — `fish/conf.d/takemi_theme.fish`, with a preview copy in - `fish/themes/Takemi.theme` for `fish_config`. Replaced the Catppuccin - `fish_frozen_theme.fish` that fish 4.3 generated on upgrade. The prompt arrow - is white and turns crimson on a non-zero exit — the shell's one "wrong" - signal, and so the one place that earns the accent. +- **Hyprland** — `hypr/lua/look.lua`. No rounding, no blur, both opacities at + `1.0`. The active border runs blue into lavender and the inactive border is + `surfaceAlt`. The window shadow is a sharp 4px crust offset, the same printed + drop the shell's panels carry, not a soft glow. +- **Alacritty** — `alacritty/catppuccin-mocha.toml`, selected by the `import` + in `alacritty.toml`. Stock Mocha except for the cursor, which is the accent. + `takemi.toml` is left in place, unreferenced. +- **Neovim** — the `catppuccin` plugin, flavour mocha, configured in + `nvim/lua/plugins/ui.lua`. Blue is the accent there too. `colors/takemi.lua` + is left in place, unused. +- **Fish** — `fish/conf.d/takemi_theme.fish` is Catppuccin Mocha's fish theme + with the Takemi selection rows: a solid accent block with crust punched out. - **The launcher** — no external theming to keep in step, because it is part of this shell (`launcher/`). Fuzzel is gone; `takemi-dmenu` stands in for `fuzzel --dmenu` where a script needs a picker. - **No glass anywhere.** Alacritty is `opacity = 1.0, blur = false`, and - Hyprland's `decoration:blur` is disabled with both opacities at `1.0`. Blur is - the signature of the frosted-glass look, which is the opposite of flat ink on - flat ground — and with nothing translucent left it was costing three render - passes for no visible result. + Hyprland's `decoration:blur` is disabled. Blur is the signature of the + frosted-glass look, which is the opposite of flat ink on flat ground — and + with nothing translucent left it was costing three render passes for no + visible result. -White is the resting voice — text, icons, idle indicators. Crimson is the only -accent and always means one thing: active, or wrong. Black is structure, used as -a hard keyline rather than as a soft shadow. **There are no gradients in this -shell**; every fill is flat. `Theme.sheen` still exists as a no-op property -because a teal-to-transparent wash on every panel was the single biggest source -of the murky blue cast the design used to have. - -`primary`, `glow`, `blue`, `ok` and `warn` survive in `Theme.qml` only as -aliases onto white, crimson and grey, so the existing call sites keep working — -don't introduce new uses. `warn` used to alias `accentSoft`, which put a -washed-out pink-red wherever something was merely notable; there is no -half-crimson in this palette, so it aliases the real `accent` now and anything -merely informational (brightness, most readouts) is white instead. `Theme.heat()` is a continuous white→crimson ramp, so -a CPU figure that has gone red means the same thing as a battery that has gone -red. Tray icons are force-flattened to a single colour for the same reason: app -brand colours wreck the palette faster than anything else in the shell. - -The wallpaper is teal. The UI used to be teal too, and the two fought each other -into a blue-grey mush. Going monochrome-plus-red is what lets the wallpaper be -the colour in the composition. +There are **no gradients in this shell**; every fill is flat. `Theme.sheen` +still exists as a no-op property because a teal-to-transparent wash on every +panel was once the biggest source of murk in the design. ## Geometry discipline diff --git a/quickshell/ask.json b/quickshell/ask.json new file mode 100644 index 0000000..4fbfe5f --- /dev/null +++ b/quickshell/ask.json @@ -0,0 +1,11 @@ +{ + "model": "sonnet", + "fallbackModel": "", + "systemPrompt": "You are a desktop answer panel. Answer the question directly and compactly, in markdown, with no preamble. Prefer a few sentences over a list, and a list over an essay. Say plainly when you do not know.", + "keepContext": true, + "settingSources": "", + "allowedTools": ["WebSearch", "WebFetch"], + "disallowedTools": ["Bash", "Edit", "Write", "NotebookEdit", "Agent", "Task"], + "workingDirectory": "/home/narl", + "maxTurns": 6 +} diff --git a/quickshell/ask/AskPanel.qml b/quickshell/ask/AskPanel.qml new file mode 100644 index 0000000..6dbfcb6 --- /dev/null +++ b/quickshell/ask/AskPanel.qml @@ -0,0 +1,416 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import "root:/config" +import "root:/components" +import "root:/popouts" +import "root:/services" + +// The ask panel. It comes down off the top edge, takes the keyboard, and hands +// a question to Claude. +// +// Deliberately not the launcher: the launcher is a list you pick from in one +// keystroke, this is a conversation you read. The shape says so — full width of +// the question, answer below it, and the thing drops from the top rather than +// rising into the middle so the two are never mistaken for each other mid-type. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + // One screen at a time, latched at open — same reason as the launcher: the + // window takes focus, and a prompt should not hop monitors mid-question. + readonly property bool open: Actions.askOpen + && Actions.askScreen === modelData.name + visible: root.open || closeAnim.running + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "takemi-ask" + WlrLayershell.keyboardFocus: root.open + ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.None + + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + readonly property int panelWidth: Math.min(860, root.width - Theme.padXL * 2) + readonly property int maxTranscript: Math.round(root.height * 0.55) + + onOpenChanged: { + if (root.open) { + PopupCoordinator.dismissAll(); + input.text = Actions.askSeed; + openAnim.restart(); + input.forceActiveFocus(); + input.cursorPosition = input.text.length; + if (Actions.askSeed !== "") root.send(); + } else { + closeAnim.restart(); + } + } + + function send(): void { + const q = input.text.trim(); + if (q === "" || Ask.busy) return; + Ask.ask(q); + input.text = ""; + } + + function copyLast(): void { + for (let i = Ask.turns.length - 1; i >= 0; i--) { + if (Ask.turns[i].role === "assistant" && Ask.turns[i].text !== "") { + Actions.copyText(Ask.turns[i].text); + return; + } + } + } + + // -------------------------------------------------------------- backdrop + Rectangle { + id: backdrop + anchors.fill: parent + color: Theme.alpha(Theme.base, 0.6) + opacity: 0 + + TapHandler { onTapped: Actions.closeAsk() } + } + + // ----------------------------------------------------------------- panel + Item { + id: shell + + width: root.panelWidth + height: header.height + divider.height + body.height + hint.height + anchors.horizontalCenter: parent.horizontalCenter + y: 0 + + opacity: 0 + // The drop. The offset is animated, never bound: binding it to the + // panel's own height means every height change — every streamed line — + // snaps the panel back off the top of the screen. + transform: Translate { id: drop; y: 0 } + + PopoutSurface { + anchors.fill: parent + tone: Ask.busy ? Theme.accent : Theme.primary + padding: 0 + slashWidth: Ask.busy ? 8 : 5 + + // -------------------------------------------------------- header + Item { + id: header + width: parent.width + height: 62 + anchors.top: parent.top + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.padL + anchors.rightMargin: Theme.padL + spacing: Theme.padM + + // Pulses rather than spins. Native text rendering is + // subpixel-antialiased, so a rotated glyph smears into + // colour fringes; scale keeps it on the pixel grid. + Icon { + anchors.verticalCenter: parent.verticalCenter + text: "󰭹" + color: Ask.busy ? Theme.accent : Theme.primary + font.pixelSize: Theme.fsTitle + pulsing: Ask.busy + pulseScale: 1.12 + } + + TextInput { + id: input + anchors.verticalCenter: parent.verticalCenter + width: parent.width - x - modelChip.width - Theme.padM + color: Theme.text + font.family: Theme.fontMono + font.pixelSize: Theme.fsLarge + selectionColor: Theme.accent + selectedTextColor: Theme.ink + clip: true + focus: true + renderType: Text.NativeRendering + + Keys.priority: Keys.BeforeItem + Keys.onPressed: event => root.handleKey(event) + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: input.text === "" + text: Ask.busy ? "thinking…" + : Ask.turns.length > 0 ? "follow up…" : "ask anything" + color: Theme.muted + font: input.font + renderType: Text.NativeRendering + } + } + + // The model, which is also the control for it: Ctrl+M walks + // the ring and writes the choice back to ask.json, so the + // panel and the file never disagree. + Item { + id: modelChip + anchors.verticalCenter: parent.verticalCenter + width: modelText.width + Theme.padM * 2 + height: 24 + + Skew { + anchors.fill: parent + color: Theme.surface + } + + Text { + id: modelText + anchors.centerIn: parent + text: Ask.model.toUpperCase() + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Ask.cycleModel() + } + } + } + } + + Skew { + id: divider + anchors.top: header.bottom + width: parent.width + height: Theme.stroke + color: Theme.alpha(Theme.outline, 0.8) + lean: 0 + } + + // --------------------------------------------------- transcript + Item { + id: body + anchors.top: divider.bottom + width: parent.width + height: Math.min(transcript.contentHeight + Theme.padM * 2, + root.maxTranscript) + visible: Ask.turns.length > 0 || Ask.error !== "" + clip: true + + Behavior on height { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo } + } + + ListView { + id: transcript + anchors.fill: parent + anchors.margins: Theme.padM + anchors.leftMargin: Theme.padL + anchors.rightMargin: Theme.padL + model: Ask.turns + spacing: Theme.padM + boundsBehavior: Flickable.StopAtBounds + + // Stay at the bottom while an answer streams in, but leave + // the view alone the moment it is scrolled by hand. + property bool pinned: true + onMovementEnded: pinned = atYEnd + onContentHeightChanged: if (pinned) positionViewAtEnd() + + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Column { + required property var modelData + width: transcript.width + spacing: Theme.padS + + Text { + visible: modelData.role === "user" + text: "YOU" + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 3 + renderType: Text.NativeRendering + } + + Text { + width: parent.width + text: modelData.text + textFormat: modelData.role === "assistant" + ? Text.MarkdownText : Text.PlainText + color: modelData.role === "user" ? Theme.subtext : Theme.text + linkColor: Theme.accent + font.family: Theme.fontMono + font.pixelSize: Theme.fsBody + wrapMode: Text.Wrap + renderType: Text.NativeRendering + onLinkActivated: link => Actions.run(["xdg-open", link]) + } + } + + footer: Text { + width: transcript.width + visible: Ask.error !== "" + topPadding: visible ? Theme.padM : 0 + text: Ask.error + color: Theme.alarm + font.family: Theme.fontMono + font.pixelSize: Theme.fsSmall + wrapMode: Text.Wrap + renderType: Text.NativeRendering + } + } + } + + // -------------------------------------------------------- hints + Item { + id: hint + anchors.top: body.visible ? body.bottom : divider.bottom + width: parent.width + height: 28 + + Text { + anchors.left: parent.left + anchors.leftMargin: Theme.padL + anchors.verticalCenter: parent.verticalCenter + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 3 + renderType: Text.NativeRendering + text: Ask.busy + ? "⏎ QUEUE CTRL+S STOP ESC HIDE" + : "⏎ ASK CTRL+M MODEL CTRL+Y COPY CTRL+L NEW ESC HIDE" + } + + Text { + anchors.right: parent.right + anchors.rightMargin: Theme.padL + anchors.verticalCenter: parent.verticalCenter + visible: Ask.turns.length > 0 + text: Math.floor(Ask.turns.length / 2) + " TURN" + + (Ask.turns.length > 2 ? "S" : "") + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 1 + renderType: Text.NativeRendering + } + } + } + } + + // ------------------------------------------------------------------ keys + function handleKey(event: var): void { + if (!root.open) return; + + const ctrl = (event.modifiers & Qt.ControlModifier) !== 0; + + if (event.key === Qt.Key_Escape || (ctrl && event.key === Qt.Key_C)) { + // Hide, do not clear. The thread outlives the panel so that closing + // it to read something and reopening to ask about that is one + // conversation, not two. + Actions.closeAsk(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + // Shift+Enter is a newline; a multi-line question is still one + // question. Plain Enter sends. + if ((event.modifiers & Qt.ShiftModifier) === 0) { + root.send(); + event.accepted = true; + } + return; + } + + if (ctrl && event.key === Qt.Key_L) { + Ask.clear(); + input.text = ""; + event.accepted = true; + return; + } + + if (ctrl && event.key === Qt.Key_S) { + Ask.stop(); + event.accepted = true; + return; + } + + if (ctrl && event.key === Qt.Key_M) { + Ask.cycleModel(); + event.accepted = true; + return; + } + + if (ctrl && event.key === Qt.Key_Y) { + root.copyLast(); + event.accepted = true; + return; + } + + if (ctrl && event.key === Qt.Key_U) { + input.text = ""; + event.accepted = true; + return; + } + + // The transcript scrolls on the arrows, because the input is one line + // and has nowhere for them to go. + if (event.key === Qt.Key_PageDown || (ctrl && event.key === Qt.Key_J)) { + transcript.flick(0, -900); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_PageUp || (ctrl && event.key === Qt.Key_K)) { + transcript.flick(0, 900); + event.accepted = true; + return; + } + } + + // ------------------------------------------------------------- animation + ParallelAnimation { + id: openAnim + NumberAnimation { + target: backdrop; property: "opacity" + to: 1; duration: Theme.durBase; easing.type: Easing.OutQuad + } + NumberAnimation { + target: shell; property: "opacity" + to: 1; duration: Theme.durFast + } + NumberAnimation { + target: drop; property: "y" + from: -shell.height; to: 0 + duration: Theme.durSlow; easing.type: Easing.OutBack + } + } + + ParallelAnimation { + id: closeAnim + NumberAnimation { + target: backdrop; property: "opacity" + to: 0; duration: Theme.durFast + } + NumberAnimation { + target: shell; property: "opacity" + to: 0; duration: Theme.durFast + } + NumberAnimation { + target: drop; property: "y" + to: -shell.height; duration: Theme.durFast; easing.type: Easing.InQuad + } + } +} diff --git a/quickshell/bar/ActiveWindow.qml b/quickshell/bar/ActiveWindow.qml index 3dc263b..2bf6657 100644 --- a/quickshell/bar/ActiveWindow.qml +++ b/quickshell/bar/ActiveWindow.qml @@ -56,7 +56,7 @@ Item { renderType: Text.NativeRendering } - // Crimson duplicate: visible only during the landing transition. The + // Accent duplicate: visible only during the landing transition. The // small opposing offset gives the title Persona's rough print snap // without leaving permanent motion in the bar. Text { @@ -119,7 +119,7 @@ Item { landedToplevel = toplevel; } - // A quick crimson wipe under the title on focus change. + // A quick accent wipe under the title on focus change. Rectangle { id: wipe anchors.bottom: parent.bottom diff --git a/quickshell/bar/Bar.qml b/quickshell/bar/Bar.qml index 57eca11..abe04a5 100644 --- a/quickshell/bar/Bar.qml +++ b/quickshell/bar/Bar.qml @@ -126,7 +126,7 @@ PanelWindow { anchors.centerIn: parent width: parent.ruleHeight * Theme.skew + parent.ruleWeight height: parent.ruleHeight - color: Theme.alpha(Theme.outline, 0.9) + color: Theme.alpha(Theme.outline, 0.5) } } } diff --git a/quickshell/bar/BarPill.qml b/quickshell/bar/BarPill.qml index 05161ee..d788484 100644 --- a/quickshell/bar/BarPill.qml +++ b/quickshell/bar/BarPill.qml @@ -5,7 +5,7 @@ import "root:/components" // Shared chrome for every bar module. // // The bar is one continuous slab, so by default a module draws no panel of its -// own — it is content inside the bar's surface, and marks hover with a crimson +// own — it is content inside the bar's surface, and marks hover with an accent // underline rather than by lighting up a card. That is what stops the bar from // reading as a row of floating islands. // @@ -34,7 +34,7 @@ Item { // // The rule used to be drawn into the same box as the content, so anything // taller than about 32px — the media pill's art, the metric meters — had a - // crimson line struck through its bottom edge on hover. Reserving the band + // accent line struck through its bottom edge on hover. Reserving the band // and centring content in what is left is what keeps the rule under the // module instead of across it. // The band reserved the full rule plus its inset — 7px off a 40px module, @@ -102,7 +102,7 @@ Item { anchors.bottomMargin: root.ruleBand } - // Hover mark for in-bar modules: a crimson rule under the content, wiping + // Hover mark for in-bar modules: an accent rule under the content, wiping // out from the leading edge. Sheared to match the one angle everything uses. Item { visible: !root.standalone && root.interactive diff --git a/quickshell/bar/Clock.qml b/quickshell/bar/Clock.qml index f7325dd..38f4175 100644 --- a/quickshell/bar/Clock.qml +++ b/quickshell/bar/Clock.qml @@ -36,7 +36,8 @@ BarPill { color: Theme.accent font.family: Theme.fontMono font.pixelSize: Theme.fsMicro - font.letterSpacing: 2.5 + font.weight: Font.Bold + font.letterSpacing: 3 renderType: Text.NativeRendering } @@ -98,7 +99,6 @@ BarPill { text: Qt.formatDateTime(clock.date, "HH:mm") pixelSize: Theme.fsTitle horizontalAlignment: Text.AlignHCenter - split: 1.4 } } diff --git a/quickshell/bar/NotifButton.qml b/quickshell/bar/NotifButton.qml deleted file mode 100644 index b0854e8..0000000 --- a/quickshell/bar/NotifButton.qml +++ /dev/null @@ -1,72 +0,0 @@ -import QtQuick -import Quickshell -import "root:/config" -import "root:/components" -import "root:/services" -import "root:/popouts" - -// Notification bell with an unread badge. Click opens the history, right click -// toggles do-not-disturb. -BarPill { - id: root - - required property var screenRef - - accent: Notifs.dnd ? Theme.muted : Theme.primary - padding: Theme.padS - implicitWidth: Theme.barHeight - 4 + contentPad - - onClicked: { - popout.pinned = !popout.pinned; - Notifs.markRead(); - } - onRightClicked: Notifs.toggleDnd() - - Icon { - id: bell - anchors.centerIn: parent - text: Notifs.icon - font.pixelSize: Theme.fsLarge - color: { - if (Notifs.dnd) return Theme.muted; - if (Notifs.hasUnread) return Theme.accent; - return root.hovered ? Theme.glow : Theme.primary; - } - } - - // Unread badge, chopped like everything else. - Skew { - visible: Notifs.unread > 0 && !Notifs.dnd - anchors.right: parent.right - anchors.rightMargin: 2 - anchors.top: parent.top - anchors.topMargin: 4 - width: Math.max(12, badge.implicitWidth + 5) - height: 12 - color: Theme.accent - - // The badge count stays upright. It used to inherit the shear from the - // transform on the block below, which at 8px left the digits smeared. - Text { - id: badge - anchors.centerIn: parent - text: Notifs.unread > 9 ? "9+" : Notifs.unread - color: Theme.text - font.family: Theme.fontMono - font.pixelSize: 8 - font.weight: Font.Bold - renderType: Text.NativeRendering - } - } - - Connections { - target: Notifs - function onPopupRequested() { bell.bump(); } - } - - NotifPopout { - id: popout - anchorItem: root - triggerHovered: root.hovered - } -} diff --git a/quickshell/bar/PowerButton.qml b/quickshell/bar/PowerButton.qml deleted file mode 100644 index be6a56d..0000000 --- a/quickshell/bar/PowerButton.qml +++ /dev/null @@ -1,42 +0,0 @@ -import QtQuick -import Quickshell -import "root:/config" -import "root:/components" -import "root:/services" - -// Far right. Opens the full-screen power menu. -BarPill { - id: root - - required property var screenRef - - accent: Theme.accent - slash: true - padding: Theme.padS - active: Actions.powerMenuOpen - implicitWidth: Theme.barHeight - 4 + contentPad - - onClicked: Actions.togglePowerMenu() - - Icon { - id: glyph - anchors.centerIn: parent - text: "󰐥" - font.pixelSize: Theme.fsLarge - color: root.hovered || Actions.powerMenuOpen ? Theme.accent : Theme.subtext - } - - // A crimson halo that swells under the pointer. - Rectangle { - anchors.centerIn: glyph - z: -1 - width: root.hovered ? 26 : 0 - height: width - radius: width / 2 - color: Theme.alpha(Theme.accent, 0.18) - - Behavior on width { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } - } - } -} diff --git a/quickshell/bar/Rail.qml b/quickshell/bar/Rail.qml index 3055d60..5f0c778 100644 --- a/quickshell/bar/Rail.qml +++ b/quickshell/bar/Rail.qml @@ -94,7 +94,7 @@ PanelWindow { statusText: Audio.muted ? "Muted" : Audio.volumePercent + "% · " + Audio.sinkName hint: "Scroll to adjust" suppressTooltip: audioPopout.detailActive - alert: Audio.muted + dim: Audio.muted onClicked: audioPopout.togglePinned() onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05) } @@ -106,7 +106,7 @@ PanelWindow { statusText: Audio.micMuted ? "Muted" : Audio.micPercent + "% · " + Audio.sourceName hint: "Scroll to adjust" suppressTooltip: audioPopout.detailActive - alert: Audio.micMuted + dim: Audio.micMuted onClicked: audioPopout.togglePinned() onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05)) } @@ -132,7 +132,7 @@ PanelWindow { label: "Battery" statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel suppressTooltip: battPopout.detailActive - alert: Battery.critical + alarm: Battery.critical pulsing: Battery.critical onClicked: battPopout.togglePinned() } @@ -165,7 +165,7 @@ PanelWindow { : "" reserveReadout: true dim: Notifs.dnd - alert: Notifs.hasUnread && !Notifs.dnd + live: Notifs.hasUnread && !Notifs.dnd suppressTooltip: notifPopout.detailActive onClicked: { notifPopout.togglePinned(); @@ -178,7 +178,7 @@ PanelWindow { icon: "󰐥" label: "Power" statusText: "Session actions" - alert: Actions.powerMenuOpen + live: Actions.powerMenuOpen onClicked: Actions.togglePowerMenu() } } @@ -201,7 +201,7 @@ PanelWindow { width: parent.ruleWidth height: parent.ruleWidth * Theme.skew + parent.ruleWeight vertical: true - color: Theme.alpha(Theme.outline, 0.9) + color: Theme.alpha(Theme.outline, 0.5) } } diff --git a/quickshell/bar/RailIcon.qml b/quickshell/bar/RailIcon.qml index abfeb62..db86e60 100644 --- a/quickshell/bar/RailIcon.qml +++ b/quickshell/bar/RailIcon.qml @@ -3,10 +3,10 @@ import "root:/config" import "root:/components" // One slot on the right-hand rail: a glyph with an optional micro readout -// stacked beneath it, and a crimson bar that wipes down the leading edge on -// hover. The rail is only 48px wide, so unlike StatusIcon the readout sits -// under the glyph rather than beside it, and anything longer than a percentage -// belongs in the popout instead. +// stacked beneath it, and an accent bar that wipes down the leading edge on +// hover. The rail is only 48px wide, so the readout sits under the glyph +// rather than beside it, and anything longer than a percentage belongs in the +// popout instead. Item { id: root @@ -17,7 +17,15 @@ Item { property string hint: "" property bool suppressTooltip: false property color tint: Theme.primary - property bool alert: false + + // Three states, three meanings. `live` wants attention but nothing is wrong + // (unread notifications, an open power menu): accent. `alarm` is wrong or + // critical (a dying battery): alarm. `dim` is off by choice (muted, radio + // off, do-not-disturb): grey. These used to be one `alert` flag painted in + // the accent, which made a muted speaker look like the most active thing + // on the rail. + property bool live: false + property bool alarm: false property bool dim: false property bool pulsing: false @@ -63,7 +71,7 @@ Item { id: glyphMetrics text: root.icon font.family: Theme.fontIcon - font.pixelSize: Theme.fsLarge + font.pixelSize: Theme.fsRailIcon } Item { @@ -76,9 +84,10 @@ Item { y: (root.glyphBand - height) / 2 text: root.icon pulsing: root.pulsing - font.pixelSize: Theme.fsLarge + font.pixelSize: Theme.fsRailIcon color: { - if (root.alert) return Theme.accent; + if (root.alarm) return Theme.alarm; + if (root.live) return Theme.accent; if (root.dim) return Theme.muted; if (root.hovered) return Theme.glow; return root.tint; @@ -96,7 +105,11 @@ Item { anchors.topMargin: root.glyphBand + 1 visible: root.readout !== "" text: root.readout - color: root.dim ? Theme.muted : Theme.subtext + color: { + if (root.alarm) return Theme.alarm; + if (root.dim) return Theme.muted; + return Theme.subtext; + } font.family: Theme.fontMono font.pixelSize: Theme.fsMicro font.weight: Font.DemiBold @@ -116,9 +129,9 @@ Item { // scale then pushed it out past the silhouette onto the desktop. The inset // has to survive that scale, which is why it is not simply 1 or 2. // - // It is crimson rather than `tint` because it only ever appears on hover, - // and hover is crimson everywhere else in the shell (Theme.glow). Painting - // it white put a white bar on the rail's edge for no stated reason. + // It is the accent rather than `tint` because it only ever appears on + // hover, and hover is the accent everywhere else in the shell (Theme.glow). + // Painting it white put a white bar on the rail's edge for no stated reason. Rectangle { anchors.left: parent.left anchors.leftMargin: root.wipeInset @@ -137,7 +150,8 @@ Item { NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } } - onAlertChanged: if (alert) glyph.bump() + onAlarmChanged: if (alarm) glyph.bump() + onLiveChanged: if (live) glyph.bump() HoverHandler { id: hover diff --git a/quickshell/bar/Resources.qml b/quickshell/bar/Resources.qml index 1d43411..45dcf66 100644 --- a/quickshell/bar/Resources.qml +++ b/quickshell/bar/Resources.qml @@ -63,7 +63,7 @@ BarPill { anchors.centerIn: parent width: parent.height * Theme.skew + 1 height: parent.height - color: Theme.alpha(Theme.outline, 0.8) + color: Theme.alpha(Theme.outline, 0.5) } } @@ -106,7 +106,7 @@ BarPill { id: labelText anchors.baseline: gauge.baseline text: metric.label - color: Theme.muted + color: Theme.subtext font.family: Theme.fontMono font.pixelSize: Theme.fsMicro font.weight: Font.Bold diff --git a/quickshell/bar/Sigil.qml b/quickshell/bar/Sigil.qml index 10c00ac..5c7e8a8 100644 --- a/quickshell/bar/Sigil.qml +++ b/quickshell/bar/Sigil.qml @@ -22,7 +22,7 @@ BarPill { // The wordmark. Was the Death arcana skull (nf-md-skull, U+F068C) — a // pictogram in a shell that otherwise says everything in heavy italic - // type. SplitText puts it in the display face with the crimson-and-black + // type. SplitText puts it in the display face with the ink print // offset every other heading carries, so the house mark is built out of // the same parts as the rest of the bar. SplitText { diff --git a/quickshell/bar/Status.qml b/quickshell/bar/Status.qml deleted file mode 100644 index 680355a..0000000 --- a/quickshell/bar/Status.qml +++ /dev/null @@ -1,117 +0,0 @@ -import QtQuick -import Quickshell -import "root:/config" -import "root:/components" -import "root:/services" -import "root:/popouts" - -// Network, bluetooth, audio, mic, backlight and battery, in one slab. Each -// glyph carries its own popout. -BarPill { - id: root - - required property var screenRef - - accent: Theme.primary - padding: Theme.padS - interactive: false - implicitWidth: layout.implicitWidth + contentPad * 2 - - Row { - id: layout - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - spacing: Theme.padM - - StatusIcon { - id: netIcon - icon: Net.icon - label: Sys.netUp ? Sys.rate(Sys.netRx + Sys.netTx) : "" - labelWidth: 62 - tint: Theme.primary - dim: !Sys.netUp - onClicked: netPopout.pinned = !netPopout.pinned - onRightClicked: Net.toggleWifi() - onMiddleClicked: Actions.openNetworkSettings() - } - - StatusIcon { - id: btIcon - icon: Bt.icon - tint: Bt.connected.length > 0 ? Theme.blue : Theme.primary - dim: !Bt.enabled - onClicked: btPopout.pinned = !btPopout.pinned - onRightClicked: Bt.toggle() - } - - StatusIcon { - id: volIcon - icon: Audio.icon - label: Audio.muted ? "" : Audio.volumePercent + "%" - labelWidth: Audio.muted ? 0 : 34 - alert: Audio.muted - onClicked: Audio.toggleMute() - onRightClicked: audioPopout.pinned = !audioPopout.pinned - onMiddleClicked: Actions.openAudioSettings() - onScrolled: delta => Audio.changeVolume(delta > 0 ? 0.05 : -0.05) - } - - StatusIcon { - id: micIcon - icon: Audio.micIcon - alert: Audio.micMuted - onClicked: Audio.toggleMicMute() - onRightClicked: audioPopout.pinned = !audioPopout.pinned - onScrolled: delta => Audio.setMicVolume(Audio.micVolume + (delta > 0 ? 0.05 : -0.05)) - } - - StatusIcon { - id: brightIcon - visible: Brightness.available - icon: Brightness.icon - label: Math.round(Brightness.percent) + "%" - labelWidth: 34 - // Brightness is never an alarm — it stays white and lets the - // battery be the only thing in the cluster that can go crimson. - tint: Theme.text - onScrolled: delta => Brightness.change(delta > 0 ? 5 : -5) - } - - StatusIcon { - id: battIcon - visible: Battery.available - icon: Battery.icon - label: Math.round(Battery.percent) + "%" - labelWidth: 34 - tint: Battery.tint - alert: Battery.critical - pulsing: Battery.critical - onClicked: battPopout.pinned = !battPopout.pinned - } - } - - NetPopout { - id: netPopout - anchorItem: netIcon - triggerHovered: netIcon.hovered - } - - BtPopout { - id: btPopout - anchorItem: btIcon - triggerHovered: btIcon.hovered - } - - AudioPopout { - id: audioPopout - anchorItem: volIcon - triggerHovered: volIcon.hovered || micIcon.hovered - } - - BatteryPopout { - id: battPopout - anchorItem: battIcon - triggerHovered: battIcon.hovered - visible: Battery.available && shown - } -} diff --git a/quickshell/bar/StatusIcon.qml b/quickshell/bar/StatusIcon.qml deleted file mode 100644 index 8b6d44b..0000000 --- a/quickshell/bar/StatusIcon.qml +++ /dev/null @@ -1,108 +0,0 @@ -import QtQuick -import "root:/config" -import "root:/components" - -// One indicator inside the status cluster: a glyph, an optional readout, and a -// crimson underline that wipes in on hover. -Item { - id: root - - property string icon: "" - property string label: "" - property color tint: Theme.primary - property bool alert: false - property bool dim: false - property bool pulsing: false - - // Fixed label width. Readouts that change width constantly (throughput, - // percentages) would otherwise shove the whole cluster around. - property real labelWidth: 0 - - readonly property bool hovered: hover.hovered - - signal clicked() - signal rightClicked() - signal middleClicked() - signal scrolled(real delta) - - implicitWidth: layout.implicitWidth - implicitHeight: Theme.barHeight - Theme.padS * 2 - - Row { - id: layout - anchors.centerIn: parent - spacing: 5 - - Icon { - id: glyph - anchors.verticalCenter: parent.verticalCenter - text: root.icon - pulsing: root.pulsing - color: { - if (root.alert) return Theme.accent; - if (root.dim) return Theme.muted; - if (root.hovered) return Theme.glow; - return root.tint; - } - } - - Text { - anchors.verticalCenter: parent.verticalCenter - visible: root.label !== "" - text: root.label - width: root.labelWidth > 0 ? root.labelWidth : implicitWidth - horizontalAlignment: Text.AlignRight - elide: Text.ElideRight - color: root.dim ? Theme.muted : Theme.subtext - font.family: Theme.fontMono - font.pixelSize: Theme.fsSmall - font.weight: Font.DemiBold - renderType: Text.NativeRendering - - Behavior on color { - ColorAnimation { duration: Theme.durBase } - } - } - } - - Rectangle { - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - height: 2 - width: root.hovered ? layout.implicitWidth : 0 - color: root.alert ? Theme.accent : root.tint - - Behavior on width { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo } - } - } - - scale: root.hovered ? 1.06 : 1.0 - Behavior on scale { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } - } - - onAlertChanged: if (alert) glyph.bump() - - HoverHandler { - id: hover - cursorShape: Qt.PointingHandCursor - } - - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: root.clicked() - } - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: root.rightClicked() - } - TapHandler { - acceptedButtons: Qt.MiddleButton - onTapped: root.middleClicked() - } - WheelHandler { - acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad - onWheel: event => root.scrolled(event.angleDelta.y) - } -} diff --git a/quickshell/bar/Tray.qml b/quickshell/bar/Tray.qml index 35a394a..88c2967 100644 --- a/quickshell/bar/Tray.qml +++ b/quickshell/bar/Tray.qml @@ -1,5 +1,4 @@ import QtQuick -import QtQuick.Effects import Quickshell import Quickshell.Services.SystemTray import Quickshell.Widgets @@ -15,10 +14,19 @@ Column { id: root spacing: Theme.padS + 2 - visible: SystemTray.items.values.length > 0 + visible: root.shownItems.length > 0 + + // Applets whose whole job the rail already does. nm-applet and blueman keep + // running — they are the Wi-Fi secret agent and the pairing agent — but + // their icons were a second network and a second bluetooth glyph a few + // slots below the rail's own. + readonly property var hiddenIds: ["nm-applet", "blueman"] + + readonly property var shownItems: SystemTray.items.values + .filter(item => root.hiddenIds.indexOf(item.id) === -1) Repeater { - model: SystemTray.items + model: root.shownItems Item { id: entry @@ -31,8 +39,8 @@ Column { // App icons arrive in their own brand colours, which wrecks the // two-hue palette faster than anything else in the shell. Flatten - // every one of them to a single-colour silhouette: white at rest, - // crimson under the pointer. Detail inside the glyph is lost on + // every one of them to a single-colour silhouette: subtext at rest, + // a notch under the rail's own glyphs, and accent under the pointer. Detail inside the glyph is lost on // purpose — that flattening *is* the screen-printed look. IconImage { id: img @@ -41,22 +49,15 @@ Column { asynchronous: true layer.enabled: true - layer.effect: MultiEffect { - colorization: 1.0 - colorizationColor: itemHover.hovered + layer.effect: TintEffect { + tintColor: itemHover.hovered ? Theme.accent - : Theme.text + : Theme.subtext - Behavior on colorizationColor { + Behavior on tintColor { ColorAnimation { duration: Theme.durFast } } } - - opacity: itemHover.hovered ? 1.0 : 0.82 - - Behavior on opacity { - NumberAnimation { duration: Theme.durFast } - } } scale: itemHover.hovered ? 1.18 : 1.0 diff --git a/quickshell/bar/Workspaces.qml b/quickshell/bar/Workspaces.qml index c6f582e..61b92dd 100644 --- a/quickshell/bar/Workspaces.qml +++ b/quickshell/bar/Workspaces.qml @@ -5,7 +5,7 @@ import "root:/config" import "root:/components" // Workspace indicator: leaning ticks that stretch open when focused. Occupied -// workspaces glow teal, urgent ones snap to crimson. +// workspaces fill in, urgent ones snap to the alarm colour. Item { id: root @@ -65,8 +65,12 @@ Item { // Every tick is the same height and the same shear. Only the // width changes, and only to say which workspace is focused. - width: focused ? 32 : (occupied ? 16 : 9) - height: 18 + // + // Kept well short of the bar's 20px divider rules. At 18px an + // empty outline tick was the same height, lean and weight as a + // divider, and the workspace strip read as a row of rules. + width: focused ? 30 : (occupied ? 16 : 8) + height: 14 Behavior on width { NumberAnimation { @@ -80,23 +84,32 @@ Item { id: tick anchors.fill: parent - // Focused is a solid crimson block. Occupied is a white - // block at half weight. Empty is an outline. Three states, - // two colours, no ambiguity. + // Focused is a solid accent block. Urgent is a solid alarm + // block — it used to be the accent too, which made an + // urgent workspace indistinguishable from the focused one. + // Occupied is a subtext block. Empty is a flat recessed + // slot, filled rather than outlined so it cannot pass for a + // divider. color: { - if (pip.focused || pip.urgent) return Theme.accent; + if (pip.focused) return Theme.accent; + if (pip.urgent) return Theme.alarm; if (pip.hovered) return Theme.text; - if (pip.occupied) return Theme.alpha(Theme.text, 0.5); - return "transparent"; + if (pip.occupied) return Theme.subtext; + return Theme.surfaceAlt; } - borderColor: pip.occupied || pip.focused - ? "transparent" - : Theme.alpha(Theme.text, 0.35) - borderWidth: 1 Behavior on color { ColorAnimation { duration: Theme.durBase } } + + // Urgent is the one pip that has to be seen peripherally. + SequentialAnimation on opacity { + running: pip.urgent && !pip.focused + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 520; easing.type: Easing.InOutQuad } + } } // The focused workspace wears its number. @@ -107,7 +120,7 @@ Item { text: pip.wsId color: Theme.ink font.family: Theme.fontDisplay - font.pixelSize: Theme.fsBody + font.pixelSize: Theme.fsSmall font.weight: Theme.weightDisplay font.italic: true renderType: Text.NativeRendering diff --git a/quickshell/components/Gauge.qml b/quickshell/components/Gauge.qml deleted file mode 100644 index 76b3af5..0000000 --- a/quickshell/components/Gauge.qml +++ /dev/null @@ -1,120 +0,0 @@ -import QtQuick -import QtQuick.Shapes -import "root:/config" - -// Radial gauge used across the resource popout. Sweeps a 270° arc with a -// tick-marked rail behind it and the reading set in the middle. -Item { - id: root - - property real value: 0 // 0..100 - property string label: "" - property string readout: Math.round(value) + "%" - property string sub: "" - property color arcColor: Theme.heat(value) - property real thickness: 7 - property real startAngle: 135 - property real sweep: 270 - property int ticks: 24 - - implicitWidth: 96 - implicitHeight: 96 - - readonly property real _r: Math.min(width, height) / 2 - thickness / 2 - 4 - readonly property real _cx: width / 2 - readonly property real _cy: height / 2 - - // Rail - Shape { - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - ShapePath { - fillColor: "transparent" - strokeColor: Theme.alpha(Theme.outline, 0.5) - strokeWidth: root.thickness - capStyle: ShapePath.FlatCap - PathAngleArc { - centerX: root._cx; centerY: root._cy - radiusX: root._r; radiusY: root._r - startAngle: root.startAngle - sweepAngle: root.sweep - } - } - } - - // Tick marks around the rail. - Repeater { - model: root.ticks - Rectangle { - required property int index - readonly property real a: (root.startAngle + root.sweep * (index / (root.ticks - 1))) * Math.PI / 180 - readonly property real rr: root._r + root.thickness / 2 + 3 - width: 2 - height: index % 4 === 0 ? 6 : 3 - color: Theme.alpha(Theme.primary, index % 4 === 0 ? 0.55 : 0.25) - x: root._cx + Math.cos(a) * rr - width / 2 - y: root._cy + Math.sin(a) * rr - height / 2 - rotation: (root.startAngle + root.sweep * (index / (root.ticks - 1))) + 90 - } - } - - // Value arc - Shape { - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - ShapePath { - fillColor: "transparent" - strokeColor: root.arcColor - strokeWidth: root.thickness - capStyle: ShapePath.FlatCap - PathAngleArc { - id: valueArc - centerX: root._cx; centerY: root._cy - radiusX: root._r; radiusY: root._r - startAngle: root.startAngle - sweepAngle: root.sweep * Math.max(0, Math.min(100, root.value)) / 100 - - Behavior on sweepAngle { - NumberAnimation { - duration: Theme.durSlow - easing.type: Easing.OutExpo - } - } - } - } - } - - Column { - anchors.centerIn: parent - spacing: -2 - - SplitText { - anchors.horizontalCenter: parent.horizontalCenter - text: root.readout - pixelSize: Theme.fsLarge - color: Theme.text - split: 1.2 - splitOpacity: 0.6 - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: root.label - color: Theme.muted - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - font.letterSpacing: 2 - renderType: Text.NativeRendering - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - visible: root.sub !== "" - text: root.sub - color: Theme.subtext - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - renderType: Text.NativeRendering - } - } -} diff --git a/quickshell/components/ListRow.qml b/quickshell/components/ListRow.qml new file mode 100644 index 0000000..a6d0c5f --- /dev/null +++ b/quickshell/components/ListRow.qml @@ -0,0 +1,118 @@ +import QtQuick +import "root:/config" + +// One row of a popout list — a Wi-Fi network, a bluetooth device, an audio +// output. The pickers used to draw their own rows, each with its own height and +// its own translucent white wash for hover, which on Mocha's blue-grey ground +// came out as a faint milky film. This is the one row they share now. +// +// Rest is bare. Hover lifts the row onto the surface tone. The current item — +// connected, in use — carries an accent tick down its leading edge and an +// accent glyph, and its label goes to full weight. +Item { + id: root + + property string icon: "" + property string label: "" + // Optional mono line under the label: a device state, an address. + property string detail: "" + property bool current: false + + // Right-aligned trailing content — a lock, a battery level, a tag. + default property alias trailing: trailingRow.data + + readonly property bool hovered: hover.hovered + + signal clicked() + signal rightClicked() + + implicitHeight: root.detail !== "" ? 36 : 30 + + Rectangle { + anchors.fill: parent + color: root.hovered ? Theme.treatmentHoverFill : "transparent" + + Behavior on color { + ColorAnimation { duration: Theme.durFast } + } + } + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: root.current ? root.height - 10 : 0 + color: Theme.accent + + Behavior on height { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } + } + } + + Icon { + id: glyph + anchors.left: parent.left + anchors.leftMargin: Theme.padM + anchors.verticalCenter: parent.verticalCenter + width: 18 + visible: root.icon !== "" + text: root.icon + color: root.current ? Theme.accent : (root.hovered ? Theme.text : Theme.subtext) + font.pixelSize: Theme.fsLarge + } + + Column { + anchors.left: glyph.visible ? glyph.right : parent.left + anchors.leftMargin: glyph.visible ? Theme.padS : Theme.padM + anchors.right: trailingRow.left + anchors.rightMargin: Theme.padS + anchors.verticalCenter: parent.verticalCenter + spacing: -1 + + Text { + width: parent.width + text: root.label + elide: Text.ElideRight + color: root.current || root.hovered ? Theme.text : Theme.subtext + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsSmall + font.italic: true + font.weight: root.current ? Font.Black : Font.Medium + renderType: Text.NativeRendering + } + + Text { + width: parent.width + visible: text !== "" + text: root.detail + elide: Text.ElideRight + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } + } + + Row { + id: trailingRow + anchors.right: parent.right + anchors.rightMargin: Theme.padS + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: root.clicked() + } + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: root.rightClicked() + } +} diff --git a/quickshell/components/MetricRow.qml b/quickshell/components/MetricRow.qml index f4ceac1..4b45ecd 100644 --- a/quickshell/components/MetricRow.qml +++ b/quickshell/components/MetricRow.qml @@ -70,8 +70,6 @@ Item { text: root.readout pixelSize: Theme.fsBody color: Theme.text - split: 1 - splitOpacity: 0.5 } } diff --git a/quickshell/components/NotifCard.qml b/quickshell/components/NotifCard.qml index 209876c..b2b593d 100644 --- a/quickshell/components/NotifCard.qml +++ b/quickshell/components/NotifCard.qml @@ -34,13 +34,16 @@ Item { chop: root.chop fill: Theme.surface fillOpacity: 0.97 - border: Theme.alpha(root.tone, hover.hovered ? 0.95 : 0.5) + // Urgency lives in the slash, and in the frame only when critical. A + // tone-tinted frame on every card drew a pale outline round ordinary + // notifications, and the critical halftone was painted in the accent, + // so the most urgent card wore the colour that means "live". + border: root.notif?.urgency === NotificationUrgency.Critical + ? root.tone + : (hover.hovered ? Theme.treatmentHoverMark : Theme.surfaceAlt) slash: true slashColor: root.tone slashWidth: 4 - halftone: root.notif?.urgency === NotificationUrgency.Critical - halftoneColor: Theme.accent - halftoneOpacity: 0.12 padding: Theme.padM Column { @@ -66,7 +69,7 @@ Item { text: (root.notif?.appName || "System").toUpperCase() color: root.tone font.family: Theme.fontMono - font.pixelSize: 8 + font.pixelSize: Theme.fsMicro font.letterSpacing: 2 renderType: Text.NativeRendering } @@ -126,18 +129,16 @@ Item { Skew { anchors.fill: parent - color: actionHover.hovered - ? Theme.alpha(root.tone, 0.3) - : Theme.alpha(root.tone, 0.1) + color: actionHover.hovered ? Theme.accent : Theme.surfaceAlt borderWidth: 1 - borderColor: Theme.alpha(root.tone, 0.6) + borderColor: actionHover.hovered ? Theme.accent : Theme.overlay } Text { id: actionLabel anchors.centerIn: parent text: actionChip.modelData.text - color: Theme.text + color: actionHover.hovered ? Theme.ink : Theme.text font.family: Theme.fontDisplay font.pixelSize: Theme.fsMicro font.italic: true diff --git a/quickshell/components/P5Backdrop.qml b/quickshell/components/P5Backdrop.qml index 298b41c..fea8a1f 100644 --- a/quickshell/components/P5Backdrop.qml +++ b/quickshell/components/P5Backdrop.qml @@ -3,7 +3,7 @@ import QtQuick.Shapes import "root:/config" import "root:/components" -// The shared ground for the two full-screen surfaces: a crimson wedge leaning +// The shared ground for the two full-screen surfaces: an accent wedge leaning // at the house angle, a tiled stripe field that sweeps in from the left, and a // halftone dot wash over the top. // @@ -33,7 +33,9 @@ Item { property real wedgeSpan: 0.34 property bool stripes: true - property real stripeOpacity: 0.10 + // Tuned down from 0.10 for Mocha. Crust stripes on the blue-grey base read + // far heavier than they did on black, and buzzed against the text. + property real stripeOpacity: 0.07 property bool halftone: true property real halftoneOpacity: 0.05 diff --git a/quickshell/components/P5Button.qml b/quickshell/components/P5Button.qml index 827d75e..7e4b353 100644 --- a/quickshell/components/P5Button.qml +++ b/quickshell/components/P5Button.qml @@ -21,6 +21,12 @@ FocusScope { readonly property bool focusedOrSelected: root.activeFocus || root.selected readonly property bool highlighted: root.hovered || root.focusedOrSelected + // Keyboard focus and roving selection invert the slab to solid accent. + // Pointer hover stays a keyline change, so the two never look alike and a + // mouse user never sees the "selected" treatment by accident. Destructive + // buttons keep their own mauve inversion for both. + readonly property bool inverted: root.enabled && !root.destructive && root.focusedOrSelected + // Pointer activation must not turn a transient action into the black/white // keyboard-focus treatment. Tab still reaches it, and programmatic focus // (power-menu roving selection / pinned popouts) still works. @@ -49,6 +55,7 @@ FocusScope { if (root.destructive) return root.highlighted ? Theme.treatmentDestructiveActiveFill : Theme.treatmentDestructiveFill; + if (root.inverted) return Theme.treatmentFocusFill; if (root.highlighted) return Theme.treatmentHoverFill; return Theme.treatmentRestFill; } @@ -58,15 +65,13 @@ FocusScope { if (root.destructive) return root.highlighted ? Theme.treatmentDestructiveActiveMark : Theme.treatmentDestructiveMark; + if (root.inverted) return Theme.treatmentFocusMark; if (root.highlighted) return Theme.treatmentHoverMark; return Theme.alpha(root.accent, 0.55); } borderWidth: Theme.stroke slash: !root.highlighted slashColor: root.destructive ? Theme.alarm : root.accent - halftone: root.enabled && root.highlighted - halftoneColor: Theme.ink - halftoneOpacity: 0.14 Behavior on fillOpacity { NumberAnimation { duration: Theme.durFast } @@ -88,6 +93,7 @@ FocusScope { if (root.destructive) return root.highlighted ? Theme.treatmentDestructiveActiveText : Theme.treatmentDestructiveText; + if (root.inverted) return Theme.treatmentFocusText; if (root.highlighted) return Theme.treatmentHoverText; return root.accent; } @@ -101,6 +107,7 @@ FocusScope { if (root.destructive) return root.highlighted ? Theme.treatmentDestructiveActiveText : Theme.treatmentDestructiveText; + if (root.inverted) return Theme.treatmentFocusText; if (root.highlighted) return Theme.treatmentHoverText; return Theme.treatmentRestText; } diff --git a/quickshell/components/P5Panel.qml b/quickshell/components/P5Panel.qml index ab1b0a2..624f378 100644 --- a/quickshell/components/P5Panel.qml +++ b/quickshell/components/P5Panel.qml @@ -5,7 +5,7 @@ import "root:/config" import "root:/components" // The base surface for everything in this shell: a leaning, corner-chopped slab -// with an optional halftone wash and a crimson slash along its leading edge. +// with an optional halftone wash and a accent slash along its leading edge. Item { id: root @@ -29,7 +29,7 @@ Item { property real chop: Theme.cut property var chopCorners: [1, 3] - // Crimson slash down the leading edge — the Persona 5 tell. + // Tone slash down the leading edge — the Persona 5 tell. property bool slash: false property color slashColor: Theme.accent property real slashWidth: 4 @@ -183,7 +183,7 @@ Item { // // Its polygon is built from the panel's raw corners, so on a panel with a // chopped corner the slash used to run straight through the cut and out the - // far side — a crimson wedge sitting a few pixels outside the silhouette, + // far side — an accent wedge sitting a few pixels outside the silhouette, // at an angle that matched nothing. It is masked to the outline now, so the // slash ends exactly where the chamfer does. Loader { diff --git a/quickshell/components/P5Slider.qml b/quickshell/components/P5Slider.qml index d989cf6..0e20e5e 100644 --- a/quickshell/components/P5Slider.qml +++ b/quickshell/components/P5Slider.qml @@ -31,10 +31,13 @@ Item { anchors.left: parent.left anchors.right: parent.right height: root.activeFocus ? 10 : 6 + // Focus widens the rail and rings it in the accent rather than filling + // it: the level fill is often the accent itself, and a solid accent + // rail swallowed it. color: root.activeFocus - ? Theme.treatmentFocusFill + ? Theme.surfaceAlt : Theme.alpha(root.enabled ? Theme.outline : Theme.treatmentDisabledMark, 0.7) - borderColor: root.activeFocus ? Theme.treatmentFocusMark : "transparent" + borderColor: root.activeFocus ? Theme.accent : "transparent" borderWidth: root.activeFocus ? Theme.stroke : 0 Behavior on height { diff --git a/quickshell/components/SectionRule.qml b/quickshell/components/SectionRule.qml new file mode 100644 index 0000000..9874f6e --- /dev/null +++ b/quickshell/components/SectionRule.qml @@ -0,0 +1,35 @@ +import QtQuick +import "root:/config" + +// Section caption for popouts: a spaced mono label running into a hairline. +// Every panel used to label its lists differently — a bare caption here, a +// caption and rule there — which is part of why no two popouts looked related. +Item { + id: root + + property string text: "" + + implicitHeight: visible ? 18 : 0 + + Text { + id: label + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.text.toUpperCase() + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + + Rectangle { + anchors.left: label.right + anchors.leftMargin: Theme.padS + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Theme.alpha(Theme.outline, 0.5) + } +} diff --git a/quickshell/components/SplitText.qml b/quickshell/components/SplitText.qml index 503aad0..2699656 100644 --- a/quickshell/components/SplitText.qml +++ b/quickshell/components/SplitText.qml @@ -1,8 +1,14 @@ import QtQuick import "root:/config" -// Display text with the wallpaper's RGB split baked in: a crimson and a cyan -// ghost sitting a pixel off the white body. Drives most headings in the shell. +// Display text, screen-printed: the body sits on a hard crust copy knocked a +// pixel and a half down-right — the same offset `P5Panel` puts behind every +// slab. Drives most headings in the shell. +// +// This used to carry a permanent RGB split, a blue and a mauve ghost either +// side of the body. On crimson-and-black that read as misregistered print; on +// Mocha's pastels it read as blur, and it turned every heading in the shell +// soft. The two ghosts still exist, but only for the moment `glitch()` runs. Item { id: root @@ -12,23 +18,34 @@ Item { property string family: Theme.fontDisplay property bool italic: true property color color: Theme.text - property real split: 1.6 - property real splitOpacity: 0.85 property int elide: Text.ElideNone property int horizontalAlignment: Text.AlignLeft property real letterSpacing: 0.5 + // Print shadow. Scales gently with size so a 34px heading does not sit on + // the same hairline offset as an 11px label. + property real shadowOffset: Math.max(1, Math.round(root.pixelSize / 10)) + property color shadowColor: Theme.ink + + // Ghost offset and resting strength. Resting strength is zero, so the + // ghosts are invisible until `glitch()` kicks them out. A caller that sets + // `splitOpacity` is asking for the old permanent split and still gets it. + property real split: 1.6 + property real splitOpacity: 0 + + // 0..1, driven only by `glitch()`. Kept apart from `split` and + // `splitOpacity` so the animation never has to remember a caller's values. + property real kick: 0 + + readonly property real ghostOffset: root.split + root.kick * 4 + readonly property real ghostOpacity: Math.max(root.splitOpacity, root.kick * 0.85) + implicitWidth: body.implicitWidth implicitHeight: body.implicitHeight - Text { - id: ghostRed + component Layer: Text { anchors.fill: parent - anchors.leftMargin: -root.split - anchors.topMargin: root.split * 0.4 text: root.text - color: Theme.splitRed - opacity: root.splitOpacity elide: root.elide horizontalAlignment: root.horizontalAlignment verticalAlignment: Text.AlignVCenter @@ -40,39 +57,33 @@ Item { renderType: Text.NativeRendering } - Text { - id: ghostCyan - anchors.fill: parent - anchors.leftMargin: root.split - anchors.topMargin: -root.split * 0.4 - text: root.text - color: Theme.splitCyan - opacity: root.splitOpacity - elide: root.elide - horizontalAlignment: root.horizontalAlignment - verticalAlignment: Text.AlignVCenter - font.family: root.family - font.pixelSize: root.pixelSize - font.weight: root.weight - font.italic: root.italic - font.letterSpacing: root.letterSpacing - renderType: Text.NativeRendering + Layer { + anchors.leftMargin: root.shadowOffset + anchors.rightMargin: -root.shadowOffset + anchors.topMargin: root.shadowOffset + anchors.bottomMargin: -root.shadowOffset + color: root.shadowColor } - Text { + Layer { + visible: root.ghostOpacity > 0.01 + anchors.leftMargin: -root.ghostOffset + anchors.topMargin: root.ghostOffset * 0.4 + color: Theme.accent + opacity: root.ghostOpacity + } + + Layer { + visible: root.ghostOpacity > 0.01 + anchors.leftMargin: root.ghostOffset + anchors.topMargin: -root.ghostOffset * 0.4 + color: Theme.alarm + opacity: root.ghostOpacity + } + + Layer { id: body - anchors.fill: parent - text: root.text color: root.color - elide: root.elide - horizontalAlignment: root.horizontalAlignment - verticalAlignment: Text.AlignVCenter - font.family: root.family - font.pixelSize: root.pixelSize - font.weight: root.weight - font.italic: root.italic - font.letterSpacing: root.letterSpacing - renderType: Text.NativeRendering } // Kick the ghosts outward briefly — used on track changes, mode flips, etc. @@ -83,14 +94,14 @@ Item { SequentialAnimation { id: glitchAnim NumberAnimation { - target: root; property: "split" - to: 5.5; duration: Theme.durFast + target: root; property: "kick" + to: 1; duration: Theme.durFast easing.type: Easing.OutExpo } NumberAnimation { - target: root; property: "split" - to: 1.6; duration: Theme.durSlow - easing.type: Easing.OutBack + target: root; property: "kick" + to: 0; duration: Theme.durSlow + easing.type: Easing.OutCubic } } } diff --git a/quickshell/components/TintEffect.qml b/quickshell/components/TintEffect.qml index 63dc603..f951c6d 100644 --- a/quickshell/components/TintEffect.qml +++ b/quickshell/components/TintEffect.qml @@ -1,11 +1,17 @@ import QtQuick import QtQuick.Effects -// Layer effect that recolours whatever it is applied to — used to tint the -// white texture tiles without shipping a copy per colour. +// Layer effect that recolours whatever it is applied to into a flat silhouette +// of `tintColor` — used for the white texture tiles and for tray icons. +// +// Colorization alone keeps the source's lightness, so a dark icon came out as a +// dark shade of the tint and all but vanished against the rail. Brightness runs +// first and lifts every pixel to white, which leaves colorization nothing to +// preserve but the alpha. MultiEffect { property color tintColor: "white" + brightness: 1.0 colorization: 1.0 colorizationColor: tintColor } diff --git a/quickshell/config/Theme.qml b/quickshell/config/Theme.qml index 1c98860..e2b12b7 100644 --- a/quickshell/config/Theme.qml +++ b/quickshell/config/Theme.qml @@ -3,7 +3,7 @@ pragma Singleton import QtQuick import Quickshell -// Mocha palette: Catppuccin Mocha ground, blue as the accent, pink as the +// Mocha palette: Catppuccin Mocha ground, blue as the accent, mauve as the // alarm. Persona geometry, softer colour. // // The Takemi branch ran monochrome-plus-crimson, where one hue carried both @@ -40,8 +40,10 @@ Singleton { // ------------------------------------------------------------ THE KNOBS // Two colours, two meanings, and nothing else in the shell carries hue. // - // `accent` is blue: anything live, focused or selected. `alarm` is pink: - // anything destructive or failed. Swap either line and everything derived + // `accent` is blue: anything live, focused or selected. `alarm` is mauve: + // anything destructive, failed or critical. Things that are merely off — + // muted, disabled, do-not-disturb — carry no hue at all; see the `Off` + // treatment below. Swap either line and everything derived // from it recolours. // // Keep them in step with `c.accent` / `c.alarm` in @@ -66,8 +68,8 @@ Singleton { // ------------------------------------------------ semantic treatments // These tokens describe interaction meaning, not new palette entries. Each - // state is composed only from Takemi's black, white, crimson, and neutral - // surfaces so callers do not invent one-off state colors. + // state is composed only from crust, the neutral ramp, blue and mauve, so + // callers do not invent one-off state colours. readonly property color treatmentRestFill: root.surface readonly property color treatmentRestMark: root.outline readonly property color treatmentRestText: root.text @@ -76,7 +78,11 @@ Singleton { readonly property color treatmentHoverMark: root.accent readonly property color treatmentHoverText: root.text - readonly property color treatmentFocusFill: root.text + // Focus and selection are an inverted slab: solid blue with crust punched + // out of it (9:1). This used to be a white fill, which was right against + // Takemi's black but glares as a lavender-white block on Mocha — and it + // made "selected" a third look that was neither hover nor accent. + readonly property color treatmentFocusFill: root.accent readonly property color treatmentFocusMark: root.ink readonly property color treatmentFocusText: root.ink readonly property color treatmentSelectedFill: root.treatmentFocusFill @@ -112,16 +118,13 @@ Singleton { readonly property color treatmentDisabledMark: root.outline readonly property color treatmentDisabledText: root.muted - // Semantic aliases. Still only two hues: a thing is fine (text), live - // (accent) or wrong (alarm). Nothing here introduces a third colour. - readonly property color blue: root.accent - readonly property color ok: root.text - readonly property color warn: root.alarm + // Off is a state the user chose — muted, do-not-disturb, radio switched + // off. It is neither live nor wrong, so it gets no hue: painting a muted + // speaker blue said "active", and painting it mauve would say "broken". + readonly property color treatmentOffFill: root.surface + readonly property color treatmentOffMark: root.overlay + readonly property color treatmentOffText: root.muted - // Display text carries a blue and a mauve offset rather than an RGB split. - // Same trick, this palette's two colours. - readonly property color splitRed: root.accent - readonly property color splitCyan: root.alarm function alpha(c: color, a: real): color { return Qt.rgba(c.r, c.g, c.b, a); @@ -154,7 +157,9 @@ Singleton { readonly property int weightDisplay: Font.Black readonly property int weightBody: Font.Medium - readonly property int fsMicro: 9 + // Nothing in the shell renders below this. 9px mono was legible on + // crimson-on-black; on Mocha's lower-contrast ramp it was not. + readonly property int fsMicro: 10 readonly property int fsSmall: 11 readonly property int fsBody: 12 readonly property int fsLarge: 15 @@ -162,6 +167,8 @@ Singleton { readonly property int fsHuge: 34 readonly property int fsMega: 92 + readonly property int fsRailIcon: 17 + // -------------------------------------------------------------- geometry readonly property int barHeight: 40 readonly property int barMargin: 8 @@ -180,7 +187,7 @@ Singleton { readonly property int cut: 10 readonly property int stroke: 2 - // Pure-black keyline and offset copy behind panels. This is the one piece of + // Crust keyline and offset copy behind panels. This is the one piece of // layering the style needs; it is not a soft shadow and never blurs. readonly property real dropOffset: 3 readonly property real keylineWidth: 2 @@ -213,5 +220,5 @@ Singleton { // which asks hyprpaper what is actually on screen — a path written down // here goes stale the moment the wallpaper is switched, which is exactly // what happened to the lock screen. - readonly property url fallbackWallpaper: "file:///home/narl/.wallpapers/takemi_1.jpg" + readonly property url fallbackWallpaper: "file:///home/narl/.wallpapers/arch.png" } diff --git a/quickshell/launcher/ResultRow.qml b/quickshell/launcher/ResultRow.qml index eeaa18d..9846553 100644 --- a/quickshell/launcher/ResultRow.qml +++ b/quickshell/launcher/ResultRow.qml @@ -36,7 +36,7 @@ Item { } } - // Hover is a crimson tick in the gutter rather than a second slab, so + // Hover is an accent tick in the gutter rather than a second slab, so // pointing at a row cannot be mistaken for selecting it. Skew { anchors.verticalCenter: parent.verticalCenter diff --git a/quickshell/lock/LockFace.qml b/quickshell/lock/LockFace.qml index 1657acd..177f721 100644 --- a/quickshell/lock/LockFace.qml +++ b/quickshell/lock/LockFace.qml @@ -68,7 +68,7 @@ Item { // The same wedge-and-stripes ground the power menu stands on. The wedge // sits under the clock, which is where the power menu puts it under the - // title — so the two screens land the crimson mass in the same place. + // title — so the two screens land the accent mass in the same place. P5Backdrop { id: backdrop anchors.fill: parent diff --git a/quickshell/overlays/Osd.qml b/quickshell/overlays/Osd.qml index 2b653f4..e0fda81 100644 --- a/quickshell/overlays/Osd.qml +++ b/quickshell/overlays/Osd.qml @@ -66,10 +66,12 @@ PanelWindow { readonly property color tone: { switch (root.kind) { - case "mic": return Audio.micMuted ? Theme.accent : Theme.blue; - // Turning the screen up is not a warning. White, like volume. - case "brightness": return Theme.text; - default: return Audio.muted ? Theme.accent : Theme.primary; + // One tone for every level: the accent while the channel is live, the + // off grey while it is muted. Muted used to be the accent too, so a + // silenced speaker flashed the same colour as a live one. + case "mic": return Audio.micMuted ? Theme.treatmentOffText : Theme.accent; + case "brightness": return Theme.accent; + default: return Audio.muted ? Theme.treatmentOffText : Theme.accent; } } @@ -104,15 +106,11 @@ PanelWindow { anchors.margins: 4 fill: Theme.mantle fillOpacity: 0.97 - border: Theme.alpha(root.tone, 0.85) + border: Theme.surfaceAlt borderWidth: Theme.stroke slash: true slashColor: root.tone slashWidth: 5 - halftone: true - halftoneColor: root.tone - halftoneOpacity: 0.08 - sheen: true lean: 0.09 chop: 14 padding: Theme.padM @@ -180,8 +178,6 @@ PanelWindow { // widening readout only moves its own left edge. text: Math.round(root.level * 100) + "%" pixelSize: Theme.fsBody - split: 1 - splitOpacity: 0.5 } } diff --git a/quickshell/overlays/PowerMenu.qml b/quickshell/overlays/PowerMenu.qml index 062c300..1c000cb 100644 --- a/quickshell/overlays/PowerMenu.qml +++ b/quickshell/overlays/PowerMenu.qml @@ -5,7 +5,7 @@ import "root:/config" import "root:/components" import "root:/services" -// Full-screen power menu. Crimson stripes sweep in behind a stack of leaning +// Full-screen power menu. An accent wedge and stripes sweep in behind a stack of leaning // buttons; Escape or a click on the backdrop dismisses it. PanelWindow { id: root @@ -67,7 +67,7 @@ PanelWindow { Rectangle { id: backdrop anchors.fill: parent - color: Theme.alpha(Theme.base, 0.82) + color: Theme.alpha(Theme.base, 0.9) opacity: 0 TapHandler { diff --git a/quickshell/overlays/Toasts.qml b/quickshell/overlays/Toasts.qml index f00d7bc..9a7c5b7 100644 --- a/quickshell/overlays/Toasts.qml +++ b/quickshell/overlays/Toasts.qml @@ -13,9 +13,9 @@ PanelWindow { required property var modelData screen: modelData - // Only the primary bar screen shows toasts, to avoid duplicates. - readonly property bool primaryScreen: Quickshell.screens.length === 0 - || Quickshell.screens[0] === modelData + // A toast window exists on every screen, but each notification lands only + // on the monitor that had focus when it arrived. Latched per toast rather + // than live, so a card does not hop screens while it is being read. WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "takemi-toasts" @@ -31,7 +31,7 @@ PanelWindow { implicitHeight: Math.max(1, stack.implicitHeight) color: "transparent" exclusionMode: ExclusionMode.Ignore - visible: primaryScreen && stack.children.length > 0 + visible: root.live.length > 0 // Toasts currently on screen, newest first. property list live: [] @@ -39,9 +39,8 @@ PanelWindow { Connections { target: Notifs - enabled: root.primaryScreen - function onPopupRequested(notif) { + if (Actions.focusedScreenName() !== root.modelData.name) return; root.live = [notif, ...root.live].slice(0, root.maxLive); } } diff --git a/quickshell/popouts/AudioInputBand.qml b/quickshell/popouts/AudioInputBand.qml index e260893..cbadc90 100644 --- a/quickshell/popouts/AudioInputBand.qml +++ b/quickshell/popouts/AudioInputBand.qml @@ -19,7 +19,7 @@ Item { Icon { anchors.verticalCenter: parent.verticalCenter text: Audio.micIcon - color: Audio.micMuted ? Theme.accent : Theme.text + color: Audio.micMuted ? Theme.treatmentOffText : Theme.text font.pixelSize: Theme.fsLarge } @@ -42,7 +42,7 @@ Item { Text { text: Audio.micMuted ? "MUTED" : Audio.micPercent + "%" - color: Audio.micMuted ? Theme.accent : Theme.subtext + color: Audio.micMuted ? Theme.treatmentOffText : Theme.subtext font.family: Theme.fontMono font.pixelSize: Theme.fsMicro font.weight: Font.Bold diff --git a/quickshell/popouts/AudioOutputHero.qml b/quickshell/popouts/AudioOutputHero.qml index c4b1bda..d11c6e7 100644 --- a/quickshell/popouts/AudioOutputHero.qml +++ b/quickshell/popouts/AudioOutputHero.qml @@ -51,7 +51,7 @@ Item { Text { text: Audio.muted ? "MUTED" : "LIVE" - color: Audio.muted ? Theme.accent : Theme.text + color: Audio.muted ? Theme.treatmentOffText : Theme.accent font.family: Theme.fontMono font.pixelSize: Theme.fsMicro font.weight: Font.Bold diff --git a/quickshell/popouts/AudioPopout.qml b/quickshell/popouts/AudioPopout.qml index 4debbcf..13906de 100644 --- a/quickshell/popouts/AudioPopout.qml +++ b/quickshell/popouts/AudioPopout.qml @@ -85,12 +85,12 @@ Popout { Repeater { model: Audio.sinks.length > 1 ? Audio.sinks : [] - PickRow { + ListRow { required property var modelData width: column.width label: modelData.nickname || modelData.description || modelData.name icon: "󰓃" - selected: Audio.sink === modelData + current: Audio.sink === modelData onClicked: Audio.setSink(modelData) } } @@ -147,7 +147,7 @@ Popout { id: levelIcon anchors.verticalCenter: parent.verticalCenter text: levelRow.icon - color: levelRow.muted ? Theme.accent : levelRow.accent + color: levelRow.muted ? Theme.treatmentOffText : levelRow.accent font.pixelSize: Theme.fsLarge TapHandler { onTapped: levelRow.toggled() } @@ -171,7 +171,7 @@ Popout { id: readoutText anchors.verticalCenter: parent.verticalCenter text: levelRow.readout - color: levelRow.muted ? Theme.accent : Theme.subtext + color: levelRow.muted ? Theme.treatmentOffText : Theme.subtext font.family: Theme.fontMono font.pixelSize: Theme.fsMicro font.weight: Font.Bold @@ -189,94 +189,4 @@ Popout { onMoved: v => levelRow.moved(v) } } - - component SectionRule: Item { - property string text: "" - implicitHeight: visible ? 18 : 0 - - Text { - id: sectionLabel - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: parent.text.toUpperCase() - color: Theme.muted - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - font.letterSpacing: 2 - renderType: Text.NativeRendering - } - - Rectangle { - anchors.left: sectionLabel.right - anchors.leftMargin: Theme.padS - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - height: 1 - color: Theme.alpha(Theme.outline, 0.6) - } - } - - component PickRow: Item { - id: pickRow - - property string label: "" - property string icon: "" - property bool selected: false - - signal clicked() - - implicitHeight: 26 - - Rectangle { - anchors.fill: parent - color: pickHover.hovered - ? Theme.alpha(Theme.primary, 0.14) - : (pickRow.selected ? Theme.alpha(Theme.primary, 0.07) : "transparent") - } - - Rectangle { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - width: 3 - height: pickRow.selected ? 18 : 0 - color: Theme.accent - - Behavior on height { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } - } - } - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.padM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.padS - - Icon { - anchors.verticalCenter: parent.verticalCenter - text: pickRow.icon - color: pickRow.selected ? Theme.primary : Theme.muted - font.pixelSize: Theme.fsBody - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: pickRow.label - color: pickRow.selected ? Theme.text : Theme.subtext - font.family: Theme.fontDisplay - font.pixelSize: Theme.fsSmall - font.italic: true - renderType: Text.NativeRendering - } - } - - HoverHandler { - id: pickHover - cursorShape: Qt.PointingHandCursor - } - - TapHandler { - onTapped: pickRow.clicked() - } - } } diff --git a/quickshell/popouts/BatteryPopout.qml b/quickshell/popouts/BatteryPopout.qml index bf75bd4..000163e 100644 --- a/quickshell/popouts/BatteryPopout.qml +++ b/quickshell/popouts/BatteryPopout.qml @@ -52,8 +52,6 @@ Popout { text: Math.round(Battery.percent) + "%" pixelSize: Theme.fsHuge color: Battery.tint - split: 2.5 - splitOpacity: 0.7 } } @@ -68,8 +66,6 @@ Popout { horizontalAlignment: Text.AlignRight text: Battery.timeLabel pixelSize: Theme.fsBody - split: 1.1 - splitOpacity: 0.55 } Text { @@ -89,7 +85,7 @@ Popout { text: "Health " + Math.round(Battery.health) + "%" anchors.right: parent.right horizontalAlignment: Text.AlignRight - color: Battery.health < 70 ? Theme.warn : Theme.muted + color: Battery.health < 70 ? Theme.alarm : Theme.muted font.family: Theme.fontMono font.pixelSize: Theme.fsMicro renderType: Text.NativeRendering diff --git a/quickshell/popouts/BtPopout.qml b/quickshell/popouts/BtPopout.qml index 1e97ca3..45c72ee 100644 --- a/quickshell/popouts/BtPopout.qml +++ b/quickshell/popouts/BtPopout.qml @@ -20,7 +20,7 @@ Popout { PopoutSurface { anchors.fill: parent - tone: Bt.enabled ? Theme.blue : Theme.muted + tone: Bt.enabled ? Theme.accent : Theme.muted PopoutHeader { id: header @@ -29,7 +29,7 @@ Popout { anchors.right: parent.right title: "Bluetooth" icon: Bt.icon - accent: Theme.blue + accent: Theme.accent subtitle: Bt.adapter ? Bt.adapter.name : "NO ADAPTER" } @@ -43,7 +43,7 @@ Popout { P5Button { text: Bt.enabled ? "On" : "Off" icon: Bt.enabled ? "󰂯" : "󰂲" - accent: Bt.enabled ? Theme.blue : Theme.muted + accent: Bt.enabled ? Theme.accent : Theme.muted onClicked: Bt.toggle() } @@ -55,17 +55,13 @@ Popout { } } - Text { + SectionRule { id: listLabel anchors.top: controls.bottom anchors.topMargin: Theme.padM anchors.left: parent.left - text: Bt.devices.length > 0 ? "DEVICES" : "NO DEVICES" - color: Theme.muted - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - font.letterSpacing: 2 - renderType: Text.NativeRendering + anchors.right: parent.right + text: Bt.devices.length > 0 ? "Devices" : "No devices" } ListView { @@ -78,113 +74,41 @@ Popout { spacing: 2 model: Bt.devices - delegate: Item { + delegate: ListRow { id: devRow required property BluetoothDevice modelData width: ListView.view.width - height: 34 - - Rectangle { - anchors.fill: parent - color: devHover.hovered - ? Theme.alpha(Theme.blue, 0.16) - : (devRow.modelData.connected ? Theme.alpha(Theme.blue, 0.08) : "transparent") - } - - Rectangle { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - width: 3 - height: devRow.modelData.connected ? 22 : 0 - color: Theme.accent - - Behavior on height { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } - } - } - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.padM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.padS - - Icon { - anchors.verticalCenter: parent.verticalCenter - text: Bt.deviceIcon(devRow.modelData) - color: devRow.modelData.connected ? Theme.blue : Theme.subtext - font.pixelSize: Theme.fsLarge - } - - Column { - anchors.verticalCenter: parent.verticalCenter - spacing: -2 - - Text { - text: Bt.deviceLabel(devRow.modelData) - color: devRow.modelData.connected ? Theme.text : Theme.subtext - font.family: Theme.fontDisplay - font.pixelSize: Theme.fsSmall - font.italic: true - font.weight: devRow.modelData.connected ? Font.Black : Font.Medium - renderType: Text.NativeRendering - } - - Text { - text: { - if (devRow.modelData.pairing) return "pairing…"; - if (devRow.modelData.connected) return "connected"; - if (devRow.modelData.paired || devRow.modelData.bonded) return "paired"; - return devRow.modelData.address; - } - color: Theme.muted - font.family: Theme.fontMono - font.pixelSize: 8 - renderType: Text.NativeRendering - } - } + icon: Bt.deviceIcon(modelData) + label: Bt.deviceLabel(modelData) + detail: { + if (modelData.pairing) return "pairing…"; + if (modelData.connected) return "connected"; + if (modelData.paired || modelData.bonded) return "paired"; + return modelData.address; } + current: modelData.connected + onClicked: Bt.toggleDevice(modelData) + onRightClicked: if (modelData.paired) modelData.forget() // Device battery, where BlueZ exposes it. - Row { - anchors.right: parent.right - anchors.rightMargin: Theme.padS + Icon { anchors.verticalCenter: parent.verticalCenter - spacing: 3 visible: devRow.modelData.batteryAvailable - - Icon { - anchors.verticalCenter: parent.verticalCenter - text: "󰥉" - color: devRow.modelData.battery > 0.25 ? Theme.ok : Theme.warn - font.pixelSize: Theme.fsSmall - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: Math.round(devRow.modelData.battery * 100) + "%" - color: Theme.subtext - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - renderType: Text.NativeRendering - } + text: "󰥉" + color: devRow.modelData.battery > 0.25 ? Theme.subtext : Theme.alarm + font.pixelSize: Theme.fsSmall } - HoverHandler { - id: devHover - cursorShape: Qt.PointingHandCursor - } - - TapHandler { - acceptedButtons: Qt.LeftButton - onTapped: Bt.toggleDevice(devRow.modelData) - } - - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: if (devRow.modelData.paired) devRow.modelData.forget() + Text { + anchors.verticalCenter: parent.verticalCenter + visible: devRow.modelData.batteryAvailable + text: Math.round(devRow.modelData.battery * 100) + "%" + color: Theme.subtext + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering } } } diff --git a/quickshell/popouts/CalendarPopout.qml b/quickshell/popouts/CalendarPopout.qml index 8fe3b97..5190da5 100644 --- a/quickshell/popouts/CalendarPopout.qml +++ b/quickshell/popouts/CalendarPopout.qml @@ -4,7 +4,7 @@ import "root:/config" import "root:/components" import "root:/services" -// Month grid with the current day slashed in crimson, plus uptime and a couple +// Month grid with the current day slashed in the accent, plus uptime and a couple // of world clocks. Popout { id: root @@ -99,9 +99,9 @@ Popout { width: dayNames.width / 7 horizontalAlignment: Text.AlignHCenter text: modelData - color: index >= 5 ? Theme.alpha(Theme.accent, 0.8) : Theme.muted + color: index >= 5 ? Theme.subtext : Theme.muted font.family: Theme.fontMono - font.pixelSize: 8 + font.pixelSize: Theme.fsMicro font.letterSpacing: 1 renderType: Text.NativeRendering } @@ -126,7 +126,7 @@ Popout { width: grid.width / 7 height: 30 - // Today gets a leaning crimson slab. + // Today gets a leaning accent slab, inked like every selected slab. Skew { anchors.centerIn: parent width: 26 @@ -139,7 +139,7 @@ Popout { anchors.centerIn: parent text: cell.modelData.day color: { - if (cell.modelData.today) return Theme.text; + if (cell.modelData.today) return Theme.treatmentSelectedText; if (!cell.modelData.inMonth) return Theme.alpha(Theme.muted, 0.45); return Theme.subtext; } @@ -167,7 +167,7 @@ Popout { text: "UPTIME" color: Theme.muted font.family: Theme.fontMono - font.pixelSize: 8 + font.pixelSize: Theme.fsMicro font.letterSpacing: 1.5 renderType: Text.NativeRendering } @@ -191,7 +191,7 @@ Popout { text: "TOKYO" color: Theme.muted font.family: Theme.fontMono - font.pixelSize: 8 + font.pixelSize: Theme.fsMicro font.letterSpacing: 1.5 renderType: Text.NativeRendering } @@ -226,9 +226,9 @@ Popout { Skew { anchors.fill: parent - color: stepHover.hovered ? Theme.alpha(Theme.accent, 0.25) : "transparent" + color: stepHover.hovered ? Theme.treatmentHoverFill : "transparent" borderWidth: 1 - borderColor: Theme.alpha(Theme.outline, 0.7) + borderColor: stepHover.hovered ? Theme.treatmentHoverMark : Theme.alpha(Theme.outline, 0.7) } Icon { diff --git a/quickshell/popouts/MediaPopout.qml b/quickshell/popouts/MediaPopout.qml index a1aaaf6..3316891 100644 --- a/quickshell/popouts/MediaPopout.qml +++ b/quickshell/popouts/MediaPopout.qml @@ -80,7 +80,7 @@ Popout { preferredRendererType: Shape.CurveRenderer ShapePath { fillColor: Theme.surface - strokeColor: Theme.alpha(Theme.primary, 0.5) + strokeColor: Theme.surfaceAlt strokeWidth: 2 PathPolyline { path: Geom.chamfer(Geom.parallelogram(108, 108, 0.1), 18, [1, 3]) @@ -93,7 +93,7 @@ Popout { visible: art.status !== Image.Ready text: Media.sourceIcon font.pixelSize: 42 - color: Theme.alpha(Theme.primary, 0.7) + color: Theme.muted } // Spinning rule around the art while playing. @@ -265,14 +265,14 @@ Popout { : (chip.activeFocus ? Theme.treatmentFocusFill : (chip.current - ? Theme.alpha(Theme.primary, 0.22) - : (chipHover.hovered ? Theme.alpha(Theme.primary, 0.1) : "transparent"))) + ? Theme.treatmentActiveFill + : (chipHover.hovered ? Theme.treatmentHoverFill : "transparent"))) borderWidth: chip.activeFocus ? Theme.stroke : 1 borderColor: !chip.enabled ? Theme.treatmentDisabledMark : (chip.activeFocus ? Theme.treatmentFocusMark - : (chip.current ? Theme.primary : Theme.alpha(Theme.outline, 0.7))) + : (chip.current ? Theme.treatmentActiveMark : Theme.alpha(Theme.outline, 0.7))) } Text { @@ -283,7 +283,7 @@ Popout { ? Theme.treatmentDisabledText : (chip.activeFocus ? Theme.treatmentFocusText - : (chip.current ? Theme.text : Theme.muted)) + : (chip.current || chipHover.hovered ? Theme.text : Theme.subtext)) font.family: Theme.fontMono font.pixelSize: Theme.fsMicro renderType: Text.NativeRendering @@ -334,14 +334,17 @@ Popout { color: { if (!tb.enabled) return "transparent"; if (tb.activeFocus) return Theme.treatmentFocusFill; - if (tbHover.hovered) return tb.primary ? Theme.accent : Theme.alpha(Theme.primary, 0.22); - return tb.primary ? Theme.alpha(Theme.primary, 0.18) : "transparent"; + // The play button is the one solid control on the panel: a + // surface slab at rest, the accent slab under the pointer. The + // others stay bare until hovered. + if (tbHover.hovered) return tb.primary ? Theme.accent : Theme.treatmentHoverFill; + return tb.primary ? Theme.surface : "transparent"; } borderWidth: tb.activeFocus ? Theme.stroke * 2 : (tb.primary ? 2 : 1) borderColor: tb.enabled ? (tb.activeFocus ? Theme.treatmentFocusMark - : (tbHover.hovered ? Theme.text : Theme.alpha(Theme.primary, 0.6))) + : (tbHover.hovered ? Theme.treatmentHoverMark : (tb.primary ? Theme.overlay : Theme.surfaceAlt))) : Theme.alpha(Theme.outline, 0.4) Behavior on color { @@ -356,8 +359,9 @@ Popout { color: { if (!tb.enabled) return Theme.alpha(Theme.muted, 0.5); if (tb.activeFocus) return Theme.treatmentFocusText; - if (tbHover.hovered && tb.primary) return Theme.text; - return Theme.primary; + // Ink on the accent slab. White on light blue was barely 2:1. + if (tbHover.hovered && tb.primary) return Theme.ink; + return Theme.text; } } diff --git a/quickshell/popouts/NetPopout.qml b/quickshell/popouts/NetPopout.qml index 91d4e6c..a76dacc 100644 --- a/quickshell/popouts/NetPopout.qml +++ b/quickshell/popouts/NetPopout.qml @@ -76,17 +76,13 @@ Popout { } } - Text { + SectionRule { id: apsLabel anchors.top: controls.bottom anchors.topMargin: Theme.padM anchors.left: parent.left - text: Net.scanning ? "SCANNING…" : "NETWORKS" - color: Theme.muted - font.family: Theme.fontMono - font.pixelSize: Theme.fsMicro - font.letterSpacing: 2 - renderType: Text.NativeRendering + anchors.right: parent.right + text: Net.scanning ? "Scanning…" : "Networks" } ListView { @@ -99,94 +95,44 @@ Popout { spacing: 2 model: Net.wifiNetworks - delegate: Item { + delegate: ListRow { id: apRow required property var modelData width: ListView.view.width - height: 30 + icon: Net.signalIcon(modelData.signalStrength ?? 0) + label: modelData.name + current: modelData.connected - Rectangle { - anchors.fill: parent - color: apHover.hovered - ? Theme.alpha(Theme.primary, 0.14) - : (apRow.modelData.connected ? Theme.alpha(Theme.primary, 0.07) : "transparent") - } - - Rectangle { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - width: 3 - height: apRow.modelData.connected ? 20 : 0 - color: Theme.accent - - Behavior on height { - NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutBack } - } - } - - Row { - anchors.left: parent.left - anchors.leftMargin: Theme.padM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.padS - - Icon { - anchors.verticalCenter: parent.verticalCenter - text: Net.signalIcon(apRow.modelData.signalStrength ?? 0) - color: apRow.modelData.connected ? Theme.primary : Theme.subtext - font.pixelSize: Theme.fsBody - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: apRow.modelData.name - color: apRow.modelData.connected ? Theme.text : Theme.subtext - font.family: Theme.fontDisplay - font.pixelSize: Theme.fsSmall - font.italic: true - font.weight: apRow.modelData.connected ? Font.Black : Font.Medium - renderType: Text.NativeRendering - } - - Icon { - anchors.verticalCenter: parent.verticalCenter - visible: Net.secured(apRow.modelData) - text: "󰌾" - color: Theme.muted - font.pixelSize: Theme.fsMicro + onClicked: { + if (apRow.modelData.connected) { + apRow.modelData.disconnect(); + } else { + // Saved networks connect directly; new secured ones + // need a passphrase, which NetworkManager's own + // agent will prompt for. + apRow.modelData.connect(); } } Text { - anchors.right: parent.right - anchors.rightMargin: Theme.padS anchors.verticalCenter: parent.verticalCenter - text: apRow.modelData.known ? "SAVED" : "" + visible: apRow.modelData.known + text: "SAVED" color: Theme.muted font.family: Theme.fontMono - font.pixelSize: 8 + font.pixelSize: Theme.fsMicro font.letterSpacing: 1 renderType: Text.NativeRendering } - HoverHandler { - id: apHover - cursorShape: Qt.PointingHandCursor - } - - TapHandler { - onTapped: { - if (apRow.modelData.connected) { - apRow.modelData.disconnect(); - } else { - // Saved networks connect directly; new secured ones - // need a passphrase, which NetworkManager's own - // agent will prompt for. - apRow.modelData.connect(); - } - } + Icon { + anchors.verticalCenter: parent.verticalCenter + visible: Net.secured(apRow.modelData) + text: "󰌾" + color: Theme.muted + font.pixelSize: Theme.fsSmall } } } @@ -228,7 +174,7 @@ Popout { text: rateBlock.label.toUpperCase() color: Theme.muted font.family: Theme.fontMono - font.pixelSize: 8 + font.pixelSize: Theme.fsMicro font.letterSpacing: 1.5 renderType: Text.NativeRendering } diff --git a/quickshell/popouts/NotifPopout.qml b/quickshell/popouts/NotifPopout.qml index 5fcda11..ea880b0 100644 --- a/quickshell/popouts/NotifPopout.qml +++ b/quickshell/popouts/NotifPopout.qml @@ -37,7 +37,7 @@ Popout { P5Button { text: Notifs.dnd ? "Silenced" : "Silence" icon: Notifs.dnd ? "󰂛" : "󰂚" - accent: Notifs.dnd ? Theme.accent : Theme.primary + accent: Notifs.dnd ? Theme.treatmentOffText : Theme.primary onClicked: Notifs.toggleDnd() } diff --git a/quickshell/popouts/PopoutHeader.qml b/quickshell/popouts/PopoutHeader.qml index 57129db..a529573 100644 --- a/quickshell/popouts/PopoutHeader.qml +++ b/quickshell/popouts/PopoutHeader.qml @@ -2,8 +2,8 @@ import QtQuick import "root:/config" import "root:/components" -// Title bar for a popout: glyph, split-ink heading, right-aligned status text, -// and a crimson rule with a chopped tail. +// Title bar for a popout: glyph, print-shadowed heading, right-aligned status +// text, and a rule with a chopped accent tail. Item { id: root @@ -32,8 +32,6 @@ Item { text: root.title.toUpperCase() pixelSize: Theme.fsLarge letterSpacing: 1.5 - split: 1.3 - splitOpacity: 0.7 } } @@ -43,7 +41,7 @@ Item { anchors.topMargin: 6 visible: root.subtitle !== "" text: root.subtitle - color: Theme.muted + color: Theme.subtext font.family: Theme.fontMono font.pixelSize: Theme.fsMicro font.letterSpacing: 1 diff --git a/quickshell/popouts/PopoutSurface.qml b/quickshell/popouts/PopoutSurface.qml index 9ba9d5b..25da113 100644 --- a/quickshell/popouts/PopoutSurface.qml +++ b/quickshell/popouts/PopoutSurface.qml @@ -10,7 +10,11 @@ P5Panel { fill: Theme.mantle fillOpacity: 0.97 - border: Theme.alpha(tone, 0.75) + // A neutral keyline. `tone` used to colour the whole border at 75%, and + // since most panels pass the resting text colour that put a pale 2px frame + // around every popout — the loudest thing on screen for no reason. The + // tone lives in the slash now; the frame is structure. + border: Theme.surfaceAlt borderWidth: Theme.stroke halftone: textured halftoneColor: tone diff --git a/quickshell/popouts/TrayMenuList.qml b/quickshell/popouts/TrayMenuList.qml index 4eab649..a6a955f 100644 --- a/quickshell/popouts/TrayMenuList.qml +++ b/quickshell/popouts/TrayMenuList.qml @@ -107,7 +107,7 @@ Column { height: row.entry.isSeparator ? 0 : 26 visible: !row.entry.isSeparator - // Hover is a solid crimson block, the same statement the + // Hover is a solid accent block, the same statement the // workspace ticks and the rail icons make. Skew { anchors.fill: parent diff --git a/quickshell/services/Actions.qml b/quickshell/services/Actions.qml index c69d05a..c316438 100644 --- a/quickshell/services/Actions.qml +++ b/quickshell/services/Actions.qml @@ -17,6 +17,7 @@ Singleton { property bool lockRequested: false property bool sidebarOpen: false property bool launcherOpen: false + property bool askOpen: false // Seed text for the next launcher open. Lets a keybind or a script hand the // launcher a query it did not have to type — `qs ipc call shell prompt @@ -32,6 +33,13 @@ Singleton { // Same story for the power menu: a window per screen, one of them wanted. property string powerMenuScreen: "" + // …and for the ask panel, which also takes the keyboard. + property string askScreen: "" + + // Seed text for the next ask open. A seeded open asks straight away, so + // `qs ipc call shell askAbout "$(wl-paste)"` is a one-shot question. + property string askSeed: "" + signal osdRequested(string kind) function togglePowerMenu() { @@ -57,6 +65,22 @@ Singleton { } function closeLauncher() { root.launcherOpen = false; } + function toggleAsk() { + if (!root.askOpen) { + root.askSeed = ""; + root.askScreen = root.focusedScreenName(); + } + root.askOpen = !root.askOpen; + } + function closeAsk() { root.askOpen = false; } + + function askAbout(seed: string) { + root.askSeed = seed; + root.askScreen = root.focusedScreenName(); + root.askOpen = false; + root.askOpen = true; + } + // Stand in for `fuzzel --dmenu`. See Apps.beginDmenu for the transport. function dmenuLauncher(inPath: string, outPath: string, prompt: string) { Apps.beginDmenu(inPath, outPath, prompt); @@ -106,6 +130,8 @@ Singleton { function lock(): void { root.lock(); } function power(): void { root.togglePowerMenu(); } function launcher(): void { root.toggleLauncher(); } + function ask(): void { root.toggleAsk(); } + function askAbout(question: string): void { root.askAbout(question); } function prompt(seed: string): void { root.promptLauncher(seed); } function dmenu(inPath: string, outPath: string, prompt: string): void { root.dmenuLauncher(inPath, outPath, prompt); diff --git a/quickshell/services/Ask.qml b/quickshell/services/Ask.qml new file mode 100644 index 0000000..8553192 --- /dev/null +++ b/quickshell/services/Ask.qml @@ -0,0 +1,248 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +// Ask — the question panel's brain. +// +// One `claude -p` per turn, streamed. The CLI is the transport rather than the +// API because the subscription lives in the CLI's credentials: nothing here +// ever sees a key, and nothing here has to hold one. +// +// The defaults deliberately strip Claude Code down to a chat: no settings +// sources, no MCP servers, no project files. That is not only about tokens — +// a session started from the shell would otherwise inherit whatever hooks and +// CLAUDE.md the last project left lying around, and answer in its voice. +// Everything below is overridable from ~/.config/quickshell/ask.json. +Singleton { + id: root + + // ---------------------------------------------------------------- config + readonly property string configPath: + Quickshell.env("HOME") + "/.config/quickshell/ask.json" + + property var config: ({}) + + readonly property var defaults: ({ + model: "sonnet", + fallbackModel: "", + systemPrompt: "You are a desktop answer panel. Answer the question directly and " + + "compactly, in markdown, with no preamble. Prefer a few sentences over a " + + "list, and a list over an essay. Say plainly when you do not know.", + keepContext: true, + // "" = none. "user,project,local" is the full Claude Code behaviour. + settingSources: "", + allowedTools: ["WebSearch", "WebFetch"], + disallowedTools: ["Bash", "Edit", "Write", "NotebookEdit", "Agent", "Task"], + workingDirectory: Quickshell.env("HOME"), + maxTurns: 6 + }) + + function setting(key: string): var { + const v = root.config[key]; + return v === undefined || v === null ? root.defaults[key] : v; + } + + readonly property string model: root.setting("model") + + // The model ring. Ctrl+M walks it; whatever ask.json asks for is row zero, + // so a custom model stays reachable even when it is not one of the three. + readonly property var models: { + const ring = ["sonnet", "opus", "haiku"]; + const m = root.model; + return ring.indexOf(m) >= 0 ? ring : [m].concat(ring); + } + + function cycleModel(): void { + const ring = root.models; + const next = ring[(ring.indexOf(root.model) + 1) % ring.length]; + const c = JSON.parse(JSON.stringify(root.config)); + c.model = next; + root.config = c; + store.setText(JSON.stringify(c, null, 2) + "\n"); + } + + FileView { + id: store + path: root.configPath + preload: true + watchChanges: true + printErrors: false + atomicWrites: true + onFileChanged: store.reload() + onLoaded: { + try { + root.config = JSON.parse(store.text()) || ({}); + } catch (e) { + root.config = ({}); + root.error = "ask.json is not valid JSON"; + } + } + // No file yet: write the defaults out so there is something to edit. + onLoadFailed: error => { + root.config = ({}); + store.setText(JSON.stringify(root.defaults, null, 2) + "\n"); + } + } + + // ----------------------------------------------------------------- state + // `turns` is the transcript the panel draws: { role, text } in order. The + // answer being streamed is the last entry and is rewritten in place. + property var turns: [] + property bool busy: false + property string error: "" + property string sessionId: "" + property real lastCost: 0 + property int exitCode: 0 + property string stderrText: "" + + signal answered() + + function clear(): void { + root.stop(); + root.turns = []; + root.sessionId = ""; + root.error = ""; + root.lastCost = 0; + } + + function stop(): void { + if (proc.running) proc.running = false; + root.busy = false; + } + + function ask(question: string): void { + const q = question.trim(); + if (q === "" || root.busy) return; + + root.error = ""; + root.turns = root.turns.concat([ + { role: "user", text: q }, + { role: "assistant", text: "" } + ]); + root.busy = true; + + root.exitCode = 0; + root.stderrText = ""; + proc.running = false; + // stdin is closed explicitly. Inherited from the shell it never ends, + // and the CLI waits three seconds for input that is never coming + // before it starts on the question. + proc.command = ["sh", "-c", 'exec claude "$@" 0) args.push("--allowed-tools", allowed.join(",")); + + const disallowed = root.setting("disallowedTools"); + if (disallowed.length > 0) args.push("--disallowed-tools", disallowed.join(",")); + + // Continuity is a resume, not a re-send: the CLI keeps the transcript, + // so a follow-up costs one question rather than the whole thread. + if (root.setting("keepContext") && root.sessionId !== "") + args.push("--resume", root.sessionId); + + return args; + } + + // The streamed answer lives in the last turn. Rewriting an array element in + // place does not notify, so the array is rebuilt — the transcript is a + // handful of entries, and this keeps the panel's binding honest. + function _append(text: string): void { + const t = root.turns.slice(); + const last = t.length - 1; + if (last < 0 || t[last].role !== "assistant") return; + t[last] = { role: "assistant", text: t[last].text + text }; + root.turns = t; + } + + function _ingest(line: string): void { + if (line === "") return; + + let ev; + try { + ev = JSON.parse(line); + } catch (e) { + return; // partial line, or a stray log + } + + switch (ev.type) { + case "system": + if (ev.subtype === "init" && ev.session_id) root.sessionId = ev.session_id; + return; + + case "stream_event": { + const inner = ev.event; + if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") + root._append(inner.delta.text); + return; + } + + case "result": + root.busy = false; + if (ev.session_id) root.sessionId = ev.session_id; + if (typeof ev.total_cost_usd === "number") root.lastCost = ev.total_cost_usd; + if (ev.is_error) root.error = ev.result || "the request failed"; + // A turn that streamed nothing (a refusal, a rate limit) still has + // an answer in the result field. Use it rather than showing a gap. + else if (root.turns.length > 0 + && root.turns[root.turns.length - 1].text === "" + && ev.result) + root._append(ev.result); + root.answered(); + return; + } + } + + Process { + id: proc + + // Somewhere harmless and stable. The CLI still reads the directory it + // is started in for project context; the shell's own is whatever + // Hyprland handed it at login. + workingDirectory: root.setting("workingDirectory") + + stdout: SplitParser { + onRead: data => root._ingest(data) + } + + // Only shown when the run actually failed. The CLI writes advisory + // warnings here on a good run too, and a warning in red under the + // answer reads as the answer being wrong. + stderr: StdioCollector { + onStreamFinished: root.stderrText = this.text.trim(); + } + + onExited: (code, status) => { + root.busy = false; + root.exitCode = code; + if (code === 0 || root.error !== "") return; + const lines = root.stderrText.split("\n").filter(l => l.trim() !== ""); + root.error = lines.length > 0 + ? lines[lines.length - 1] + : "claude exited with " + code; + } + } +} diff --git a/quickshell/services/Battery.qml b/quickshell/services/Battery.qml index 59b911d..da4c9a5 100644 --- a/quickshell/services/Battery.qml +++ b/quickshell/services/Battery.qml @@ -58,9 +58,9 @@ Singleton { } readonly property color tint: { - if (root.charging || root.full) return Theme.ok; + if (root.charging || root.full) return Theme.text; if (root.critical) return Theme.alarm; - if (root.low) return Theme.warn; + if (root.low) return Theme.alarm; return Theme.primary; } diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 7aba288..0837c65 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -4,6 +4,7 @@ import "root:/config" import "root:/services" import "root:/bar" import "root:/launcher" +import "root:/ask" import "root:/overlays" import "root:/lock" @@ -12,6 +13,7 @@ import "root:/lock" // bar/ the top bar and its modules // popouts/ hover panels hanging off bar modules // launcher/ the application launcher +// ask/ the question panel, dropped from the top edge // overlays/ power menu, notification toasts, OSD // lock/ session lock // services/ data sources (fluxo, pipewire, bluez, upower, mpris, …) @@ -51,6 +53,12 @@ ShellRoot { Launcher {} } + Variants { + model: Quickshell.screens + + AskPanel {} + } + Variants { model: Quickshell.screens diff --git a/systemd/user/claude-account-router.service b/systemd/user/claude-account-router.service new file mode 100644 index 0000000..d322f3a --- /dev/null +++ b/systemd/user/claude-account-router.service @@ -0,0 +1,12 @@ +# Written by `claude-account router install`. Do not edit by hand. +[Unit] +Description=Router for .headroom.localhost +After=network-online.target + +[Service] +ExecStart=/home/narl/.local/bin/claude-account router serve --port 80 +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/systemd/user/claude-headroom@.service b/systemd/user/claude-headroom@.service new file mode 100644 index 0000000..675c5d8 --- /dev/null +++ b/systemd/user/claude-headroom@.service @@ -0,0 +1,13 @@ +# Written by `claude-account headroom install`. Do not edit by hand. +[Unit] +Description=Headroom proxy for the %i Claude account +After=network-online.target + +[Service] +EnvironmentFile=%h/.config/claude-account/headroom-%i.env +ExecStart=/home/narl/.local/share/headroom-venv/bin/headroom proxy --host 127.0.0.1 --port ${CLAUDE_ACCOUNT_PORT} --mode cache --no-telemetry +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/systemd/user/default.target.wants/claude-account-router.service b/systemd/user/default.target.wants/claude-account-router.service new file mode 120000 index 0000000..af74f2c --- /dev/null +++ b/systemd/user/default.target.wants/claude-account-router.service @@ -0,0 +1 @@ +/home/narl/.config/systemd/user/claude-account-router.service \ No newline at end of file diff --git a/systemd/user/default.target.wants/claude-headroom@personal.service b/systemd/user/default.target.wants/claude-headroom@personal.service new file mode 120000 index 0000000..0803fc4 --- /dev/null +++ b/systemd/user/default.target.wants/claude-headroom@personal.service @@ -0,0 +1 @@ +/home/narl/.config/systemd/user/claude-headroom@.service \ No newline at end of file diff --git a/systemd/user/default.target.wants/claude-headroom@work.service b/systemd/user/default.target.wants/claude-headroom@work.service new file mode 120000 index 0000000..0803fc4 --- /dev/null +++ b/systemd/user/default.target.wants/claude-headroom@work.service @@ -0,0 +1 @@ +/home/narl/.config/systemd/user/claude-headroom@.service \ No newline at end of file diff --git a/systemd/user/default.target.wants/enqs-headroom.service b/systemd/user/default.target.wants/enqs-headroom.service deleted file mode 120000 index 54c41d5..0000000 --- a/systemd/user/default.target.wants/enqs-headroom.service +++ /dev/null @@ -1 +0,0 @@ -/home/narl/.config/systemd/user/enqs-headroom.service \ No newline at end of file diff --git a/systemd/user/default.target.wants/headroom-private.service b/systemd/user/default.target.wants/headroom-private.service deleted file mode 120000 index 088ebb0..0000000 --- a/systemd/user/default.target.wants/headroom-private.service +++ /dev/null @@ -1 +0,0 @@ -/home/narl/.config/systemd/user/headroom-private.service \ No newline at end of file diff --git a/systemd/user/default.target.wants/headroom-work.service b/systemd/user/default.target.wants/headroom-work.service deleted file mode 120000 index d9c1774..0000000 --- a/systemd/user/default.target.wants/headroom-work.service +++ /dev/null @@ -1 +0,0 @@ -/home/narl/.config/systemd/user/headroom-work.service \ No newline at end of file diff --git a/systemd/user/default.target.wants/ydotool.service b/systemd/user/default.target.wants/ydotool.service new file mode 120000 index 0000000..e3b4f63 --- /dev/null +++ b/systemd/user/default.target.wants/ydotool.service @@ -0,0 +1 @@ +/usr/lib/systemd/user/ydotool.service \ No newline at end of file diff --git a/systemd/user/headroom-private.service b/systemd/user/headroom-private.service deleted file mode 100644 index 3105eaf..0000000 --- a/systemd/user/headroom-private.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Headroom proxy (private Claude profile) -After=network-online.target - -[Service] -Environment=HEADROOM_WORKSPACE_DIR=%h/.headroom -Environment=HEADROOM_TELEMETRY=off -ExecStart=%h/.local/share/headroom-venv/bin/headroom proxy --host 127.0.0.1 --port 8787 --mode cache --no-telemetry -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target diff --git a/systemd/user/headroom-work.service b/systemd/user/headroom-work.service deleted file mode 100644 index d54ae3a..0000000 --- a/systemd/user/headroom-work.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Headroom proxy (work Claude profile) -After=network-online.target - -[Service] -Environment=HEADROOM_WORKSPACE_DIR=%h/.headroom-work -Environment=HEADROOM_TELEMETRY=off -ExecStart=%h/.local/share/headroom-venv/bin/headroom proxy --host 127.0.0.1 --port 8788 --mode cache --no-telemetry -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target