diff --git a/Backend.qml b/Backend.qml index 8895670..28cab69 100644 --- a/Backend.qml +++ b/Backend.qml @@ -23,6 +23,7 @@ Item { property var appLibrary: null property var summon: null + property var dismiss: null property var config: Schema.withConfigDefaults(null) property bool directoriesReady: false @@ -210,6 +211,8 @@ Item { } function runShell(command) { + // Match Omarchy's menu: hide before a command can freeze/capture the screen. + if (backend.dismiss) backend.dismiss() var summon = OmarchyMenu.summonAction(command) if (summon) backend.summonPlugin(summon.id, summon.payload) else Util.execDetached(command) diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..7d649d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,7 @@ Works: - **Alerts:** Shut Down, Restart, Log Out and Delete All Entries ask first (↵ confirms, Esc cancels). `omacast run` skips the alert. - **Feedback:** toasts in the footer and a HUD through Omarchy's OSD when the window closes; static footer "Loading…" appears only after 500 ms of visible loading, yields to toasts and resets on hide. The header divider stays still. - **Window behaviour:** + - Omarchy actions dismiss the launcher before executing (including Text/OCR). A runtime Hyprland layer rule disables compositor fades for `omacast`, like the stock overlays, and is reapplied after config reload. No user keybindings or config files are changed. - The `escapeKeyBehavior` setting works both ways. - Pop-to-root runs after `popToRootTimeout`. - Compact mode shows only the header until you type. diff --git a/Launcher.qml b/Launcher.qml index f14679f..9f87acc 100644 --- a/Launcher.qml +++ b/Launcher.qml @@ -74,6 +74,7 @@ Item { Backend { id: backend appLibrary: root.shell ? root.shell.appLibrary : null + dismiss: function() { root.hide() } summon: function(id, payload) { return root.shell ? root.shell.summon(id, payload) : false } } diff --git a/tests/capture-dismiss.test.js b/tests/capture-dismiss.test.js new file mode 100644 index 0000000..83a966f --- /dev/null +++ b/tests/capture-dismiss.test.js @@ -0,0 +1,84 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const path = require("node:path") +const vm = require("node:vm") +const OmarchyMenu = require("../core/OmarchyMenu.js") + +const backendText = fs.readFileSync(path.join(__dirname, "../Backend.qml"), "utf8") +const windowText = fs.readFileSync(path.join(__dirname, "../ui/LauncherWindow.qml"), "utf8") +const launcherText = fs.readFileSync(path.join(__dirname, "../Launcher.qml"), "utf8") + +function method(source, name, context) { + const start = source.indexOf(" function " + name + "(") + assert.ok(start >= 0, name) + const end = source.indexOf("\n }", start) + 4 + return vm.runInNewContext("(" + source.slice(start, end) + ")", context) +} + +function harness() { + const events = [] + let visible = true + const backend = { + dismiss() { visible = false; events.push("hide") }, + summonPlugin(id, payload) { assert.equal(visible, false); events.push([id, payload]) } + } + const context = { + backend, OmarchyMenu, + Util: { execDetached(command) { assert.equal(visible, false); events.push(command) } } + } + return { events, backend, run: method(backendText, "runShell", context) } +} + +for (const command of ["omarchy-capture-text", "omarchy-capture-screenshot", "custom-command --literal '*' "]) { + test("dismiss before executing " + command, () => { + const h = harness() + h.run(command) + assert.deepEqual(h.events, ["hide", command]) + }) +} + +test("shell summon actions still route natively, after dismissal", () => { + const h = harness() + h.run("omarchy-shell shell summon omarchy.clipboard") + assert.deepEqual(h.events, ["hide", ["omarchy.clipboard", "{}"]]) +}) + +test("headless backends can still execute without a dismissal hook", () => { + const commands = [] + const run = method(backendText, "runShell", { + backend: { dismiss: null }, OmarchyMenu, + Util: { execDetached: command => commands.push(command) } + }) + run("omarchy-capture-text") + assert.deepEqual(commands, ["omarchy-capture-text"]) +}) + +test("UI and IPC actions share the backend dismissal hook", () => { + assert.match(launcherText, /dismiss: function\(\) \{ root\.hide\(\) \}/) + assert.match(backendText, /case "omarchy-action":[\s\S]*?backend\.runShell\(candidate\.action\)/) +}) + +test("capture layer rule uses the current namespace and is reapplied on config reload", () => { + const commands = [] + const window = {} + window.applyLayerRule = method(windowText, "applyLayerRule", { + Quickshell: { execDetached: args => commands.push(Array.from(args)) } + }) + const startup = windowText.match(/Component\.onCompleted: (window\.applyLayerRule\(\))/) + assert.ok(startup) + vm.runInNewContext(startup[1], { window }) + const handler = windowText.match(/function onRawEvent\(event\) \{([\s\S]*?)\n \}/) + assert.ok(handler) + const onRawEvent = vm.runInNewContext("(function(event) {" + handler[1] + "})", { window }) + onRawEvent(null) + onRawEvent({ name: "focusedmon" }) + assert.equal(commands.length, 1) + onRawEvent({ name: "configreloaded" }) + assert.equal(commands.length, 2) + assert.deepEqual(commands[0], commands[1]) + assert.deepEqual(commands[0].slice(0, 2), ["hyprctl", "eval"]) + assert.match(commands[0][2], /namespace = "\^omacast\$"/) + assert.match(commands[0][2], /no_anim = true/) + assert.match(commands[0][2], /animation = "none"/) +}) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..e1df7bb 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Hyprland import Quickshell.Wayland import qs.Commons import qs.Commons as Commons @@ -41,6 +42,21 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + // [D] Match Omarchy's overlays: an unmapped layer must not linger in a + // compositor fade when Text/OCR freezes the screen. Runtime rules are lost + // on config reload, so reapply without changing the user's Hyprland files. + function applyLayerRule() { + Quickshell.execDetached(["hyprctl", "eval", + 'hl.layer_rule({ name = "omacast", match = { namespace = "^omacast$" }, no_anim = true, animation = "none" })']) + } + Component.onCompleted: window.applyLayerRule() + Connections { + target: Hyprland + function onRawEvent(event) { + if (event && String(event.name) === "configreloaded") window.applyLayerRule() + } + } + readonly property var general: { window.backend.storeRevision var r = window.backend.ready ? window.backend.callSync("settings.getGeneralSettings", null) : null