import QtQuick import Quickshell.Io import qs.config import qs.services // Dictation is an input method, so it lives under Input beside the keyboard — // but it listens through whichever device the Sound page selects, so the // microphone card below hands off there rather than duplicating the picker. // // Status first. The two questions this page exists to answer are "is it ready" // and "which keys" -- both were previously spelled as a pair of rows reading // "Ready / Missing" beside a hardcoded sentence about Super+D. The hotkeys are // now looked up from the live keymap, so rebinding the dictation key changes // what this page says instead of quietly making it wrong. SettingsPage { id: root // Chords come from the compositor by matching the descriptions // hypr/keybinds.lua gives the dictation binds. The literals are a fallback // for the moment before the keymap has loaded, not a second source of truth. function chordFor(needle: string, fallback: string): string { for (const bind of Keybinds.binds) { if (String(bind.description).toLowerCase().indexOf(needle) >= 0) return String(bind.chord); } return fallback; } readonly property string holdChord: root.chordFor("dictate (hold to talk)", "Super + D") readonly property string cancelChord: root.chordFor("cancel dictation", "Super + Shift + D") readonly property int modelMegabytes: Math.round(Dictation.modelBytes / 1048576) // What the setup is doing, as steps rather than a percentage with no // subject. Driven entirely by the service's existing phase and progress // fields -- nothing new is asked of panama-dictate. readonly property var setupSteps: [ { number: 1, title: "Container image", detail: Dictation.imageBuilt ? "The speech server is on this machine" : (Dictation.phase === "pulling" ? "Fetching the speech server…" : "About 1 GB, pulled once"), done: Dictation.imageBuilt, active: Dictation.phase === "pulling", progress: -1 }, { number: 2, title: "Speech model", detail: Dictation.modelInstalled ? root.modelMegabytes + " MB, kept across rebuilds of the server" : (Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0 ? "Downloading — " + Math.round(Dictation.downloadedBytes / 1048576) + " of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB" : "About 490 MB, downloaded once"), done: Dictation.modelInstalled, active: Dictation.phase === "downloading", progress: Dictation.phase === "downloading" && Dictation.downloadTotalBytes > 0 ? Dictation.downloadFraction : -1 }, { number: 3, title: "First transcription", detail: Dictation.serverReady ? "The speech server is answering" : "The server starts itself the first time you hold the key", done: Dictation.serverReady, active: false, progress: -1 } ] // ── The on-page test ──────────────────────────────────────────────────── // panama-dictate types what it heard into whatever has keyboard focus, so // a test needs no new plumbing: the field below takes focus, the helper // runs exactly as the hotkey runs it, and the words arrive here. The helper // is addressed through the path the Dictation service already publishes // rather than one spelled again in this file. property bool listening: false Process { id: dictateRun onExited: (exitCode, exitStatus) => Dictation.refresh() } function runDictate(action: string): void { if (dictateRun.running) return; dictateRun.command = [Dictation.helper, action]; dictateRun.running = true; } title: "Dictation" lede: "Local speech to text — the audio, the model and the server never leave this machine." // ── Ready ─────────────────────────────────────────────────────────────── SettingsCard { visible: Dictation.ready Item { width: parent.width height: 72 Rectangle { id: readyTile anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 44 height: 44 radius: 14 color: Theme.alpha(Theme.ok, 0.10) border.width: 1 border.color: Theme.alpha(Theme.ok, 0.35) Text { anchors.centerIn: parent text: "✓" color: Theme.ok font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeLarge + 4 } } Column { anchors.left: readyTile.right anchors.leftMargin: 16 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 2 Text { width: parent.width text: "Dictation is ready" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeLarge font.weight: Font.DemiBold } Text { width: parent.width text: "Speech model · " + root.modelMegabytes + " MB · " + (Dictation.serverReady ? "the speech server is running" : "the speech server starts on your first dictation") color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } } } SettingRow { label: "Hold to dictate" detail: "Release to type what you said, wherever the cursor is" controlWidth: 220 KeycapChord { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter chord: root.holdChord } } SettingRow { label: "Cancel a dictation" detail: "Throws the recording away without transcribing it" controlWidth: 220 KeycapChord { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter chord: root.cancelChord } } SettingRow { label: "Try it" detail: "Speak a sentence and it is typed into the field here, rather than into whatever you were working on" controlWidth: 340 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 Rectangle { anchors.verticalCenter: parent.verticalCenter width: 200 height: 30 radius: 8 color: Theme.alpha(Theme.fg, 0.05) border.width: 1 border.color: heard.activeFocus ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.12) TextInput { id: heard anchors.fill: parent anchors.leftMargin: 10 anchors.rightMargin: 10 verticalAlignment: TextInput.AlignVCenter clip: true color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall selectByMouse: true selectionColor: Theme.alpha(Theme.accent, 0.35) Text { anchors.verticalCenter: parent.verticalCenter visible: heard.text === "" text: root.listening ? "Listening…" : "Dictated text lands here" color: Theme.fgMuted font: heard.font } } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: root.listening ? "Stop and type it" : "Test dictation" tone: root.listening ? "accent" : "normal" onClicked: { // Focus first, and keep it: the helper types with wtype // into whatever holds keyboard focus when it finishes. heard.forceActiveFocus(); if (root.listening) { root.listening = false; root.runDictate("stop"); return; } heard.text = ""; root.listening = true; root.runDictate("start"); } } } } SettingRow { visible: !Dictation.typingAvailable label: "Typing" detail: "wtype is missing, so dictated text goes to the clipboard instead of being typed" value: "Missing" divider: false } } // ── Setting up ────────────────────────────────────────────────────────── SettingsCard { visible: !Dictation.ready Item { width: parent.width height: 72 Rectangle { id: setupTile anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 44 height: 44 radius: 14 color: Dictation.downloading ? Theme.alpha(Theme.accent, 0.10) : Theme.alpha(Theme.fg, 0.07) border.width: 1 border.color: Dictation.downloading ? Theme.alpha(Theme.accent, 0.35) : Theme.alpha(Theme.fg, 0.16) Text { anchors.centerIn: parent text: Dictation.downloading ? "…" : "✗" color: Dictation.downloading ? Theme.accent : Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeLarge + 4 } } Column { anchors.left: setupTile.right anchors.leftMargin: 16 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 2 Text { width: parent.width text: Dictation.downloading ? "Setting up dictation" : "Dictation is not set up yet" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeLarge font.weight: Font.DemiBold } Text { width: parent.width text: "Everything stays local: a speech server in a container, and a Whisper model. Neither ships with Panama — both are large and want the network." color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } } } Repeater { model: root.setupSteps Item { id: step required property var modelData width: parent ? parent.width : 620 height: 48 Rectangle { id: number anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 24 height: 24 radius: 12 color: { if (step.modelData.done) return Theme.alpha(Theme.ok, 0.15); if (step.modelData.active) return Theme.alpha(Theme.accent, 0.20); return Theme.alpha(Theme.fg, 0.08); } Text { anchors.centerIn: parent text: step.modelData.done ? "✓" : String(step.modelData.number) color: { if (step.modelData.done) return Theme.ok; if (step.modelData.active) return Theme.accent; return Theme.fgDim; } font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold } } Column { anchors.left: number.right anchors.leftMargin: 12 anchors.right: progress.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter spacing: 2 Text { width: parent.width text: String(step.modelData.title) color: step.modelData.done || step.modelData.active ? Theme.fg : Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.Medium elide: Text.ElideRight } Text { width: parent.width text: String(step.modelData.detail) color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } // A real bar rather than a spinner: this is the one part of // setup whose length is actually known. Rectangle { id: progress anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: step.modelData.progress >= 0 ? 140 : 0 height: 5 radius: 3 visible: step.modelData.progress >= 0 color: Theme.alpha(Theme.fg, 0.09) Rectangle { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: parent.width * Math.max(0, Math.min(1, step.modelData.progress)) radius: parent.radius gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0.0; color: Theme.accent } GradientStop { position: 1.0; color: Theme.accentSecondary } } } } Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: 1 color: Theme.alpha(Theme.fg, 0.05) } } } // ONE action that actually works: panama-dictate setup pulls the // server image and downloads the model together. This card used to // offer a Download button wired to a command the helper does not have, // so nothing here did anything. It does now. ActionRow { label: "Set up dictation" detail: Dictation.downloading ? "Working — this can take several minutes on a slow connection" : "Fetches the speech server and the model. Runs once, keeps both." action: Dictation.downloading ? "Working…" : "Set up" enabled: !Dictation.downloading onTriggered: Dictation.setup() divider: Dictation.lastError !== "" } TextRow { visible: Dictation.lastError !== "" label: "Problem" detail: Dictation.lastError value: "" divider: false } } SettingsCard { title: "Microphone" subtitle: AudioDevices.current(false)?.description ?? "No input device" ActionRow { label: "Input device" detail: "Dictation listens through the input device selected in Sound" divider: false action: "Open Sound" onTriggered: ShellState.openSettings("sound") } } }