Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,68 +1,440 @@
|
||||
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 {
|
||||
title: "Dictation"
|
||||
lede: Dictation.ready
|
||||
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
|
||||
: "Speech to text, on the GPU, once the one-time setup below has run."
|
||||
id: root
|
||||
|
||||
SettingsCard {
|
||||
title: "Dictation"
|
||||
subtitle: Dictation.ready
|
||||
? "Hold Super+D, speak, and release. The words are typed where the cursor is."
|
||||
: "The one-time setup fetches a speech server and a ~490 MB model — neither ships with Panama, because both are large and want the network."
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Status of the two pieces, read from the machine rather than guessed.
|
||||
TextRow {
|
||||
label: "Speech server"
|
||||
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
|
||||
? "Running"
|
||||
: (Dictation.imageBuilt ? "Ready — starts on the first dictation" : "Not set up yet")
|
||||
value: Dictation.imageBuilt ? "Ready" : "Missing"
|
||||
? "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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Speech model"
|
||||
detail: Dictation.modelInstalled
|
||||
? "Kept across rebuilds of the server"
|
||||
: "About 490 MB, downloaded once"
|
||||
value: Dictation.modelInstalled ? Math.round(Dictation.modelBytes / 1048576) + " MB" : "Missing"
|
||||
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,
|
||||
// and told you to build a "panama app whisper-vulkan" that does not
|
||||
// exist -- so nothing here did anything. It does now.
|
||||
// so nothing here did anything. It does now.
|
||||
ActionRow {
|
||||
visible: !Dictation.ready || Dictation.downloading
|
||||
label: "Set up dictation"
|
||||
detail: {
|
||||
if (!Dictation.downloading)
|
||||
return "Fetches the speech server and the model. Runs once, keeps both.";
|
||||
if (Dictation.phase === "pulling")
|
||||
return "Fetching the speech server…";
|
||||
if (Dictation.downloadTotalBytes > 0)
|
||||
return "Downloading model — " + Math.round(Dictation.downloadFraction * 100)
|
||||
+ "% of " + Math.round(Dictation.downloadTotalBytes / 1048576) + " MB";
|
||||
return "Setting up…";
|
||||
}
|
||||
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.typingAvailable || Dictation.lastError !== ""
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: !Dictation.typingAvailable
|
||||
label: "Typing"
|
||||
detail: "wtype is missing, so dictated text would go to the clipboard instead of being typed."
|
||||
value: "Missing"
|
||||
divider: Dictation.lastError !== ""
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user