a07082f17b
- Settings: "Keep history" (a day … forever, default a month); older recordings and their audio are pruned at startup, after each recording and when the setting changes. - History rows keep play, copy and delete only. - Advanced: the microphone is picked from PipeWire's sources instead of a free-text field, with a note about Bluetooth headsets switching profile. - A stock machine without voxtype gets a banner with an Install button that runs omarchy-voxtype-install in a floating terminal; the daemon refuses to record until the tools are there and says why.
1285 lines
56 KiB
QML
1285 lines
56 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import qs.Commons
|
||
import qs.Ui
|
||
|
||
// Bar widget + popup for Speech to Text. In the bar: a microphone icon that,
|
||
// while a recording runs, turns into a waveform (yellow sine while the microphone
|
||
// connects, green voice levels once it listens) with the words as they are
|
||
// recognised; it folds back the moment the text has been pasted. The popup
|
||
// has two tabs: History (every recording, with play / copy / paste / delete) and
|
||
// Settings (one key and an auto-send switch per language; the rest is
|
||
// tucked under Advanced). All state lives in the daemon (see Service.qml);
|
||
// this file renders and forwards.
|
||
Panel {
|
||
id: root
|
||
moduleName: "alanfortlink.speech-to-text"
|
||
ipcTarget: "alanfortlink.speech-to-text"
|
||
manageIpc: false
|
||
|
||
readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("alanfortlink.speech-to-text") : null
|
||
readonly property bool connected: svc ? svc.connected : false
|
||
readonly property bool recording: svc ? svc.recording : false
|
||
readonly property bool transcribing: svc ? svc.transcribing : false
|
||
readonly property bool listening: svc ? svc.listening : false // audio is flowing
|
||
readonly property bool connecting: recording && !listening // pw-record is still opening the microphone
|
||
readonly property bool busy: recording || transcribing
|
||
readonly property var cfg: svc ? svc.config : ({})
|
||
readonly property var download: svc ? svc.download : null
|
||
readonly property bool alwaysShow: setting("alwaysShow", true)
|
||
readonly property int maxTextWidth: Style.space(setting("maxTextWidth", 360))
|
||
readonly property color fg: bar ? bar.foreground : Color.foreground
|
||
readonly property color dim: Qt.darker(fg, 1.4)
|
||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||
readonly property bool vertical: bar ? bar.vertical : false
|
||
readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal
|
||
readonly property int rowH: Style.spacing.controlHeight
|
||
readonly property int trailInset: Style.space(6) // ToggleSwitch's hover ring pad; trailing controls line up with it
|
||
readonly property int labelW: Style.space(104) // one label column for every form row
|
||
|
||
// Traffic-light colours for the recording: yellow while the microphone connects,
|
||
// green once it listens. The shell's Color singleton does not expose them,
|
||
// so they come from the current theme's colors.toml (with fallbacks).
|
||
property color yellow: "#f9e2af"
|
||
property color green: "#a6e3a1"
|
||
FileView {
|
||
id: themeFile
|
||
path: Color.currentThemePath + "/colors.toml"
|
||
watchChanges: true
|
||
onLoaded: root.readThemeColors()
|
||
onFileChanged: reload()
|
||
onTextChanged: root.readThemeColors()
|
||
}
|
||
function readThemeColors() {
|
||
var t = String(themeFile.text() || "")
|
||
var y = t.match(/^\s*yellow\s*=\s*"(#[0-9a-fA-F]{6})"/m), g = t.match(/^\s*green\s*=\s*"(#[0-9a-fA-F]{6})"/m)
|
||
if (y) yellow = y[1]
|
||
if (g) green = g[1]
|
||
}
|
||
readonly property color takeColor: connecting ? yellow : recording ? green : fg
|
||
|
||
// A failed recording shows its reason in the bar for a moment; a successful one
|
||
// shows nothing — the text is already where it belongs.
|
||
property bool showError: false
|
||
Timer { id: errorTimer; interval: 4000; onTriggered: root.showError = false }
|
||
onTranscribingChanged: {
|
||
if (transcribing || !svc) return
|
||
if (svc.error !== "") { showError = true; errorTimer.restart() }
|
||
}
|
||
onRecordingChanged: if (recording) { showError = false; errorTimer.stop() }
|
||
|
||
readonly property bool expanded: !vertical && (busy || showError || download !== null)
|
||
visible: alwaysShow || expanded
|
||
implicitWidth: expanded ? strip.implicitWidth : button.implicitWidth
|
||
implicitHeight: button.implicitHeight
|
||
Behavior on implicitWidth { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
|
||
|
||
// Idle: left = popup, right = dictate. While recording the strip is the
|
||
// control: left = stop, right = discard.
|
||
function pressed(b) {
|
||
if (!svc) return
|
||
if (recording) {
|
||
if (b === Qt.RightButton) svc.cancel()
|
||
else if (b === Qt.LeftButton) svc.toggle(null, false)
|
||
return
|
||
}
|
||
if (b === Qt.RightButton) svc.toggle(null, false)
|
||
else if (b === Qt.MiddleButton) svc.cancel()
|
||
else root.toggle()
|
||
}
|
||
|
||
// ---- text helpers ----
|
||
function langName(code) {
|
||
if (svc && svc.languageNames && svc.languageNames[code]) return svc.languageNames[code]
|
||
for (var i = 0; i < langs.length; i++) if (langs[i].code === code) return langs[i].label || code
|
||
return code
|
||
}
|
||
function keyFor(code, kind) { // the applied key for one language ("" if none); kind "Dictate" (default) or "Ask agent"
|
||
if (!svc) return ""
|
||
var want = (kind || "Dictate") + " (" + langName(code) + ")", b = svc.binds || []
|
||
for (var i = 0; i < b.length; i++) {
|
||
if (String(b[i].desc || "") !== want) continue
|
||
return (b[i].mods ? b[i].mods + " " : "") + b[i].key
|
||
}
|
||
return ""
|
||
}
|
||
readonly property string defaultLang: langs.length ? langs[0].code : ""
|
||
readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent"
|
||
function clock(secs) {
|
||
var s = Math.floor(secs || 0)
|
||
return Math.floor(s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
|
||
}
|
||
function when(ts) {
|
||
var d = new Date((ts || 0) * 1000), now = new Date()
|
||
var hm = Qt.formatTime(d, "HH:mm")
|
||
if (d.toDateString() === now.toDateString()) return hm
|
||
var y = new Date(now.getTime() - 86400000)
|
||
if (d.toDateString() === y.toDateString()) return "Yesterday " + hm
|
||
return Qt.formatDate(d, "d MMM") + " " + hm
|
||
}
|
||
function subtitle() {
|
||
if (!svc) return "Service not loaded"
|
||
if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…"
|
||
if (connecting) return "Opening the microphone…"
|
||
if (recording) return "Listening · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "")
|
||
if (transcribing) return "Transcribing…"
|
||
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
|
||
return "Idle · " + svc.langLabel
|
||
}
|
||
readonly property string liveText: {
|
||
if (!svc) return ""
|
||
if (connecting) return "Opening microphone…"
|
||
if (recording) return svc.partial !== "" ? svc.partial : "Listening…"
|
||
if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : "Transcribing…")
|
||
if (showError) return svc.error
|
||
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
|
||
return ""
|
||
}
|
||
readonly property bool livePlaceholder: recording ? (svc && svc.partial === "") : (transcribing || (download !== null && !showError))
|
||
// The popup shows the end of a long transcript (Text cannot left-elide wrapped text).
|
||
readonly property string liveTail: liveText.length > 420 ? "…" + liveText.slice(-420) : liveText
|
||
|
||
// ---------- bar: idle icon ----------
|
||
BarIconButton {
|
||
id: button
|
||
anchors.fill: parent
|
||
visible: !root.expanded
|
||
bar: root.bar
|
||
text: ""
|
||
active: root.busy || root.showError
|
||
useActiveColor: true
|
||
activeColor: root.busy ? root.takeColor : Color.urgent
|
||
tooltipText: (root.connected ? (root.svc.missing.length ? "Speech to text · click to install " + root.svc.missing.join(", ") : "Speech to text") : "Speech to text · starting")
|
||
+ (root.svc && root.svc.error !== "" ? " · " + root.svc.error : "")
|
||
+ (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "")
|
||
+ " · right-click: dictate"
|
||
onPressed: function(b) { root.pressed(b) }
|
||
}
|
||
|
||
// ---------- bar: live strip ----------
|
||
Item {
|
||
id: strip
|
||
anchors.fill: parent
|
||
visible: root.expanded
|
||
implicitWidth: stripRow.implicitWidth + Style.spacing.xxxl
|
||
|
||
Row {
|
||
id: stripRow
|
||
anchors.centerIn: parent
|
||
spacing: Style.spacing.md
|
||
|
||
Waveform {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
height: Style.bar.iconCanvas
|
||
bars: 18
|
||
levels: root.svc ? root.svc.levels : []
|
||
sine: root.connecting || (root.download !== null && !root.busy)
|
||
idle: root.transcribing || (!root.recording && root.showError)
|
||
color: root.showError && !root.busy ? Color.urgent : root.download !== null && !root.busy ? root.yellow : root.takeColor
|
||
Behavior on color { ColorAnimation { duration: 250 } }
|
||
}
|
||
|
||
TextMetrics { id: liveMetrics; text: root.liveText; font.family: root.fontFamily; font.pixelSize: Style.font.body }
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: root.liveText
|
||
color: root.showError && !root.busy ? Color.urgent : root.livePlaceholder ? root.dim : root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
font.italic: root.livePlaceholder
|
||
elide: Text.ElideLeft
|
||
width: Math.min(liveMetrics.advanceWidth + 2, root.maxTextWidth)
|
||
visible: text !== ""
|
||
}
|
||
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
visible: root.recording
|
||
text: root.clock(root.svc ? root.svc.elapsed : 0)
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
}
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||
onClicked: function(mouse) { root.pressed(mouse.button) }
|
||
onEntered: if (root.bar && root.recording) root.bar.showTooltip(strip, "Click: stop and paste · right-click: discard")
|
||
onExited: if (root.bar) root.bar.hideTooltip(strip)
|
||
}
|
||
}
|
||
|
||
IpcHandler {
|
||
target: root.ipcTarget
|
||
function open() { root.open() }
|
||
function close() { root.close() }
|
||
function show() { root.open() }
|
||
function hide() { root.close() }
|
||
function toggle() { root.toggle() }
|
||
// omarchy-shell alanfortlink.speech-to-text dictate (same as `stt toggle`)
|
||
function dictate() { if (root.svc) root.svc.toggle(null, false) }
|
||
function dictateSend() { if (root.svc) root.svc.toggle(null, true) }
|
||
function cancel() { if (root.svc) root.svc.cancel() }
|
||
function history() { root.tab = "history"; root.open() }
|
||
function settings() { root.tab = "settings"; root.open() }
|
||
// Arm key capture for the language at `index` (what clicking its key button does).
|
||
function capture(index: int) { root.tab = "settings"; root.open(); root.startCapture(index, "key") }
|
||
function captureAgent(index: int) { root.tab = "settings"; root.open(); root.startCapture(index, "agentKey") }
|
||
}
|
||
|
||
onOpenedChanged: {
|
||
endCapture()
|
||
if (opened && svc) { svc.refresh(); svc.loadHistory(""); expandedTake = 0 }
|
||
}
|
||
onTabChanged: endCapture()
|
||
|
||
// Anything that pastes or starts typing must run after the popup has given
|
||
// the keyboard back to the window underneath, or the text lands in here.
|
||
property var deferredFn: null
|
||
Timer { id: deferred; interval: 220; onTriggered: { var f = root.deferredFn; root.deferredFn = null; if (f) f() } }
|
||
function closeThen(fn) { deferredFn = fn; close(); deferred.restart() }
|
||
|
||
// ---------- popup ----------
|
||
readonly property var tabs: [ { key: "history", label: "History" }, { key: "settings", label: "Settings" } ]
|
||
property string tab: "history"
|
||
property bool clearConfirm: false
|
||
property int expandedTake: 0
|
||
property bool advancedOpen: false
|
||
property int captureIndex: -1 // language row waiting for a key press (-1: none)
|
||
property string captureField: "key" // "key" (dictate) or "agentKey" (send to the agent)
|
||
property string captureNote: ""
|
||
Timer { id: noteClear; interval: 3500; onTriggered: root.captureNote = "" }
|
||
function note(t) { captureNote = t; noteClear.restart() }
|
||
|
||
// The daemon pushes state ~20×/s while recording; derive the lists from
|
||
// JSON text so the rows are not rebuilt on every push.
|
||
readonly property string langsJson: JSON.stringify(svc ? svc.languages : [])
|
||
readonly property var langs: JSON.parse(langsJson)
|
||
readonly property string namesJson: JSON.stringify(svc ? svc.languageNames : ({}))
|
||
readonly property var addableLangs: {
|
||
var names = JSON.parse(namesJson), have = {}, out = []
|
||
for (var i = 0; i < langs.length; i++) have[langs[i].code] = true
|
||
for (var code in names) if (!have[code]) out.push({ value: code, label: names[code] + " (" + code + ")" })
|
||
out.sort(function(a, b) { return a.value === "auto" ? -1 : b.value === "auto" ? 1 : a.label.localeCompare(b.label) })
|
||
return out
|
||
}
|
||
readonly property string enginesJson: JSON.stringify(svc ? svc.engines : [])
|
||
readonly property var engineOpts: JSON.parse(enginesJson).map(function(e) {
|
||
return { value: e, label: ({ voxtype: "Omarchy built-in (voxtype)", "whisper-cpp": "whisper.cpp", command: "Custom command" })[e] || e }
|
||
})
|
||
readonly property var langOpts: langs.map(function(l) { return { value: l.code, label: l.label || l.code } })
|
||
readonly property string sourcesJson: JSON.stringify(svc ? svc.sources : [])
|
||
readonly property var micOpts: {
|
||
var list = JSON.parse(sourcesJson), cur = String(cfg.device || "default")
|
||
var opts = [ { value: "default", label: "System default" } ]
|
||
for (var i = 0; i < list.length; i++) opts.push({ value: String(list[i].name), label: String(list[i].label) })
|
||
if (cur !== "default" && !list.some(function(m) { return String(m.name) === cur })) opts.push({ value: cur, label: cur + " (not connected)" })
|
||
return opts
|
||
}
|
||
readonly property string conflictsJson: JSON.stringify(svc ? svc.conflicts : [])
|
||
readonly property var conflicts: JSON.parse(conflictsJson)
|
||
function conflictText() {
|
||
return conflicts.map(function(c) {
|
||
return (c.mods ? c.mods + " " : "") + c.key + " is already used by “" + c.takenBy + "” — pick another key"
|
||
}).join("\n")
|
||
}
|
||
|
||
function saveLangs(list) { if (svc) svc.setSetting("languages", list) }
|
||
function patchLang(index, key, value) {
|
||
var list = JSON.parse(langsJson)
|
||
if (index < 0 || index >= list.length) return false
|
||
list[index][key] = value
|
||
saveLangs(list)
|
||
return true
|
||
}
|
||
function addLang(code) {
|
||
if (!code) return
|
||
var list = JSON.parse(langsJson)
|
||
for (var i = 0; i < list.length; i++) if (list[i].code === code) return
|
||
list.push({ code: code, key: "", autoSend: false, agentKey: "", engineArgs: "" })
|
||
saveLangs(list)
|
||
}
|
||
function moveLang(index, delta) { // the first language is the default
|
||
var list = JSON.parse(langsJson), j = index + delta
|
||
if (index < 0 || index >= list.length || j < 0 || j >= list.length) return
|
||
var t = list[index]; list[index] = list[j]; list[j] = t
|
||
saveLangs(list)
|
||
}
|
||
function removeLang(index) {
|
||
var list = JSON.parse(langsJson)
|
||
if (list.length <= 1) return
|
||
list.splice(index, 1)
|
||
saveLangs(list)
|
||
}
|
||
|
||
// ---- key capture ----
|
||
// While a row is armed the daemon takes our own binds down (so pressing the
|
||
// current key is captured instead of starting a recording) and puts them back after.
|
||
function startCapture(index, field) {
|
||
if (index < 0 || index >= langs.length) return
|
||
captureIndex = index
|
||
captureField = field || "key"
|
||
if (svc) svc.suspendBinds()
|
||
keyCatcher.forceActiveFocus()
|
||
}
|
||
function endCapture() {
|
||
if (captureIndex === -1) return
|
||
captureIndex = -1
|
||
if (svc) svc.resumeBinds()
|
||
}
|
||
// Qt key event -> Hyprland bind spec ("CTRL SHIFT F13"). "" for a lone modifier;
|
||
// null for a bare key that must not become a global bind (a letter, digit…).
|
||
function keySpec(event) {
|
||
var k = event.key
|
||
if (k === Qt.Key_Control || k === Qt.Key_Shift || k === Qt.Key_Alt || k === Qt.Key_Meta || k === Qt.Key_AltGr || k === Qt.Key_Super_L || k === Qt.Key_Super_R) return ""
|
||
var mods = []
|
||
if (event.modifiers & Qt.ControlModifier) mods.push("CTRL")
|
||
if (event.modifiers & Qt.ShiftModifier) mods.push("SHIFT")
|
||
if (event.modifiers & Qt.AltModifier) mods.push("ALT")
|
||
if (event.modifiers & Qt.MetaModifier) mods.push("SUPER")
|
||
var name = "", bareOk = false
|
||
// F13–F24 reach Qt as XF86Tools/XF86Launch* keysyms; their xkb key codes are fixed (evdev KEY_F13 = 183, +8).
|
||
var code = event.nativeScanCode
|
||
if (code >= 191 && code <= 202) { name = "F" + (13 + code - 191); bareOk = true }
|
||
else if (k >= Qt.Key_F1 && k <= Qt.Key_F35) { name = "F" + (k - Qt.Key_F1 + 1); bareOk = true }
|
||
else {
|
||
var special = {}
|
||
special[Qt.Key_Pause] = "PAUSE"; special[Qt.Key_Print] = "PRINT"; special[Qt.Key_ScrollLock] = "SCROLL_LOCK"
|
||
special[Qt.Key_Insert] = "INSERT"; special[Qt.Key_Menu] = "MENU"; special[Qt.Key_CapsLock] = "CAPS_LOCK"
|
||
special[Qt.Key_Home] = "HOME"; special[Qt.Key_End] = "END"; special[Qt.Key_PageUp] = "PAGE_UP"; special[Qt.Key_PageDown] = "PAGE_DOWN"
|
||
var table = {}
|
||
table[Qt.Key_Space] = "SPACE"; table[Qt.Key_Return] = "RETURN"; table[Qt.Key_Enter] = "KP_Enter"; table[Qt.Key_Tab] = "TAB"
|
||
table[Qt.Key_Backspace] = "BACKSPACE"; table[Qt.Key_Delete] = "DELETE"
|
||
table[Qt.Key_Left] = "LEFT"; table[Qt.Key_Right] = "RIGHT"; table[Qt.Key_Up] = "UP"; table[Qt.Key_Down] = "DOWN"
|
||
table[Qt.Key_Minus] = "MINUS"; table[Qt.Key_Equal] = "EQUAL"; table[Qt.Key_Comma] = "COMMA"; table[Qt.Key_Period] = "PERIOD"
|
||
table[Qt.Key_Slash] = "SLASH"; table[Qt.Key_Semicolon] = "SEMICOLON"; table[Qt.Key_Apostrophe] = "APOSTROPHE"
|
||
table[Qt.Key_BracketLeft] = "BRACKETLEFT"; table[Qt.Key_BracketRight] = "BRACKETRIGHT"; table[Qt.Key_Backslash] = "BACKSLASH"; table[Qt.Key_QuoteLeft] = "GRAVE"
|
||
if (special[k] !== undefined) { name = special[k]; bareOk = true }
|
||
else if (table[k] !== undefined) name = table[k]
|
||
else if (k >= Qt.Key_A && k <= Qt.Key_Z) name = String.fromCharCode(65 + (k - Qt.Key_A))
|
||
else if (k >= Qt.Key_0 && k <= Qt.Key_9) name = String.fromCharCode(48 + (k - Qt.Key_0))
|
||
else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) > 32) name = event.text.toUpperCase()
|
||
}
|
||
if (name === "") return code > 0 ? "?" : ""
|
||
if (!mods.length && !bareOk) return null
|
||
return (mods.length ? mods.join(" ") + " " : "") + name
|
||
}
|
||
function finishCapture(event) {
|
||
if (captureIndex === -1) return false
|
||
if (event.key === Qt.Key_Escape && !(event.modifiers & ~Qt.KeypadModifier)) { endCapture(); return true }
|
||
if ((event.key === Qt.Key_Backspace || event.key === Qt.Key_Delete) && !(event.modifiers & ~Qt.KeypadModifier)) {
|
||
var i = captureIndex, f = captureField
|
||
captureIndex = -1
|
||
patchLang(i, f, "")
|
||
if (svc) svc.resumeBinds()
|
||
return true
|
||
}
|
||
var spec = keySpec(event)
|
||
if (spec === "") return true // a lone modifier: keep waiting
|
||
if (spec === "?") { note("That key has no name I can bind — try another"); return true }
|
||
if (spec === null) { note("Add a modifier (CTRL, SUPER…): a bare key would be taken from every app"); return true }
|
||
var index = captureIndex, field = captureField
|
||
captureIndex = -1
|
||
patchLang(index, field, spec)
|
||
if (svc) svc.resumeBinds()
|
||
return true
|
||
}
|
||
|
||
KeyboardPanel {
|
||
id: panel
|
||
anchorItem: root
|
||
owner: root
|
||
bar: root.bar
|
||
open: root.opened
|
||
focusTarget: keyCatcher
|
||
contentWidth: panel.fittedContentWidth(Style.space(520))
|
||
contentHeight: panel.fittedContentHeight(topBlock.implicitHeight + Style.space(10) + body.implicitHeight + Style.space(4))
|
||
|
||
PanelKeyCatcher {
|
||
id: keyCatcher
|
||
anchors.fill: parent
|
||
blocked: root.clearConfirm || root.captureIndex !== -1 || (settingsLoader.item ? settingsLoader.item.pickerOpen === true : false)
|
||
onCloseRequested: root.close()
|
||
// Tab moves between the panel's controls; with nothing focused it switches panels like elsewhere.
|
||
onTabRequested: function(direction) {
|
||
var n = keyCatcher.activeFocus ? null : keyCatcher.nextItemInFocusChain(direction > 0)
|
||
if (n && n !== keyCatcher) n.forceActiveFocus(Qt.TabFocusReason)
|
||
else root.switchPanel(direction)
|
||
}
|
||
onMoveRequested: function(dx, dy) {
|
||
var n = keyCatcher.nextItemInFocusChain(dy > 0 || dx > 0)
|
||
if (n && n !== keyCatcher) n.forceActiveFocus(Qt.TabFocusReason)
|
||
}
|
||
Keys.onPressed: function(event) {
|
||
if (root.finishCapture(event)) { event.accepted = true; return }
|
||
if (clearDialog.handleKey(event)) event.accepted = true
|
||
}
|
||
|
||
ConfirmDialog {
|
||
id: clearDialog
|
||
anchors.fill: parent
|
||
z: 10
|
||
opened: root.clearConfirm
|
||
message: "Delete every recording and its text?"
|
||
confirmText: "Delete all"
|
||
selectedIndex: 0
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onCanceled: root.clearConfirm = false
|
||
onConfirmed: { root.clearConfirm = false; if (root.svc) root.svc.clearHistory() }
|
||
}
|
||
Connections { target: root; function onClearConfirmChanged() { if (root.clearConfirm) { clearDialog.selectedIndex = 0; keyCatcher.forceActiveFocus() } } }
|
||
|
||
// ---------- Fixed top: hero · live text · error · language · tabs ----------
|
||
Column {
|
||
id: topBlock
|
||
anchors.top: parent.top
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
spacing: Style.space(10)
|
||
|
||
PanelHero {
|
||
width: parent.width
|
||
title: "Speech to Text"
|
||
meta: root.subtitle()
|
||
detail: root.connected ? root.keyFor(root.defaultLang) : ""
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
iconComponent: Component {
|
||
Text {
|
||
text: ""
|
||
color: root.recording ? root.takeColor : root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.display
|
||
}
|
||
}
|
||
trailingControl: Component {
|
||
Row {
|
||
spacing: Style.space(4)
|
||
PanelActionButton {
|
||
visible: root.busy
|
||
iconText: ""
|
||
tooltipText: "Discard this recording"
|
||
foreground: root.fg
|
||
hoverColor: Color.urgent
|
||
fontFamily: root.fontFamily
|
||
onClicked: if (root.svc) root.svc.cancel()
|
||
}
|
||
Button {
|
||
text: root.recording ? "Stop" : root.transcribing ? "Working…" : "Dictate"
|
||
enabled: root.connected && !root.transcribing
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
bordered: true
|
||
// The text is pasted into the focused window, so the popup gets out of the way first.
|
||
onClicked: root.closeThen(function() { if (root.svc) root.svc.toggle(null, false) })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// A stock machine may not have the dictation engine yet: say so, and install it from here.
|
||
Rectangle {
|
||
width: parent.width
|
||
visible: root.connected && root.svc.missing.length > 0
|
||
height: missingCol.implicitHeight + Style.space(16)
|
||
radius: Style.cornerRadius
|
||
color: Style.normalFillFor(root.fg, Color.accent)
|
||
Column {
|
||
id: missingCol
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.margins: Style.space(8)
|
||
spacing: Style.space(6)
|
||
Text {
|
||
width: parent.width
|
||
text: "Dictation needs " + (root.svc ? root.svc.missing.join(", ") : "") + " to be installed. Omarchy's installer takes care of it (about 150 MB, asks for your password)."
|
||
color: root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
wrapMode: Text.Wrap
|
||
}
|
||
Button {
|
||
text: "Install"
|
||
iconText: ""
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
bordered: true
|
||
onClicked: { if (root.svc) root.svc.install(); root.close() }
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
width: parent.width
|
||
visible: root.busy
|
||
text: root.liveTail
|
||
color: root.livePlaceholder ? root.dim : root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
font.italic: root.livePlaceholder
|
||
wrapMode: Text.Wrap
|
||
}
|
||
|
||
Row {
|
||
width: parent.width
|
||
visible: root.svc && root.svc.error !== ""
|
||
spacing: Style.space(6)
|
||
Text {
|
||
width: parent.width - errClear.width - parent.spacing
|
||
text: root.svc ? root.svc.error : ""
|
||
color: Color.urgent
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.bodySmall
|
||
wrapMode: Text.Wrap
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
PanelActionButton {
|
||
id: errClear
|
||
iconText: ""
|
||
tooltipText: "Dismiss"
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onClicked: if (root.svc) root.svc.clearError()
|
||
}
|
||
}
|
||
|
||
// ---------- Tabs: History · Settings ----------
|
||
Item {
|
||
id: tabStrip
|
||
width: parent.width
|
||
height: Style.space(26)
|
||
PanelSeparator { // baseline the active tab's underline sits on
|
||
anchors.bottom: parent.bottom
|
||
foreground: root.fg
|
||
}
|
||
Row {
|
||
anchors.fill: parent
|
||
Repeater {
|
||
model: root.tabs
|
||
delegate: Item {
|
||
id: tabItem
|
||
required property var modelData
|
||
readonly property bool current: root.tab === tabItem.modelData.key
|
||
width: tabStrip.width / root.tabs.length
|
||
height: tabStrip.height
|
||
Text {
|
||
id: tabLabel
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.verticalCenterOffset: -Style.space(1)
|
||
text: tabItem.modelData.label
|
||
color: tabItem.current ? Color.accent : (tabArea.containsMouse ? root.fg : root.dim)
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
font.bold: tabItem.current
|
||
}
|
||
Rectangle { // a dot on Settings while a key could not be applied
|
||
anchors.left: tabLabel.right
|
||
anchors.leftMargin: Style.space(3)
|
||
anchors.top: tabLabel.top
|
||
anchors.topMargin: Style.space(2)
|
||
width: Style.space(4); height: width
|
||
radius: width / 2
|
||
color: Color.urgent
|
||
visible: tabItem.modelData.key === "settings" && root.conflicts.length > 0 && !tabItem.current
|
||
}
|
||
Rectangle { // 2px accent underline under the active label
|
||
anchors.bottom: parent.bottom
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
width: Math.round(tabLabel.implicitWidth) + Style.space(12)
|
||
height: Style.space(2)
|
||
radius: height / 2
|
||
color: Color.accent
|
||
visible: tabItem.current
|
||
}
|
||
MouseArea {
|
||
id: tabArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: root.tab = tabItem.modelData.key
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Scrolling body: the active tab ----------
|
||
ScrollView {
|
||
id: scrollArea
|
||
anchors.top: topBlock.bottom
|
||
anchors.topMargin: Style.space(10)
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.bottom: parent.bottom
|
||
clip: true
|
||
readonly property bool overflows: body.implicitHeight > height + 1
|
||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||
ScrollBar.vertical.policy: overflows ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
|
||
// The Flickable takes the wheel only while there is something to
|
||
// scroll, so a short tab never bounces; no fling inertia, so the wheel
|
||
// moves the page and stops when the finger does.
|
||
Binding { target: scrollArea.contentItem; property: "interactive"; value: scrollArea.overflows }
|
||
Binding { target: scrollArea.contentItem; property: "flickDeceleration"; value: 20000 }
|
||
Binding { target: scrollArea.contentItem; property: "maximumFlickVelocity"; value: 1500 }
|
||
Binding { target: scrollArea.contentItem; property: "boundsBehavior"; value: Flickable.StopAtBounds }
|
||
|
||
Item {
|
||
id: body
|
||
width: scrollArea.availableWidth
|
||
implicitWidth: width
|
||
implicitHeight: historyLoader.active ? historyLoader.implicitHeight : settingsLoader.implicitHeight
|
||
Loader { id: historyLoader; width: body.width; active: root.opened && root.tab === "history"; sourceComponent: historyView; visible: active }
|
||
Loader { id: settingsLoader; width: body.width; active: root.opened && root.tab === "settings"; sourceComponent: settingsView; visible: active }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ======================= shared rows =======================
|
||
// One compact settings row: label on the left, switch on the right.
|
||
component SwitchRow: Item {
|
||
id: sw
|
||
property string label: ""
|
||
property string summary: "" // dim caption before the switch
|
||
property bool checked: false
|
||
property bool enabled: root.connected
|
||
signal toggled()
|
||
width: parent ? parent.width : 200
|
||
height: root.rowH
|
||
opacity: enabled ? 1 : 0.5
|
||
Text {
|
||
id: swSummary
|
||
anchors.right: swToggle.left
|
||
anchors.rightMargin: Style.space(8)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
visible: sw.summary !== ""
|
||
text: sw.summary
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
elide: Text.ElideRight
|
||
width: Math.min(implicitWidth, sw.width * 0.5)
|
||
horizontalAlignment: Text.AlignRight
|
||
}
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.right: sw.summary !== "" ? swSummary.left : swToggle.left
|
||
anchors.rightMargin: Style.space(8)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: sw.label
|
||
color: root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
elide: Text.ElideRight
|
||
}
|
||
ToggleSwitch {
|
||
id: swToggle
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
cursorPad: root.trailInset
|
||
checked: sw.checked
|
||
interactive: sw.enabled
|
||
foreground: root.fg
|
||
onToggled: sw.toggled()
|
||
}
|
||
MouseArea { // the whole row toggles, like a real settings list
|
||
anchors.fill: parent
|
||
anchors.rightMargin: swToggle.width
|
||
enabled: sw.enabled
|
||
onClicked: sw.toggled()
|
||
}
|
||
}
|
||
|
||
component RowLabel: Text {
|
||
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
|
||
width: root.labelW
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
component Note: Text {
|
||
width: parent ? parent.width : 200
|
||
color: root.fg
|
||
opacity: 0.6
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.bodySmall
|
||
wrapMode: Text.Wrap
|
||
}
|
||
|
||
component Section: Column {
|
||
property string title: ""
|
||
width: parent ? parent.width : 200
|
||
spacing: Style.space(10)
|
||
PanelSeparator { width: parent.width; foreground: root.fg }
|
||
PanelSectionHeader { text: parent.title; foreground: root.fg; fontFamily: root.fontFamily }
|
||
}
|
||
|
||
// A text field that follows a config value but never overwrites what the
|
||
// user is typing: it re-reads the value only while unfocused, commits on
|
||
// Enter / focus loss, Esc puts the old value back, Tab moves on.
|
||
component ConfigField: TextField {
|
||
id: cf
|
||
property string key: ""
|
||
property var current: root.cfg[key]
|
||
property var commit: null // function(text) -> bool; default: setSetting(key, text)
|
||
foreground: root.fg
|
||
enabled: root.connected
|
||
opacity: enabled ? 1 : 0.5
|
||
onCurrentChanged: if (!activeFocus) text = current === undefined || current === null ? "" : String(current)
|
||
Component.onCompleted: text = current === undefined || current === null ? "" : String(current)
|
||
function revert() { text = current === undefined || current === null ? "" : String(current) }
|
||
onEditingFinished: {
|
||
var v = text
|
||
if (String(current === undefined || current === null ? "" : current) === v) return
|
||
var ok = commit ? commit(v) : (root.svc ? root.svc.setSetting(key, v) : false)
|
||
if (ok === false) revert()
|
||
}
|
||
Keys.onEscapePressed: function(e) { revert(); keyCatcher.forceActiveFocus(); e.accepted = true }
|
||
Keys.onTabPressed: function(e) { var n = cf.nextItemInFocusChain(true); if (n) n.forceActiveFocus(Qt.TabFocusReason); e.accepted = true }
|
||
Keys.onBacktabPressed: function(e) { var n = cf.nextItemInFocusChain(false); if (n) n.forceActiveFocus(Qt.BacktabFocusReason); e.accepted = true }
|
||
}
|
||
|
||
// ======================= History =======================
|
||
Component {
|
||
id: historyView
|
||
Column {
|
||
spacing: Style.space(8)
|
||
|
||
TextField {
|
||
id: search
|
||
width: parent.width - root.trailInset
|
||
placeholderText: "Search recordings…"
|
||
foreground: root.fg
|
||
onTextEdited: searchDebounce.restart()
|
||
Timer { id: searchDebounce; interval: 250; onTriggered: if (root.svc) root.svc.loadHistory(search.text) }
|
||
Keys.onEscapePressed: function(e) { if (text !== "") { text = ""; if (root.svc) root.svc.loadHistory(""); e.accepted = true } }
|
||
}
|
||
|
||
ListView {
|
||
id: list
|
||
width: parent.width
|
||
height: Math.max(Style.space(60), Math.min(contentHeight, Style.space(400)))
|
||
clip: true
|
||
spacing: Style.space(6)
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
interactive: contentHeight > height
|
||
flickDeceleration: 20000
|
||
maximumFlickVelocity: 1500
|
||
model: root.svc ? root.svc.historyItems : []
|
||
ScrollBar.vertical: ScrollBar { policy: list.interactive ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff }
|
||
// The daemon replaces the whole list on every change; keep the scroll position.
|
||
property real keepY: 0
|
||
onModelChanged: Qt.callLater(function() { if (keepY <= contentHeight - height) contentY = keepY })
|
||
onContentYChanged: if (!moving || dragging || flicking) keepY = contentY
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
width: parent.width - Style.space(20)
|
||
horizontalAlignment: Text.AlignHCenter
|
||
wrapMode: Text.Wrap
|
||
visible: list.count === 0
|
||
text: !root.connected ? "Waiting for the daemon…"
|
||
: root.svc && root.svc.historyQuery !== "" ? "No matches"
|
||
: root.keyFor(root.defaultLang) !== "" ? "No recordings yet — press " + root.keyFor(root.defaultLang) + " and talk"
|
||
: "No recordings yet — click Dictate, or set a key in Settings"
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
}
|
||
|
||
delegate: Rectangle {
|
||
id: row
|
||
required property var modelData
|
||
required property int index
|
||
readonly property bool expandedRow: root.expandedTake === modelData.id
|
||
readonly property bool playingRow: root.svc && root.svc.playing === modelData.id
|
||
width: list.width - (list.interactive ? Style.space(8) : root.trailInset)
|
||
height: rowCol.implicitHeight + Style.space(12)
|
||
radius: Style.cornerRadius
|
||
color: rowHover.hovered ? Style.hoverFillFor(root.fg, Color.accent) : Style.normalFillFor(root.fg, Color.accent)
|
||
HoverHandler { id: rowHover }
|
||
property bool copied: false
|
||
Timer { id: copiedTimer; interval: 1200; onTriggered: row.copied = false }
|
||
|
||
Column {
|
||
id: rowCol
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
anchors.margins: Style.space(6)
|
||
spacing: Style.space(3)
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(4)
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: parent.width - actions.width - parent.spacing
|
||
text: root.when(row.modelData.createdAt) + " · " + root.langName(row.modelData.lang) + " · " + root.clock(row.modelData.duration)
|
||
+ (row.modelData.audio === "" ? " · no audio" : "")
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
elide: Text.ElideRight
|
||
}
|
||
Row {
|
||
id: actions
|
||
spacing: 0
|
||
PanelActionButton {
|
||
iconText: row.playingRow ? "" : ""
|
||
tooltipText: row.playingRow ? "Stop" : "Play"
|
||
enabled: row.modelData.audio !== ""
|
||
opacity: enabled ? 1 : 0.35
|
||
foreground: row.playingRow ? Color.accent : root.fg
|
||
fontFamily: root.fontFamily
|
||
onClicked: if (root.svc) { if (row.playingRow) root.svc.stopPlay(); else root.svc.play(row.modelData.id) }
|
||
}
|
||
PanelActionButton {
|
||
iconText: row.copied ? "" : ""
|
||
tooltipText: "Copy"
|
||
foreground: row.copied ? Color.accent : root.fg
|
||
fontFamily: root.fontFamily
|
||
onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() }
|
||
}
|
||
PanelActionButton {
|
||
iconText: ""
|
||
tooltipText: "Delete"
|
||
foreground: root.fg
|
||
hoverColor: Color.urgent
|
||
fontFamily: root.fontFamily
|
||
onClicked: if (root.svc) root.svc.deleteTake(row.modelData.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
id: takeText
|
||
width: parent.width
|
||
text: row.modelData.text
|
||
color: root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
wrapMode: Text.Wrap
|
||
maximumLineCount: row.expandedRow ? 400 : 3
|
||
elide: Text.ElideRight
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: (takeText.truncated || row.expandedRow) ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||
onClicked: root.expandedTake = row.expandedRow ? 0 : row.modelData.id
|
||
}
|
||
}
|
||
Text {
|
||
visible: takeText.truncated || row.expandedRow
|
||
text: row.expandedRow ? "less" : "more…"
|
||
color: Color.accent
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.expandedTake = row.expandedRow ? 0 : row.modelData.id }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Row {
|
||
width: parent.width - root.trailInset
|
||
spacing: Style.space(6)
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: parent.width - clearBtn.width - parent.spacing
|
||
text: {
|
||
var total = root.svc ? root.svc.historyTotal : 0, shown = list.count
|
||
if (root.svc && root.svc.historyQuery !== "") return shown + " of " + total + " recordings match"
|
||
if (shown < total) return "showing " + shown + " of " + total + " recordings"
|
||
return total + " recording" + (total === 1 ? "" : "s")
|
||
}
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
elide: Text.ElideRight
|
||
}
|
||
Button {
|
||
id: clearBtn
|
||
text: "Clear all"
|
||
enabled: root.svc && root.svc.historyTotal > 0
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onClicked: root.clearConfirm = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ======================= Settings =======================
|
||
Component {
|
||
id: settingsView
|
||
Column {
|
||
id: settings
|
||
spacing: Style.space(14)
|
||
readonly property bool pickerOpen: addPicker.popupOpen
|
||
|
||
// ---------- Languages: one row each; the first is the default ----------
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(10)
|
||
PanelSectionHeader { text: "LANGUAGES"; foreground: root.fg; fontFamily: root.fontFamily }
|
||
|
||
readonly property int keyW: Style.space(112)
|
||
readonly property int sendW: Style.space(70)
|
||
readonly property int actW: Style.space(72)
|
||
|
||
Row { // column headings
|
||
width: parent.width - root.trailInset
|
||
spacing: Style.space(8)
|
||
Item { width: parent.width - parent.parent.keyW * 2 - parent.parent.sendW - parent.parent.actW - parent.spacing * 4; height: 1 }
|
||
Text { width: parent.parent.keyW; text: "DICTATE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption }
|
||
Text { width: parent.parent.sendW; text: "+ RETURN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption }
|
||
Text { width: parent.parent.keyW; text: "ASK " + root.agentName; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; elide: Text.ElideRight }
|
||
}
|
||
|
||
Repeater {
|
||
model: root.langs
|
||
Rectangle {
|
||
id: langRow
|
||
required property var modelData
|
||
required property int index
|
||
readonly property bool armedKey: root.captureIndex === index && root.captureField === "key"
|
||
readonly property bool armedAgent: root.captureIndex === index && root.captureField === "agentKey"
|
||
readonly property var cols: parent
|
||
width: parent.width - root.trailInset
|
||
height: root.rowH + Style.space(8)
|
||
radius: Style.cornerRadius
|
||
color: (armedKey || armedAgent) ? Style.selectedFillFor(root.fg, Color.accent) : langHover.hovered ? Style.hoverFillFor(root.fg, Color.accent) : Style.normalFillFor(root.fg, Color.accent)
|
||
HoverHandler { id: langHover }
|
||
|
||
Row {
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.leftMargin: Style.space(8)
|
||
anchors.rightMargin: Style.space(2)
|
||
spacing: Style.space(8)
|
||
|
||
Column {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: parent.width - langRow.cols.keyW * 2 - langRow.cols.sendW - langRow.cols.actW - parent.spacing * 4
|
||
Text {
|
||
width: parent.width
|
||
text: root.langName(langRow.modelData.code)
|
||
color: root.fg
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
visible: langRow.index === 0
|
||
text: "default"
|
||
color: root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
}
|
||
}
|
||
|
||
// Keys: click to capture the next key press, right-click to clear.
|
||
Button {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: langRow.cols.keyW
|
||
text: langRow.armedKey ? "Press…" : (langRow.modelData.key ? langRow.modelData.key : "Set")
|
||
selected: langRow.armedKey
|
||
enabled: root.connected
|
||
foreground: langRow.modelData.key || langRow.armedKey ? root.fg : root.dim
|
||
fontFamily: root.fontFamily
|
||
bordered: true
|
||
tooltipText: langRow.armedKey ? "Press a key · Backspace clears · Esc cancels" : "Dictate: press to start, again to stop and paste"
|
||
onClicked: { if (langRow.armedKey) root.endCapture(); else root.startCapture(langRow.index, "key") }
|
||
onRightClicked: { root.endCapture(); root.patchLang(langRow.index, "key", "") }
|
||
}
|
||
|
||
Item {
|
||
width: langRow.cols.sendW
|
||
height: root.rowH
|
||
ToggleSwitch {
|
||
anchors.centerIn: parent
|
||
cursorPad: root.trailInset
|
||
checked: !!langRow.modelData.autoSend
|
||
interactive: root.connected
|
||
foreground: root.fg
|
||
onToggled: root.patchLang(langRow.index, "autoSend", !langRow.modelData.autoSend)
|
||
}
|
||
}
|
||
|
||
Button {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: langRow.cols.keyW
|
||
text: langRow.armedAgent ? "Press…" : (langRow.modelData.agentKey ? langRow.modelData.agentKey : "Set")
|
||
selected: langRow.armedAgent
|
||
enabled: root.connected
|
||
foreground: langRow.modelData.agentKey || langRow.armedAgent ? root.fg : root.dim
|
||
fontFamily: root.fontFamily
|
||
bordered: true
|
||
tooltipText: langRow.armedAgent ? "Press a key · Backspace clears · Esc cancels" : "Ask " + root.agentName + ": the text opens your default coding agent instead of being pasted"
|
||
onClicked: { if (langRow.armedAgent) root.endCapture(); else root.startCapture(langRow.index, "agentKey") }
|
||
onRightClicked: { root.endCapture(); root.patchLang(langRow.index, "agentKey", "") }
|
||
}
|
||
|
||
Row {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: langRow.cols.actW
|
||
spacing: 0
|
||
PanelActionButton {
|
||
iconText: ""
|
||
tooltipText: langRow.index === 1 ? "Make default" : "Move up"
|
||
enabled: langRow.index > 0
|
||
opacity: enabled ? 1 : 0.25
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onClicked: root.moveLang(langRow.index, -1)
|
||
}
|
||
PanelActionButton {
|
||
iconText: ""
|
||
tooltipText: "Move down"
|
||
enabled: langRow.index < root.langs.length - 1
|
||
opacity: enabled ? 1 : 0.25
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onClicked: root.moveLang(langRow.index, 1)
|
||
}
|
||
PanelActionButton {
|
||
iconText: ""
|
||
tooltipText: "Remove language"
|
||
enabled: root.langs.length > 1
|
||
opacity: enabled ? 1 : 0.25
|
||
foreground: root.fg
|
||
hoverColor: Color.urgent
|
||
fontFamily: root.fontFamily
|
||
onClicked: root.removeLang(langRow.index)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SearchableDropdown {
|
||
id: addPicker
|
||
width: parent.width - root.trailInset
|
||
showLabel: false
|
||
triggerLabel: "Add a language…"
|
||
placeholderText: "Search languages…"
|
||
value: ""
|
||
options: root.addableLangs
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onChanged: function(v) { root.addLang(v); value = "" }
|
||
}
|
||
|
||
Note {
|
||
visible: root.captureNote !== "" || root.conflicts.length > 0
|
||
text: root.captureNote !== "" ? root.captureNote : root.conflictText()
|
||
color: Color.urgent
|
||
opacity: 1
|
||
}
|
||
Note { text: "The first language is the default. + Return also presses Return after pasting. Esc discards while recording." }
|
||
}
|
||
|
||
// ---------- The two switches that matter ----------
|
||
Section {
|
||
title: "RECORDING"
|
||
SwitchRow {
|
||
label: "Live text while talking"
|
||
summary: checked ? "uses CPU while recording" : ""
|
||
checked: root.cfg.liveText !== false
|
||
onToggled: if (root.svc) root.svc.setSetting("liveText", root.cfg.liveText === false)
|
||
}
|
||
SwitchRow {
|
||
label: "Keep the audio of every recording"
|
||
checked: root.cfg.keepAudio !== false
|
||
onToggled: if (root.svc) root.svc.setSetting("keepAudio", root.cfg.keepAudio === false)
|
||
}
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
RowLabel { text: "Keep history" }
|
||
Dropdown {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
showLabel: false
|
||
enabled: root.connected
|
||
value: String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays)
|
||
options: [ { value: "1", label: "For a day" }, { value: "7", label: "For a week" }, { value: "30", label: "For a month" },
|
||
{ value: "90", label: "For 3 months" }, { value: "365", label: "For a year" }, { value: "0", label: "Forever" } ]
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onChanged: function(v) { if (root.svc) root.svc.setSetting("historyDays", parseInt(v)); value = Qt.binding(function() { return String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays) }) }
|
||
}
|
||
}
|
||
Note { text: "Older recordings and their audio are deleted automatically." }
|
||
}
|
||
|
||
// ---------- Advanced (collapsed) ----------
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(10)
|
||
PanelSeparator { width: parent.width; foreground: root.fg }
|
||
Item {
|
||
width: parent.width
|
||
height: root.rowH
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: (root.advancedOpen ? " " : " ") + "Advanced"
|
||
color: advHover.hovered ? root.fg : root.dim
|
||
font.family: root.fontFamily
|
||
font.pixelSize: Style.font.body
|
||
}
|
||
HoverHandler { id: advHover }
|
||
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: root.advancedOpen = !root.advancedOpen }
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(10)
|
||
visible: root.advancedOpen
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
RowLabel { text: "Recognition" }
|
||
Dropdown {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
showLabel: false
|
||
enabled: root.connected
|
||
value: String(root.cfg.engine || "voxtype")
|
||
options: root.engineOpts
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onChanged: function(v) { if (root.svc) root.svc.setSetting("engine", v); value = Qt.binding(function() { return String(root.cfg.engine || "voxtype") }) }
|
||
}
|
||
}
|
||
Note {
|
||
visible: root.cfg.engine === "voxtype" || !root.cfg.engine
|
||
text: "Uses Omarchy's dictation model (change it with omarchy-voxtype-model). English-only models are swapped for the multilingual one automatically."
|
||
}
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
visible: root.cfg.engine === "command"
|
||
RowLabel { text: "Command" }
|
||
ConfigField {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
placeholderText: "whisper-cli -m model.bin -l {lang} -nt -np -f {file}"
|
||
key: "engineCommand"
|
||
}
|
||
}
|
||
Note { visible: root.cfg.engine === "command"; text: "{file} is a 16 kHz mono WAV, {lang} the language code; stdout is the text." }
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
visible: root.cfg.engine === "whisper-cpp"
|
||
RowLabel { text: "Model" }
|
||
ConfigField {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
placeholderText: "~/.local/share/voxtype/models/ggml-base.bin"
|
||
key: "whisperModel"
|
||
}
|
||
}
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
RowLabel { text: "After recording" }
|
||
Dropdown {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
showLabel: false
|
||
enabled: root.connected
|
||
value: String(root.cfg.outputMode || "paste")
|
||
options: [ { value: "paste", label: "Paste at the cursor" }, { value: "type", label: "Type it out key by key" }, { value: "clipboard", label: "Copy to the clipboard only" } ]
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onChanged: function(v) { if (root.svc) root.svc.setSetting("outputMode", v); value = Qt.binding(function() { return String(root.cfg.outputMode || "paste") }) }
|
||
}
|
||
}
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
visible: (root.cfg.outputMode || "paste") === "paste"
|
||
RowLabel { text: "Paste keys" }
|
||
Dropdown {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
showLabel: false
|
||
enabled: root.connected
|
||
value: String(root.cfg.pasteKeys || "auto")
|
||
options: [ { value: "auto", label: "Auto (Ctrl+Shift+V in terminals, Ctrl+V elsewhere)" }, { value: "ctrl+v", label: "Ctrl+V" }, { value: "ctrl+shift+v", label: "Ctrl+Shift+V" }, { value: "shift+insert", label: "Shift+Insert" } ]
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onChanged: function(v) { if (root.svc) root.svc.setSetting("pasteKeys", v); value = Qt.binding(function() { return String(root.cfg.pasteKeys || "auto") }) }
|
||
}
|
||
}
|
||
SwitchRow {
|
||
visible: (root.cfg.outputMode || "paste") === "paste"
|
||
label: "Restore the clipboard after pasting"
|
||
checked: root.cfg.restoreClipboard !== false
|
||
onToggled: if (root.svc) root.svc.setSetting("restoreClipboard", root.cfg.restoreClipboard === false)
|
||
}
|
||
SwitchRow {
|
||
label: "Notify when a recording fails"
|
||
checked: root.cfg.notify !== false
|
||
onToggled: if (root.svc) root.svc.setSetting("notify", root.cfg.notify === false)
|
||
}
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
RowLabel { text: "Max recording (s)" }
|
||
NumberField {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
enabled: root.connected
|
||
value: Number(root.cfg.maxDurationSecs || 300)
|
||
from: 5
|
||
to: 3600
|
||
stepSize: 5
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onModified: function(v) { if (root.svc) root.svc.setSetting("maxDurationSecs", v) }
|
||
}
|
||
}
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
RowLabel { text: "Microphone" }
|
||
Dropdown {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
showLabel: false
|
||
enabled: root.connected
|
||
value: String(root.cfg.device || "default")
|
||
options: root.micOpts
|
||
foreground: root.fg
|
||
fontFamily: root.fontFamily
|
||
onChanged: function(v) { if (root.svc) root.svc.setSetting("device", v); value = Qt.binding(function() { return String(root.cfg.device || "default") }) }
|
||
}
|
||
}
|
||
Note { text: "A Bluetooth headset switches to its low-quality headset profile while its microphone is open, which pauses or degrades whatever it is playing. Pick another microphone here to avoid that." }
|
||
Row {
|
||
width: parent.width
|
||
spacing: Style.space(8)
|
||
RowLabel { text: "Agent command" }
|
||
ConfigField {
|
||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||
placeholderText: "omarchy-agent-prompt {text}"
|
||
key: "agentCommand"
|
||
}
|
||
}
|
||
Note { text: "Runs with {text} replaced by the (quoted) transcription. The default opens Omarchy's default agent (" + root.agentName + ", set with: omarchy default agent <name>)." }
|
||
Note { text: "Saved to ~/.config/speech-to-text/config.json; key bindings apply immediately." }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|