From 1f7f5aef8aedda3519ed07fe80110571cb5b5fa8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 28 Sep 2026 23:27:00 +0200 Subject: [PATCH] fixed some quickshell stuff + hypr --- hypr/lua/apps.lua | 2 +- hypr/lua/autostart.lua | 5 ++ hypr/lua/binds/shell.lua | 4 ++ hypr/lua/mirror.lua | 107 +++++++++++++++++++++++++++++++++++ quickshell/bar/Tray.qml | 29 ++++++++-- quickshell/services/Apps.qml | 15 +++-- 6 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 hypr/lua/mirror.lua diff --git a/hypr/lua/apps.lua b/hypr/lua/apps.lua index fced52e..fc5b72a 100644 --- a/hypr/lua/apps.lua +++ b/hypr/lua/apps.lua @@ -17,7 +17,7 @@ return { -- results appear as you type; a dmenu could only filter a list it was -- handed once, which made searching a two-step. It spawns its own terminal -- (class pdfs-prompt, floated in lua/rules/windows.lua). - pdfs = "pdfs-prompt --fzf", + pdfs = "pdfs-prompt --gtk", -- bluetooth menu bluetooth = "fluxo bt menu", diff --git a/hypr/lua/autostart.lua b/hypr/lua/autostart.lua index 4413800..043191b 100644 --- a/hypr/lua/autostart.lua +++ b/hypr/lua/autostart.lua @@ -16,6 +16,11 @@ hl.on("hyprland.start", function() hl.exec_cmd("wl-paste --type text --watch cliphist store") hl.exec_cmd("wl-paste --type image --watch cliphist store") + -- pdfs-prompt has no hidden/resident mode, so the first search of the + -- session is cold. Run one throwaway query once the Proton Drive daemon + -- is up to pull the binary into the page cache and warm the daemon's index. + hl.exec_cmd("sh -c 'sleep 15; pdfs-prompt --feed a >/dev/null 2>&1'") + -- hl.exec_cmd("uwsm app -- nextcloud --background") -- hl.exec_cmd("uwsm app -- rclone mount google_drive: ~/gdrive") -- hl.exec_cmd("uwsm app -- protonvpn-app") diff --git a/hypr/lua/binds/shell.lua b/hypr/lua/binds/shell.lua index 6ec3d27..d309345 100644 --- a/hypr/lua/binds/shell.lua +++ b/hypr/lua/binds/shell.lua @@ -12,3 +12,7 @@ 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" }) + +-- Not a shell call, but it lives with the other session toggles. +local mirror = require("lua/mirror") +hl.bind(mod .. " + SHIFT + P", mirror.toggle, { description = "mirror laptop to all screens" }) diff --git a/hypr/lua/mirror.lua b/hypr/lua/mirror.lua new file mode 100644 index 0000000..8b5eaaf --- /dev/null +++ b/hypr/lua/mirror.lua @@ -0,0 +1,107 @@ +-- Presentation mode: every external output shows the laptop panel. +-- +-- Toggled from lua/binds/shell.lua. Mirroring is just a `mirror` field on the +-- monitor rule, so switching it off clears that field and re-applies the specs +-- from lua/monitors.lua to put the Samsung back where it belongs. +-- +-- Anything plugged in while mirroring is on gets mirrored too, which is the +-- usual order at a talk: press the key, then find the HDMI cable. + +local monitors = require("lua/monitors") + +local M = {} + +local function notify(text) + hl.notification.create({ text = text, timeout = 3000 }) +end + +-- The panel is off while docked with the lid shut, see lua/lid.lua. Mirroring +-- a disabled output would leave every screen black. +local function builtin() + for _, mon in ipairs(hl.get_monitors()) do + if mon.name == monitors.laptop then + return mon + end + end + return nil +end + +-- A mirroring output drops out of hl.get_monitors(), so the only place left +-- to find it is the panel's own `mirrors` list. That list is also the state: +-- a Lua flag would reset on every config reload while the outputs stay +-- mirrored. +local function mirroring(panel) + local names = {} + for _, mon in ipairs(panel and panel.mirrors or {}) do + table.insert(names, mon.name) + end + return names +end + +-- Outputs keep their own mode while mirroring, and the panel image gets +-- rescaled to fit it every frame. The Dells at 1920x1200 flicker with that, +-- so match the panel's resolution; Hyprland falls back to the preferred mode +-- if the output cannot do it. +local function mirror_all(panel) + local mode = panel.width .. "x" .. panel.height + for _, mon in ipairs(monitors.externals_present()) do + hl.monitor({ output = mon.name, mirror = monitors.laptop, mode = mode, position = "auto" }) + end +end + +-- The bar and popouts stay on the outputs they were created for, so +-- respawn the shell the same way lua/autostart.lua starts it. +local function restart_clients() + hl.exec_cmd("systemctl restart --user hyprpaper") + hl.exec_cmd("sh -c 'qs kill; exec uwsm app -- qs'") +end + +-- hl.monitor() only takes effect after the calling handler returns, so +-- anything that has to see the new layout goes on a timer. +local function later(fn) + hl.timer(fn, { timeout = 500, type = "oneshot" }) +end + +-- Clearing `mirror` alone brings the image back, but Hyprland never +-- re-announces the output to clients: hyprpaper and Quickshell keep seeing +-- only the panel, however often they restart. Cycling the output through +-- disabled makes it a fresh wl_output. Undeclared outputs get their auto +-- defaults back, apply_external() then overrides what lua/monitors.lua knows +-- better. +local function unmirror_all(names) + for _, name in ipairs(names) do + hl.monitor({ output = name, mirror = "", disabled = true }) + end + later(function() + for _, name in ipairs(names) do + hl.monitor({ output = name, disabled = false, mode = "preferred", position = "auto" }) + end + monitors.apply_external() + later(restart_clients) + end) +end + +function M.toggle() + local panel = builtin() + local mirrored = mirroring(panel) + + if #mirrored > 0 then + unmirror_all(mirrored) + notify("Mirroring off") + elseif panel then + mirror_all(panel) + later(restart_clients) + notify("Mirroring " .. monitors.laptop .. " to all screens") + else + notify("Laptop panel is off, open the lid first") + end +end + +hl.on("monitor.added", function() + local panel = builtin() + if #mirroring(panel) > 0 then + mirror_all(panel) + end +end) + +return M diff --git a/quickshell/bar/Tray.qml b/quickshell/bar/Tray.qml index 88c2967..6ffd85d 100644 --- a/quickshell/bar/Tray.qml +++ b/quickshell/bar/Tray.qml @@ -16,15 +16,32 @@ Column { spacing: Theme.padS + 2 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"] + // Applets whose whole job the rail already does. blueman keeps running as + // the pairing agent, but its icon was a second bluetooth glyph a few slots + // below the rail's own. + readonly property var hiddenIds: ["blueman"] readonly property var shownItems: SystemTray.items.values .filter(item => root.hiddenIds.indexOf(item.id) === -1) + // Electron and Chromium apps send only a full-colour pixmap, and flattening + // that loses everything inside the outline: Brave's lion became a plain + // shield. The icon theme often ships a panel glyph drawn for exactly this + // job, so look one up by the app's name and use the pixmap only when none + // exists. The id is no help here ("chrome_status_icon_1" for Brave and + // Proton Pass alike), so the tooltip title is the name we go by. + function themedIcon(item: SystemTrayItem): string { + if (item.icon.startsWith("image://icon/")) return item.icon; + + const name = (item.tooltipTitle || item.title || item.id) + .toLowerCase().replace(/[^a-z0-9]+/g, "-"); + for (const suffix of ["-tray", "-panel", "-symbolic"]) { + const path = Quickshell.iconPath(name + suffix, true); + if (path !== "") return path; + } + return item.icon; + } + Repeater { model: root.shownItems @@ -45,7 +62,7 @@ Column { IconImage { id: img anchors.fill: parent - source: entry.modelData.icon + source: root.themedIcon(entry.modelData) asynchronous: true layer.enabled: true diff --git a/quickshell/services/Apps.qml b/quickshell/services/Apps.qml index 3e02910..8772c9f 100644 --- a/quickshell/services/Apps.qml +++ b/quickshell/services/Apps.qml @@ -304,18 +304,25 @@ Singleton { // So when the target declares no placeholder we build the command line // ourselves and hand uwsm an argv instead of an ID. It is still scoped and // still supervised; we just did the expansion. + // + // The same fallback covers entries whose file name uwsm will not accept as + // an ID at all. Its check is the regex below, so `Proton Authenticator + // .desktop` and most Steam/Lutris shortcuts ("Hollow Knight.desktop") fail + // with "Invalid Desktop Entry ID" before anything runs. readonly property var fieldRe: /%[fFuU]/ readonly property var anyFieldRe: /^%[a-zA-Z]$/ + readonly property var uwsmIdRe: /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*\.desktop(:.*)?$/ function launch(item: var, arg: string): void { root._remember(item.key); + const byId = root.uwsmIdRe.test(item.key); - if (arg === "") { + if (byId && arg === "") { Quickshell.execDetached(["uwsm", "app", "--", item.key]); return; } - if (root.fieldRe.test(item.exec || "")) { + if (byId && root.fieldRe.test(item.exec || "")) { Quickshell.execDetached(["uwsm", "app", "--", item.key, arg]); return; } @@ -323,10 +330,10 @@ Singleton { const argv = root._tokenize(item.exec || "") .filter(t => !root.anyFieldRe.test(t)); if (argv.length === 0) { - Quickshell.execDetached(["uwsm", "app", "--", item.key]); + if (byId) Quickshell.execDetached(["uwsm", "app", "--", item.key]); return; } - argv.push(arg); + if (arg !== "") argv.push(arg); // Without this the unit is named after the bare executable and every // app launched this way collides in the unit namespace.