pragma ComponentBehavior: Bound import QtQuick import Quickshell import "Util.js" as Util Column { id: root required property Theme theme readonly property Flickable scrollView: historyFlick required property Noko panelState property int waveTick: 0 property int copiedIndex: -1 function roleColor(role) { if (!theme) return "transparent"; if (role === "warning") return theme.warning; if (role === "error") return theme.error; return theme.ui.accent; } function assetOk(name) { const a = panelState && panelState.assets ? panelState.assets : ({}); if (name === "vad") return !!(a.vad && a.vad.exists); if (name === "model") return !!(a.parakeet && a.parakeet.complete); if (name === "runtime") return !!(a.runtime && a.runtime.exists); return false; } function toolOk(command) { const deps = panelState && panelState.doctor && panelState.doctor.systemDependencies ? panelState.doctor.systemDependencies : []; for (let i = 0; i < deps.length; i++) if (deps[i].command === command) return !!deps[i].exists; return false; } function friendlyProblem() { if (!panelState) return "local speech status unknown"; if (panelState.listening) return "capturing locally until toggled off"; if (panelState.transcribing) return "transcribing locally"; if (panelState.installing) return "downloading local runtime and speech models"; if (panelState.lastError.length === 0) return "local speech ready"; return "setup required before recording"; } function setupActionLabel() { if (!panelState) return "install setup"; if (panelState.installing) return `${panelState.installLabel || "downloading"} ${Math.round(panelState.installProgress * 100)}%`; if (panelState.ready && panelState.updateAvailable) return "apply update"; return panelState.ready ? "" : "install setup"; } function showSetupAction() { return !!panelState && (panelState.installing || !panelState.ready || panelState.updateAvailable); } function updateAccent() { if (!theme) return "white"; if (!panelState || panelState.updateUnknown || panelState.updateAvailable) return theme.warning; if (panelState.checkingUpdates) return theme.ui.accent; return panelState.updateChecked ? theme.ui.accent : theme.ui.minor; } function waveLevel(index) { if (!panelState || (!panelState.listening && !panelState.transcribing)) return 0.2 + (index % 3) * 0.08; const phase = (waveTick + index * 5) / (panelState.transcribing ? 7 : 4); return 0.24 + Math.abs(Math.sin(phase)) * (panelState.transcribing ? 0.42 : 0.7); } function updateLine() { if (!panelState) return "updates not checked"; if (panelState.checkingUpdates) return "checking updates"; if (panelState.updateUnknown) return "update check failed"; if (!panelState.updateChecked) return "updates not checked"; return panelState.updateAvailable ? "update available" : "up to date"; } width: parent ? parent.width : 0 height: parent ? parent.height : implicitHeight spacing: 14 Component.onCompleted: if (panelState) panelState.refresh() Timer { interval: 90 repeat: true running: root.panelState && (root.panelState.listening || root.panelState.transcribing) onTriggered: root.waveTick++ } Timer { id: copyFeedbackTimer interval: 1200 onTriggered: root.copiedIndex = -1 } PopupCard { theme: root.theme padding: 0 attention: root.panelState && root.panelState.role === "warning" destructive: root.panelState && root.panelState.role === "error" width: parent.width height: 76 Column { anchors.fill: parent anchors.leftMargin: 14 anchors.rightMargin: root.showSetupAction() ? 242 : 14 anchors.topMargin: 13 anchors.bottomMargin: 12 spacing: 7 Row { width: parent.width spacing: 8 NuguText { theme: root.theme variant: "title" text: "noko" color: root.theme ? root.theme.content.normal : "white" } NuguText { theme: root.theme variant: "meta" text: root.panelState ? root.panelState.status : "unknown" color: root.theme ? root.roleColor(root.panelState ? root.panelState.role : "warning") : "white" } } NuguText { theme: root.theme width: parent.width variant: "meta" text: root.friendlyProblem() color: root.theme ? root.theme.ui.minor : "white" } } NokoButton { visible: root.showSetupAction() anchors.right: parent.right anchors.rightMargin: 14 anchors.verticalCenter: parent.verticalCenter theme: root.theme label: root.setupActionLabel() kind: "primary" buttonWidth: 210 onClicked: if (root.panelState) { if (root.panelState.ready) root.panelState.update(); else root.panelState.install(); } } } Column { width: parent.width spacing: 7 Row { width: parent.width height: 24 spacing: 8 NuguText { id: setupTitle anchors.verticalCenter: parent.verticalCenter theme: root.theme variant: "title" font.pixelSize: 13 text: "setup" color: root.theme ? root.theme.ui.accent : "white" } Item { width: Math.max(1, parent.width - setupTitle.implicitWidth - updateBadge.width - parent.spacing * 2) height: 1 } PopupBadge { id: updateBadge anchors.verticalCenter: parent.verticalCenter theme: root.theme width: 136 text: root.updateLine() icon: "●" role: root.panelState && (root.panelState.updateUnknown || root.panelState.updateAvailable) ? "warning" : "active" } } PopupCard { theme: root.theme padding: 0 width: parent.width height: setupFlow.implicitHeight + 18 + (root.panelState && root.panelState.installing ? 25 : 0) Column { anchors.fill: parent anchors.margins: 9 spacing: 6 Flow { id: setupFlow width: parent.width spacing: 14 NokoAssetRow { theme: root.theme label: "voice detector" ok: root.assetOk("vad") } NokoAssetRow { theme: root.theme label: "speech model" ok: root.assetOk("model") } NokoAssetRow { theme: root.theme label: "local runtime" ok: root.assetOk("runtime") } NokoAssetRow { theme: root.theme label: "mic capture" ok: root.toolOk("pw-record") } NokoAssetRow { theme: root.theme label: "text insertion" ok: root.toolOk("wtype") } } Rectangle { visible: root.panelState && root.panelState.installing width: parent.width height: 6 radius: 3 color: root.theme ? root.theme.withAlpha(root.theme.ui.minor, 0.22) : "transparent" Rectangle { width: parent.width * Math.max(0, Math.min(1, root.panelState ? root.panelState.installProgress : 0)) height: parent.height radius: parent.radius color: root.theme ? root.theme.ui.accent : "white" } } NuguText { visible: root.panelState && root.panelState.installing theme: root.theme variant: "meta" font.pixelSize: 12 text: root.panelState ? root.panelState.installLabel : "downloading" color: root.theme ? root.theme.ui.minor : "white" } } } } Column { width: parent.width spacing: 7 NuguText { theme: root.theme variant: "title" font.pixelSize: 13 text: "capture" color: root.theme ? root.theme.ui.accent : "white" } PopupCard { theme: root.theme padding: 0 active: root.panelState && root.panelState.listening width: parent.width height: 58 Row { anchors.centerIn: parent spacing: 5 height: 36 Repeater { model: 31 Rectangle { required property int index anchors.verticalCenter: parent.verticalCenter width: 4 height: Math.max(5, parent.height * root.waveLevel(index)) radius: 2 color: root.theme ? (root.panelState && root.panelState.listening ? root.theme.ui.accent : root.theme.ui.minor) : "white" opacity: root.panelState && root.panelState.ready ? 0.86 : 0.3 Behavior on height { NumberAnimation { duration: root.theme && root.theme.lowPowerMode ? 0 : 120 easing.type: Easing.OutCubic } } } } } } NokoButton { theme: root.theme label: root.panelState && root.panelState.listening ? "stop capture" : (root.panelState && root.panelState.transcribing ? "transcribing…" : "start capture") kind: root.panelState && root.panelState.listening ? "primary" : "ghost" enabled: root.panelState && root.panelState.ready && !root.panelState.transcribing buttonWidth: parent.width onClicked: if (root.panelState) root.panelState.trigger() } } PopupCard { theme: root.theme padding: 0 width: parent.width height: Math.max(150, root.height - y) Column { anchors.fill: parent anchors.margins: 12 spacing: 7 NuguText { theme: root.theme variant: "title" font.pixelSize: 13 text: "last transcripts" color: root.theme ? root.theme.ui.accent : "white" } Item { width: parent.width height: Math.max(1, parent.height - y) clip: true Flickable { id: historyFlick anchors.fill: parent anchors.rightMargin: contentHeight > height ? 8 : 0 clip: true contentWidth: width contentHeight: historyColumn.implicitHeight boundsBehavior: Flickable.StopAtBounds interactive: contentHeight > height Column { id: historyColumn width: historyFlick.width spacing: 7 Repeater { model: root.panelState ? root.panelState.historyEntries : [] delegate: PopupCard { id: transcriptEntry required property int index required property var modelData readonly property string transcript: String(modelData.transcript || "") readonly property bool copied: root.copiedIndex === index theme: root.theme padding: 0 width: historyColumn.width height: entryColumn.implicitHeight + 10 Column { id: entryColumn anchors.left: parent.left anchors.right: copyButton.left anchors.top: parent.top anchors.margins: 8 spacing: 3 NuguText { theme: root.theme width: parent.width wrapMode: Text.WordWrap text: transcriptEntry.transcript color: root.theme ? root.theme.content.normal : "white" } NuguText { theme: root.theme variant: "meta" width: parent.width text: transcriptEntry.modelData.duration !== undefined ? `${transcriptEntry.modelData.duration}s` : "local history" color: root.theme ? root.theme.ui.minor : "white" } } PopupButton { id: copyButton anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 theme: root.theme width: 58 height: 26 text: transcriptEntry.copied ? "copied" : "copy" checked: transcriptEntry.copied onControlFocusedChanged: if (controlFocused) Util.revealIn(copyButton, historyFlick) onClicked: if (transcriptEntry.transcript.length > 0) { Quickshell.execDetached(["wl-copy", transcriptEntry.transcript]); root.copiedIndex = transcriptEntry.index; copyFeedbackTimer.restart(); } } } } NuguText { visible: !root.panelState || root.panelState.historyEntries.length === 0 theme: root.theme width: parent.width text: "no captures yet" color: root.theme ? root.theme.content.normal : "white" } } } Rectangle { visible: historyFlick.contentHeight > historyFlick.height anchors.top: parent.top anchors.bottom: parent.bottom anchors.right: parent.right width: 3 radius: 2 color: root.theme ? root.theme.withAlpha(root.theme.ui.minor, 0.14) : "transparent" Rectangle { width: parent.width height: Math.max(18, parent.height * historyFlick["visibleArea"]["heightRatio"]) y: Math.max(0, Math.min(parent.height - height, parent.height * historyFlick["visibleArea"]["yPosition"])) radius: parent.radius color: root.theme ? root.theme.withAlpha(root.theme.ui.accent, 0.55) : "white" } } } } } }