Files

529 lines
21 KiB
QML

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.
// True only once the helper has confirmed a recording actually started.
//
// It used to be set on the press, which made it a claim rather than a fact:
// `panama-dictate start` refuses with {"ok":false,"error":"already-recording"}
// when the hotkey is already holding the microphone, and the field said
// "Listening…" over that refusal. Worse, the button then read "Stop and
// type it" -- so the next press stopped the hotkey's recording and typed
// somebody else's words into this test field.
property bool listening: false
// Why the last press did not do what it said, in this page's words.
property string testError: ""
// The helper answers with a code, not a sentence, so the words live here.
// Anything unrecognised is shown as itself rather than swallowed.
readonly property var dictateReasons: ({
"already-recording": "Something is already listening — the dictation hotkey, most likely. Let that finish first.",
"not-recording": "Nothing was listening, so there was nothing to type.",
"not-downloaded": "The speech model has not been downloaded yet.",
"no-image": "The speech server is not installed yet.",
"server-unavailable": "The speech server did not answer.",
"transcribe-failed": "That could not be transcribed.",
"too-short": "That was too short to transcribe.",
"no-speech": "Nothing was said.",
"deliver-failed": "The words could not be typed into the field.",
"unknown-command": "The dictation helper did not understand that."
})
Process {
id: dictateRun
// Which action this run was, so its reply can be read against it.
property string action: ""
stdout: StdioCollector { onStreamFinished: root.absorbDictate(this.text) }
onExited: (exitCode, exitStatus) => Dictation.refresh()
}
// The helper's reply decides what happened. A start that was refused leaves
// this page exactly as it was, with the refusal on screen.
function absorbDictate(text: string): void {
let reply = null;
try {
reply = JSON.parse(text);
} catch (error) {
reply = null;
}
const ok = reply?.ok === true;
root.listening = dictateRun.action === "start" && ok;
if (ok) {
root.testError = "";
return;
}
const code = String(reply?.error ?? "");
if (code === "")
root.testError = "The dictation helper did not answer.";
else
root.testError = root.dictateReasons[code] !== undefined
? String(root.dictateReasons[code])
: code;
}
function runDictate(action: string): void {
if (dictateRun.running)
return;
root.testError = "";
dictateRun.action = action;
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"
// The refusal takes the detail's place while there is one, so a
// press that did nothing says so where the press happened.
detail: root.testError !== ""
? root.testError
: "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
// Four states, because stopping is not instant: the helper
// transcribes before it types, and a button still reading
// "Stop and type it" through those seconds invites a second
// press for a stop that has already been asked for.
text: {
if (dictateRun.running)
return root.listening ? "Transcribing…" : "Starting…";
return root.listening ? "Stop and type it" : "Test dictation";
}
tone: root.listening ? "accent" : "normal"
enabled: !dictateRun.running
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.runDictate("stop");
return;
}
heard.text = "";
// `listening` is set by the reply, not by this press.
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 !== ""
}
ErrorRow {
label: "Problem"
message: Dictation.lastError
}
}
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")
}
}
}