diff options
Diffstat (limited to 'desktop/modules/companion')
| -rw-r--r-- | desktop/modules/companion/ArtistsModule.qml | 67 | ||||
| -rw-r--r-- | desktop/modules/companion/CompanionModule.qml | 117 | ||||
| -rw-r--r-- | desktop/modules/companion/PhrasesModule.qml | 64 | ||||
| -rw-r--r-- | desktop/modules/companion/PhrasesPage.qml | 195 |
4 files changed, 422 insertions, 21 deletions
diff --git a/desktop/modules/companion/ArtistsModule.qml b/desktop/modules/companion/ArtistsModule.qml new file mode 100644 index 0000000..a0d23f4 --- /dev/null +++ b/desktop/modules/companion/ArtistsModule.qml @@ -0,0 +1,67 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." +import "../../companion/Lines.js" as Lines + +// The lines the companion says when a track by an artist she knows starts, +// matched as a case-insensitive substring of the player's artist. Same +// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and +// Lines.artists; the page's cls field holds the artist. +Module { + id: am + name: "artists" + label: "Companion artists" + // nf-fa-music, present in Inconsolata Nerd Font. + icon: "\uf001" + settings: true + group: "Companion" + keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"] + searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) + + property var draft: null + property bool resetting: false + readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists) + readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== "" + && (a.lines || []).some(l => String(l).trim() !== "")) + dirty: am.draft !== null + + function edit(list) { am.draft = list; am.resetting = false; } + function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; } + function save() { + if (am.resetting) Prefs.resetCompanionArtists(); + else Prefs.setCompanionArtists(Lines.toMap(am.draft)); + am.discard(); + } + function discard() { am.draft = null; am.resetting = false; } + + page: Component { + Page { + title: "Companion artists" + actions: Component { + SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase" + onSave: am.save(); onDiscard: am.discard() } + } + PhrasesPage { + width: parent.width + owner: am + intro: "When a track by an artist she knows starts or changes, she says one of their lines at " + + "random, under her 30 s line cooldown. The name matches any part of the player's artist, " + + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". " + + "Save applies at once." + keyHint: "artist, e.g. linkin park" + addText: "Add artist" + saved: Prefs.companionArtists + } + } + } +} diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml index ea6b13d..8d6a68d 100644 --- a/desktop/modules/companion/CompanionModule.qml +++ b/desktop/modules/companion/CompanionModule.qml @@ -11,42 +11,117 @@ import QtQuick import "../.." +import "../appearance" +import "../../companion" -// The desktop companion's one setting: whether she is shown. The same -// preference SUPER+\ toggles, so the switch follows the key. +// The desktop companion's settings: whether she is shown, the same +// preference SUPER+\ toggles, so the switch follows the key; and where her +// wallpaper list is, marked with the heart in the wallpaper picker. Module { name: "companion" label: "Companion" // nf-md-account_heart, present in Inconsolata Nerd Font. icon: "\u{F0899}" settings: true - group: "Desktop" - keywords: ["companion", "2b", "mascot", "character", "show", "hide"] + group: "Companion" + keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"] page: Component { Page { title: "Companion" - Item { + Column { width: parent.width - height: 34 - Text { - id: lbl - anchors { left: parent.left; verticalCenter: parent.verticalCenter } - width: 160 - text: "Shown" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + spacing: 6 + + Item { + width: parent.width + height: 34 + Text { + id: lbl + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Shown" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + id: sw + anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: Prefs.companion + onToggled: on => Prefs.setCompanion(on) + } + Text { + anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: "Also SUPER+\\. Kept across restarts." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + // After the self-destruct. Off keeps the leotard until she is + // hidden and shown again; turning it back on starts from the + // seconds in the field. + Item { + width: parent.width + height: 34 + Text { + id: lblSk + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Leotard timeout" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + id: swSk + anchors { left: lblSk.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: Prefs.companionSkirtless > 0 + onToggled: on => Prefs.setCompanionSkirtless(on ? (parseInt(fSk.value) || 60) : 0) + } + Field { + id: fSk + anchors { left: swSk.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + width: 60 + enabled: swSk.checked + opacity: enabled ? 1 : 0.4 + value: String(Prefs.companionSkirtless || 60) + placeholder: "60" + onEdited: { + const n = parseInt(value); + if (n >= 1 && String(n) === value.trim()) Prefs.setCompanionSkirtless(n); + else value = String(Prefs.companionSkirtless || 60); + } + } + Text { + anchors { left: fSk.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: swSk.checked ? "seconds" : "until she is hidden and shown again" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } } - Switch { - id: sw - anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter } - checked: Prefs.companion - onToggled: on => Prefs.setCompanion(on) + Item { + width: parent.width + height: 34 + Text { + id: lbl2 + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Wallpaper list" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } + value: Prefs.companionBlushFile + placeholder: Admired.defaultPath.replace(Admired.home, "~") + onEdited: Prefs.setCompanionBlushFile(value) + } } Text { - anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } - elide: Text.ElideRight - text: "Also SUPER+\\. Kept across restarts." + width: parent.width + wrapMode: Text.WordWrap + text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } } diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml new file mode 100644 index 0000000..7e4895a --- /dev/null +++ b/desktop/modules/companion/PhrasesModule.qml @@ -0,0 +1,64 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." +import "../../companion/Lines.js" as Lines + +// The lines the companion says when focus moves to a program she +// recognizes, by its lowercased window class. They live in Prefs and she +// reads them live, so a save needs no restart. The page edits a draft here; +// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults +// show again. +Module { + id: pm + name: "phrases" + label: "Companion phrases" + // nf-fa-comment, present in Inconsolata Nerd Font. + icon: "\uf075" + settings: true + group: "Companion" + keywords: ["companion", "phrase", "line", "say", "speak", "app", "program", + "window", "class", "firefox", "2b"] + searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) + + // null while there are no edits: the page then shows the saved set, or + // Lines.js' defaults when nothing is saved. + property var draft: null + // Reset was pressed: Save drops the saved set rather than saving a copy + // of the defaults, so later default changes still arrive. + property bool resetting: false + readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps) + // Every app needs a class and at least one non-empty phrase; Save waits. + readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== "" + && (a.lines || []).some(l => String(l).trim() !== "")) + dirty: pm.draft !== null + + function edit(list) { pm.draft = list; pm.resetting = false; } + function reset() { pm.draft = Lines.appList(null); pm.resetting = true; } + function save() { + if (pm.resetting) Prefs.resetCompanionApps(); + else Prefs.setCompanionApps(Lines.toMap(pm.draft)); + pm.discard(); + } + function discard() { pm.draft = null; pm.resetting = false; } + + page: Component { + Page { + title: "Companion phrases" + actions: Component { + SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase" + onSave: pm.save(); onDiscard: pm.discard() } + } + PhrasesPage { width: parent.width; owner: pm } + } + } +} diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml new file mode 100644 index 0000000..1971171 --- /dev/null +++ b/desktop/modules/companion/PhrasesPage.qml @@ -0,0 +1,195 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +// One card per recognized program: its window class and the lines she says +// when focus moves to it. Every change edits the module's draft; the +// header's Save hands the set to Prefs. Reset fills the draft with the +// built-in set from Lines.js. +Column { + id: page + spacing: 10 + + // The module, holding the draft. Not named pm, its id (it would bind to + // itself, AGENTS.md). + required property QtObject owner + // What the cards are keyed by; the defaults are the app set's, so + // PhrasesModule passes nothing. + property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third " + + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, " + + "lowercased (hyprctl activewindow shows it). Save applies at once." + property string keyHint: "window class, e.g. firefox" + property string addText: "Add app" + // The saved set, for Reset's enabled state; null means the defaults. + property var saved: Prefs.companionApps + readonly property var list: owner.shown + // The app card awaiting a second click on Remove. + property int confirming: -1 + + // Saves a copy of the list with app i replaced by fn(app), or removed + // when fn returns null. + function edit(i, fn) { + const out = []; + for (let j = 0; j < page.list.length; j++) { + const a = page.list[j]; + const c = { cls: a.cls, lines: a.lines.slice() }; + if (j !== i) { out.push(c); continue; } + const r = fn(c); + if (r) out.push(r); + } + page.confirming = -1; + page.owner.edit(out); + } + + function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); } + function removeApp(i) { page.edit(i, () => null); } + function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); } + function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); } + function setLine(i, j, t) { + page.edit(i, a => { + const ls = a.lines.slice(); + ls[j] = t; + return Object.assign(a, { lines: ls }); + }); + } + function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); } + + component Small: Text { + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + // Reports every keystroke into the draft. Not bound to value, which + // would reset the cursor; it follows the value only when the draft moves + // under it (Discard, Reset). Empty is outlined, since Save refuses it. + component Field: Rectangle { + id: field + property string value: "" + property string placeholder: "" + signal edited(string text) + height: 32 + radius: 8 + color: Qt.alpha(Theme.base, 0.6) + border.width: ti.text.trim() === "" ? 1 : 0 + border.color: Theme.red + onValueChanged: if (ti.text !== field.value) ti.text = field.value + TextInput { + activeFocusOnTab: true + id: ti + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + Component.onCompleted: text = field.value + onTextEdited: field.edited(text) + Text { + anchors.verticalCenter: parent.verticalCenter + visible: parent.text === "" + text: field.placeholder + color: Theme.overlay + font: parent.font + } + } + } + + Small { + width: page.width + wrapMode: Text.Wrap + text: page.intro + } + + Repeater { + // A count, not the list: a new list on every keystroke would rebuild + // every card and drop the focus of the field being typed in. + model: page.list.length + + delegate: Rectangle { + id: card + required property int index + readonly property var modelData: page.list[index] ?? { cls: "", lines: [] } + objectName: "app:" + index + width: page.width + height: body.implicitHeight + 20 + radius: 10 + color: Qt.alpha(Theme.surface, 0.45) + + Column { + id: body + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + height: 32 + Field { + anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } + value: card.modelData.cls + placeholder: page.keyHint + onEdited: t => page.setCls(card.index, t) + } + Button { + id: del + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + danger: true + text: page.confirming === card.index ? "Confirm" : "Remove" + onClicked: { + if (page.confirming === card.index) page.removeApp(card.index); + else page.confirming = card.index; + } + } + } + + Repeater { + model: card.modelData.lines.length + delegate: Row { + id: phraseRow + required property int index + width: body.width + spacing: 6 + Field { + width: phraseRow.width - removeBtn.width - phraseRow.spacing + value: card.modelData.lines[phraseRow.index] ?? "" + placeholder: "a line she says" + onEdited: t => page.setLine(card.index, phraseRow.index, t) + } + Button { + id: removeBtn + // nf-fa-xmark, present in Inconsolata Nerd Font. + icon: "\uf00d" + danger: true + onClicked: page.removeLine(card.index, phraseRow.index) + } + } + } + + Button { + text: "Add phrase" + onClicked: page.addLine(card.index) + } + } + } + } + + Row { + spacing: 8 + Button { text: page.addText; onClicked: page.addApp() } + Button { + text: "Reset to defaults" + danger: true + enabled: !page.owner.resetting && (page.saved !== null || page.owner.dirty) + onClicked: page.owner.reset() + } + } +} |
