Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Backend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 }
}
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<q>" with…` (Google, DuckDuckGo fallbacks)
- query: Results, then `Use "<q>" 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.
Expand Down Expand Up @@ -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).

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \"<query>\" 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 |
Expand All @@ -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 |
Expand Down
49 changes: 49 additions & 0 deletions core/LargeType.js
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 5 additions & 2 deletions core/RootSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/screenshots/large-type.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion tests/calculator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions tests/largetype.test.js
Original file line number Diff line number Diff line change
@@ -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("<b>Hello</b>"), "<b>Hello</b>")
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))
})
14 changes: 13 additions & 1 deletion tests/rootsearch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
52 changes: 52 additions & 0 deletions ui/LargeTypeOverlay.qml
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading