diff --git a/Backend.qml b/Backend.qml index 8895670..02b6fc9 100644 --- a/Backend.qml +++ b/Backend.qml @@ -11,6 +11,7 @@ import "core/FleaSearch.js" as FleaSearch import "core/Frecency.js" as Frecency import "core/OmarchyMenu.js" as OmarchyMenu import "core/RootSearch.js" as RootSearch +import "core/LargeType.js" as LargeType import "core/Calculator.js" as Calculator import "services" @@ -477,6 +478,10 @@ Item { } var fallback = RootSearch.FALLBACKS.filter(function(f) { return f.id === (data && data.id) })[0] if (fallback) { + if (fallback.largeType) { + // Blank input comes back empty and the window no-ops on it. + return { largeType: LargeType.displayText(data && data.query) } + } Util.execArgv(["omarchy-launch-browser", RootSearch.fallbackUrl(fallback, data.query)]) return { close: true } } diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..57256a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,12 +116,12 @@ Renamed **raylinux → oma-launcher → oma-cast** on 2026-10-19, then **oma-cas Works: - **Root search** ported from ref 02 §4.2–4.6 (`core/Ranker.js`, `core/Frecency.js`, golden tests). Sections (`core/RootSearch.js`): - empty root: Favorites, Suggestions (frecency > 1, top 6), Commands, Applications - - query: Results, then `Use "" with…` (Google, DuckDuckGo fallbacks) + - query: Results, then `Use "" with…` (Google, DuckDuckGo, Show in Large Type fallbacks) - **Providers:** apps from `shell.appLibrary`, the whole Omarchy menu (about 400 items, with breadcrumbs, guards, ✓ state and font/power-profile provider rows), and the built-in "Omarchy Menu". - **Opening an open app [D]:** ↵ on an app focuses its most recently used window, switching to its workspace, and launches only when none is open. Windows match by desktop id, `StartupWMClass`, or the URL Chromium/Brave web app classes are built from (`RootSearch.findAppWindow`). The focus waits for the `omacast` layer to unmap, since Hyprland refuses focus while it holds the keyboard. Focus doesn't raise on Omarchy 4.5's floating workspaces, so the window is then brought to the top, and one minimized to the dock is moved back first, the way the dock does it. Shift+↵ ("Open New Window") always launches. - **Ranking state:** frecency (+100 per launch, 10-day half-life) and learned search terms in `db/frecency.json`. Aliases (exact → top; duplicates rejected), favourites (Ctrl+Shift+F, reorder Ctrl+Alt+↑↓) and disabled commands live in `db/settings.json` `commands`. - **Navigation:** pushed Omarchy submenus (search within them), the alias editor, back with Esc / Ctrl+[ / Backspace on an empty query / the header arrow, and Shift+Esc to pop to root. -- **Keyboard:** Ctrl+K action panel (filterable, per-row shortcuts), ↵ / Ctrl+↵ / Ctrl+Shift+↵ for the first three actions, Ctrl+1–9, Ctrl+↑↓ to jump sections, PageUp/PageDown, ↑ on an empty root for search history, Ctrl+Shift+C copy, Alt+Shift+D disable, Ctrl + / − / 0 zoom. +- **Keyboard:** Ctrl+K action panel (filterable, per-row shortcuts), ↵ / Ctrl+↵ / Ctrl+Shift+↵ for the first three actions, Ctrl+1–9, Ctrl+↑↓ to jump sections, PageUp/PageDown, ↑ on an empty root for search history, Ctrl+Shift+C copy, Alt+Shift+D disable, Ctrl+L Large Type, Ctrl + / − / 0 zoom. - Navigation bindings (`general.navigationBindings`): `emacs` moves down with Ctrl+N; `vim-motions` moves with Ctrl+J/K and puts the action panel on **Alt+K**. Set it with `omacast call settings.updateGeneralSettings '{"navigationBindings":"vim-motions"}'`. - **Ctrl+P** opens the root dropdown (All / Applications / Commands); emacs Ctrl+P = up only works inside popups. - **Ctrl+T** opens the toast's actions (Copy Error Message on failures, Undo after Disable Command). **Ctrl+Shift+K** opens the About panel for the selected row. @@ -150,7 +150,9 @@ Works: ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). -- **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). +- **Tests:** root search, calculator, Large Type policy and privacy coverage (`node --test tests/*.test.js`). + +**Large Type (fork, 2026-11-03):** Ctrl+L (or the "Show in Large Type" fallback row, which answers with a `{ largeType: query }` outcome from `rootSearch.performAction`) shows the current root-search query across the screen, as Alfred's Large Type does. It is a mode inside `LauncherWindow`, not a second surface: the card hides, `ui/LargeTypeOverlay.qml` paints an opaque theme background with the text, and Esc, a click or Ctrl+L again restores the card with the query and focus intact. Blank or whitespace-only input is a no-op (`core/LargeType.js` `displayText`), the text is snapshotted so it cannot change while shown, and the overlay state resets in `closePopups()` so hide/show cycles never leave it stale. Fitting is Qt's `Text.Fit` between bounds from `core/LargeType.js` (`fitRange`), so sizing stays bounded for any input length; the query renders `Text.PlainText`, never markup. **Default-agent hand-off:** removed 2026-11-02 after the marketplace review; it exposed the question in process arguments (SPEC §2.0). diff --git a/README.md b/README.md index 5900ced..ca9a7d2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,19 @@ cache and state directories to the `omacast` names. In a development checkout, ` the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` moves `oma-cast.lua` to `omacast.lua`, keeping your key. `omacast doctor` reports anything left over. +## Large Type + +Type a message in the launcher and press **Ctrl+L** — it appears in very large +characters across the whole screen, so someone across the room can read it. +**Esc**, a click anywhere, or **Ctrl+L** again brings the launcher back with +your text still in the search field, ready to edit. "Show in Large Type" also +appears under the "Use \"\" with…" fallbacks. + +The overlay follows your Omarchy theme, sizes the text to fit anything from a +word to a wall of text, and renders the query as plain text — never markup. + +![Large Type showing 'Standup in 5 minutes' across the screen](docs/screenshots/large-type.png) + ## Keyboard | Key | Does | @@ -122,6 +135,7 @@ the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` mo | `Shift+↵` | Open a new window of an app (`↵` switches to the one already open) | | `Ctrl+K` | Open the action panel | | `Super+V` / `Ctrl+V` / `Ctrl+Shift+V` / `Shift+Insert` | Paste text into the query (also accepts clipboard-history selections) | +| `Ctrl+L` | Show the query in Large Type (`Esc` dismisses) | | `Ctrl+1`…`9` | Pick a result | | `↑` on an empty search | Search history | | `Ctrl+↑` / `Ctrl+↓` | Jump between sections | diff --git a/core/LargeType.js b/core/LargeType.js new file mode 100644 index 0000000..a0e74f4 --- /dev/null +++ b/core/LargeType.js @@ -0,0 +1,49 @@ + +// Large Type (Ctrl+L): the launcher query shown across the whole screen, as +// Alfred's Large Type does (ref: alfredapp.com/help/features/large-type). +// This module is policy only — the QML side owns measuring and painting — +// so both the display guard and the font bounds are testable in node. + +// Outer margin as a fraction of the smaller screen edge, so the glyphs keep +// off the bezel at any aspect ratio. +var MARGIN_RATIO = 0.08 + +// Font bounds in px at zoom 1. Text.Fit shrinks from `max` down to `min`; +// past `min` the text is clipped, which beats shrinking to unreadable. +var MIN_PIXEL_SIZE = 24 +var MAX_PIXEL_SIZE = 1024 + +// The text to display, or "" when there is nothing to show. Content is kept +// exactly (case, punctuation, inner spacing, markup included); only outer +// whitespace is dropped, and blank input becomes "" so the caller can no-op +// instead of taking over the screen. +function displayText(query) { + return String(query == null ? "" : query).trim() +} + +// Pixel-size bounds for a Text.Fit inside a screen of w × h. Returns the +// text box (inside the margins) plus the font range. Null when the geometry +// is unusable. Fitting is text-independent: Text.Fit walks the sizes itself, +// so the loop stays inside Qt and these bounds can never be exceeded. +function fitRange(w, h, zoom) { + w = Number(w) + h = Number(h) + if (!(w > 0) || !(h > 0)) return null + var z = zoom > 0 ? Number(zoom) : 1 + var margin = Math.min(w, h) * MARGIN_RATIO + var boxW = Math.max(1, Math.floor(w - margin * 2)) + var boxH = Math.max(1, Math.floor(h - margin * 2)) + var max = Math.max(1, Math.min(Math.round(MAX_PIXEL_SIZE * z), Math.min(boxW, boxH))) + var min = Math.min(Math.max(1, Math.round(MIN_PIXEL_SIZE * z)), max) + return { min: min, max: max, width: boxW, height: boxH } +} + +if (typeof module !== "undefined") { + module.exports = { + MARGIN_RATIO: MARGIN_RATIO, + MIN_PIXEL_SIZE: MIN_PIXEL_SIZE, + MAX_PIXEL_SIZE: MAX_PIXEL_SIZE, + displayText: displayText, + fitRange: fitRange + } +} diff --git a/core/RootSearch.js b/core/RootSearch.js index b424a03..f30a817 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -2,8 +2,11 @@ var SUGGESTIONS_LIMIT = 6 var FALLBACKS = [ - { id: "fallback:google", title: "Search Google", icon: "", url: "https://www.google.com/search?q={query}" }, - { id: "fallback:duckduckgo", title: "Search DuckDuckGo", icon: "", url: "https://duckduckgo.com/?q={query}" } + { id: "fallback:google", title: "Search Google", icon: "", url: "https://www.google.com/search?q={query}" }, + { id: "fallback:duckduckgo", title: "Search DuckDuckGo", icon: "", url: "https://duckduckgo.com/?q={query}" }, + // Not a web search: Backend's performAction answers it with a `largeType` + // outcome for the window (core/LargeType.js holds the display policy). + { id: "fallback:large-type", title: "Show in Large Type", icon: "\uf034", subtitle: "Display the query across the screen · Ctrl+L", largeType: true } ] // Web searches as root commands with an inline argument pill (ref 04 §2.2 diff --git a/docs/screenshots/large-type.png b/docs/screenshots/large-type.png new file mode 100644 index 0000000..566f765 Binary files /dev/null and b/docs/screenshots/large-type.png differ diff --git a/tests/calculator.test.js b/tests/calculator.test.js index bde8917..677a612 100644 --- a/tests/calculator.test.js +++ b/tests/calculator.test.js @@ -170,7 +170,7 @@ test("root search puts the Calculator section first; ↵ copies, Ctrl ↵ pastes assert.equal(sections[0].id, "calculator") assert.equal(sections[0].rows[0].candidate.calcResult, "4") assert.equal(sections.at(-1).id, "fallback-commands") - assert.deepEqual(sections.at(-1).rows.map((r) => r.candidate.id), ["fallback:google", "fallback:duckduckgo"]) + assert.deepEqual(sections.at(-1).rows.map((r) => r.candidate.id), ["fallback:google", "fallback:duckduckgo", "fallback:large-type"]) const actions = RS.actionsFor({ kind: "calculator" }).map((a) => a.id) assert.deepEqual(actions.slice(0, 2), ["copy-answer", "paste-answer"]) const calcHistory = RS.rootSections(RS.BUILTINS.map(RS.builtinCandidate), "calc hist", { commands: {}, frecency: {}, nowMs: NOW }, deps) diff --git a/tests/largetype.test.js b/tests/largetype.test.js new file mode 100644 index 0000000..de295ca --- /dev/null +++ b/tests/largetype.test.js @@ -0,0 +1,59 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const LT = require("../core/LargeType.js") + +test("displayText keeps content exactly, including markup and accents", () => { + assert.equal(LT.displayText("Hello, World!"), "Hello, World!") + assert.equal(LT.displayText("Hello"), "Hello") + assert.equal(LT.displayText("Reunião às 15h — até já!"), "Reunião às 15h — até já!") + assert.equal(LT.displayText("a".repeat(4000)), "a".repeat(4000)) +}) + +test("displayText trims outer whitespace only", () => { + assert.equal(LT.displayText(" padded "), "padded") + assert.equal(LT.displayText("keep inner spacing"), "keep inner spacing") +}) + +test("displayText rejects blank input so the overlay never takes over empty", () => { + assert.equal(LT.displayText(""), "") + assert.equal(LT.displayText(" \t\n "), "") + assert.equal(LT.displayText(null), "") + assert.equal(LT.displayText(undefined), "") +}) + +test("fitRange is sane on a desktop-sized screen", () => { + const fit = LT.fitRange(1920, 1080, 1) + assert.ok(fit, "expected a range for 1920x1080") + assert.ok(fit.min >= 1) + assert.ok(fit.max > fit.min) + assert.ok(fit.max <= LT.MAX_PIXEL_SIZE) + assert.ok(fit.width < 1920 && fit.height < 1080, "text box leaves a margin") + assert.ok(fit.width > 0 && fit.height > 0) +}) + +test("fitRange never returns NaN, negatives or an inverted range", () => { + const boxes = [[0, 0, 1], [-10, 500, 1], [500, -10, 1], [1, 1, 1], [1920, 1080, 0], [1920, 1080, -2], [Number.NaN, 1080, 1], [10, Number.NaN, 1.5]] + for (const [w, h, z] of boxes) { + const fit = LT.fitRange(w, h, z) + if (!fit) continue + for (const v of [fit.min, fit.max, fit.width, fit.height]) + assert.ok(Number.isFinite(v) && v >= 1, `bad value ${v} for ${w}x${h} zoom ${z}`) + assert.ok(fit.min <= fit.max, `inverted range for ${w}x${h}`) + } +}) + +test("a tiny box collapses to a valid range instead of failing", () => { + const fit = LT.fitRange(4, 4, 1) + assert.ok(fit) + assert.equal(fit.min, fit.max) + assert.ok(fit.min >= 1) + assert.ok(fit.width >= 1 && fit.height >= 1) +}) + +test("fitRange scales with uiZoom but stays bounded", () => { + // Large enough that the absolute cap, not the screen edge, is binding. + const a = LT.fitRange(4000, 3000, 1) + const b = LT.fitRange(4000, 3000, 1.5) + assert.ok(b.max > a.max) + assert.ok(b.max <= Math.round(LT.MAX_PIXEL_SIZE * 1.5)) +}) diff --git a/tests/rootsearch.test.js b/tests/rootsearch.test.js index f745aa4..0e618d1 100644 --- a/tests/rootsearch.test.js +++ b/tests/rootsearch.test.js @@ -51,7 +51,19 @@ test("a query always ends with the fallback section", () => { assert.equal(sections.length, 1) assert.equal(sections[0].id, "fallback-commands") assert.equal(sections[0].title, "Use “zzzz-nothing” with…") - assert.deepEqual(titles(sections[0]), ["Search Google", "Search DuckDuckGo"]) + assert.deepEqual(titles(sections[0]), ["Search Google", "Search DuckDuckGo", "Show in Large Type"]) +}) + +test("Show in Large Type is a query-level fallback with a display action", () => { + const row = results("meeting at noon").at(-1).rows.at(-1).candidate + assert.equal(row.id, "fallback:large-type") + assert.equal(row.title, "Show in Large Type") + assert.equal(row.kind, "fallback") + assert.ok(!row.url, "large type is not a web search") + assert.deepEqual(RS.actionsFor(row).map(a => a.id), ["open"]) + assert.equal(RS.actionsFor(row)[0].title, "Show in Large Type") + assert.ok(results("").flatMap(s => s.rows).every(r => r.candidate.id !== row.id), "never on the empty root") + assert.ok(RS.submenuSections(candidates(), "trigger", "show me", ctx(), deps).flatMap(s => s.rows).every(r => r.candidate.id !== row.id), "never inside submenus") }) test("an exact alias puts its command on top (Phase 1 accept)", () => { diff --git a/ui/LargeTypeOverlay.qml b/ui/LargeTypeOverlay.qml new file mode 100644 index 0000000..38d95a8 --- /dev/null +++ b/ui/LargeTypeOverlay.qml @@ -0,0 +1,52 @@ +import QtQuick +import qs.Commons +import "../core/LargeType.js" as LargeType + +// Large Type (Ctrl+L): the query across the whole screen, as Alfred's Large +// Type does. Dynamic text is always PlainText — user input is never markup — +// and Text.Fit walks the sizes itself inside the bounds from +// core/LargeType.js, so fitting stays bounded for any input length. +Rectangle { + id: overlay + + property string text: "" + property var look: null + property real zoom: 1 + + function px(v) { return Math.round(Style.space(v) * overlay.zoom) } + + readonly property var fit: LargeType.fitRange(overlay.width, overlay.height, overlay.zoom) + + color: overlay.look ? overlay.look.background : "#000000" + + Text { + id: display + anchors.centerIn: parent + width: overlay.fit ? overlay.fit.width : parent.width + height: overlay.fit ? overlay.fit.height : parent.height + textFormat: Text.PlainText + text: overlay.text + color: overlay.look ? overlay.look.foreground : "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + fontSizeMode: Text.Fit + font.family: overlay.look ? overlay.look.fontFamily : "" + font.pixelSize: overlay.fit ? overlay.fit.max : 64 + minimumPixelSize: overlay.fit ? overlay.fit.min : 8 + clip: true + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: overlay.px(16) + textFormat: Text.PlainText + text: "Esc to close" + visible: overlay.width >= 500 && overlay.height >= 300 + color: overlay.look ? overlay.look.foreground : "#ffffff" + opacity: 0.4 + font.family: overlay.look ? overlay.look.fontFamily : "" + font.pixelSize: Math.round(Style.font.bodySmall * overlay.zoom) + } +} diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..70ce0fc 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -5,6 +5,7 @@ import qs.Commons import qs.Commons as Commons import qs.Ui import "../core/RootSearch.js" as RootSearch +import "../core/LargeType.js" as LargeType import "../core/Rows.js" as Rows // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen @@ -79,6 +80,10 @@ PanelWindow { property var aboutRows: [] property var alert: null + // Large Type (Ctrl+L): the query across the screen while the card is hidden. + property bool largeTypeOpen: false + property string largeTypeText: "" + // Inline argument pills for the selected command: argFocus -1 is the // search field, 0.. an argument. property var argDefs: [] @@ -88,8 +93,9 @@ PanelWindow { // Navigation bindings (ref 04 §2.8): emacs moves with Ctrl+N (Ctrl+P is the // dropdown); vim-motions with Ctrl+J/K, which moves the action panel to - // Alt+K. Ctrl+B/F and Ctrl+H/L move a text cursor in Raycast; our search - // field has none, so they do nothing here. [D] + // Alt+K. Ctrl+B/F and Ctrl+H move a text cursor in Raycast; our search + // field has none, so they do nothing here, and Ctrl+L shows the query in + // Large Type. [D] readonly property bool vim: window.general.navigationBindings === "vim-motions" readonly property string actionPanelKeys: window.vim ? "Alt K" : "Ctrl K" @@ -119,6 +125,7 @@ PanelWindow { window.toastPanelOpen = false window.aboutOpen = false window.alert = null + window.largeTypeOpen = false } function prepare(payload) { @@ -323,6 +330,7 @@ PanelWindow { var outcome = r.data || {} if (outcome.confirm) { window.alert = { spec: outcome.confirm, payload: payload }; return } if (outcome.push) { window.push(outcome.push); return } + if (outcome.largeType) { window.openLargeType(outcome.largeType); return } if (outcome.close) { if (outcome.hud) window.hud(outcome.hud) window.dismissRequested() @@ -341,6 +349,21 @@ PanelWindow { window.request(payload) } + // Large Type shows the query as-is; blank input is a no-op instead of a + // blank takeover. The text is snapshotted so it can't change while shown. + function openLargeType(text) { + var display = LargeType.displayText(text) + if (!display) return + window.largeTypeText = display + window.largeTypeOpen = true + } + + function closeLargeType() { + window.largeTypeOpen = false + window.largeTypeText = "" + keyCatcher.forceActiveFocus() + } + function performNth(n) { var list = window.actionsForSelected() if (list.length > n) window.perform(list[n].id) @@ -560,6 +583,13 @@ PanelWindow { var key = event.key var enter = key === Qt.Key_Return || key === Qt.Key_Enter + // Large Type is topmost: Esc or Ctrl+L leaves it, and everything else is + // swallowed so typing can't land in the query underneath. + if (window.largeTypeOpen) { + if (key === Qt.Key_Escape || (ctrl && !shift && !alt && key === Qt.Key_L)) window.closeLargeType() + return true + } + if (window.alert) { if (key === Qt.Key_Escape) window.alert = null else if (enter) window.confirmAlert() @@ -594,6 +624,10 @@ PanelWindow { return window.handlePopupKey(event, actionPanel.item, ctrl, alt) } + // Ctrl+L shows the query in Large Type; Raycast used it to move a text + // cursor, which our search field has no use for (see the note above). + if (ctrl && !shift && !alt && !meta && key === Qt.Key_L && LargeType.displayText(window.query)) { window.openLargeType(window.query); return true } + if (window.view.kind === "alias") { if (enter) { window.saveAlias(); return true } if (key === Qt.Key_Space) return true @@ -657,11 +691,15 @@ PanelWindow { MouseArea { anchors.fill: parent - onClicked: if (!window.backend.config.keepVisibleOnFocusLoss) window.dismissRequested() + onClicked: { + if (window.largeTypeOpen) { window.closeLargeType(); return } + if (!window.backend.config.keepVisibleOnFocusLoss) window.dismissRequested() + } } BorderSurface { id: card + visible: !window.largeTypeOpen width: window.cardWidth height: window.compact ? window.headerHeight + card.borderTop + card.borderBottom : window.fullHeight radius: Math.max(Style.cornerRadius, window.px(8)) @@ -1131,4 +1169,12 @@ PanelWindow { } } } + + LargeTypeOverlay { + visible: window.largeTypeOpen + anchors.fill: parent + text: window.largeTypeText + look: window.look + zoom: window.zoom + } }