aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/island/SceneIcons.qml46
-rw-r--r--desktop/modules/obs/ObsScenesModule.qml59
-rw-r--r--desktop/modules/obs/ObsScenesPage.qml138
-rw-r--r--desktop/modules/obs/README.md21
-rw-r--r--desktop/shell.qml2
5 files changed, 264 insertions, 2 deletions
diff --git a/desktop/island/SceneIcons.qml b/desktop/island/SceneIcons.qml
index 44bcf9c..b8fe6b8 100644
--- a/desktop/island/SceneIcons.qml
+++ b/desktop/island/SceneIcons.qml
@@ -27,11 +27,53 @@ Singleton {
property var map: Icons.defaults
+ // The settings page's draft, null when not editing. Every edit reassigns
+ // draft so the page's bindings fire; Save normalizes and writes.
+ property var draft: null
+ property bool dirty: false
+ readonly property bool valid: draft !== null && Icons.validMap(draft)
+ // Emitted when the draft is dropped, so the page rebuilds its fields:
+ // TextInput breaks its text binding on user edit, so a Discard would
+ // otherwise leave the old glyph on screen.
+ signal reset
+
function iconFor(name) { return Icons.iconFor(root.map, name); }
+ function validGlyph(s) { return Icons.validGlyph(s); }
+
+ function begin() {
+ if (!root.dirty)
+ root.draft = Icons.parse(Icons.serialize(root.map));
+ }
+
+ function editScene(name, text) {
+ if (!root.draft) root.begin();
+ const scenes = Object.assign({}, root.draft.scenes);
+ if (text === "") delete scenes[name];
+ else scenes[name] = text;
+ root.draft = Object.assign({}, root.draft, { scenes: scenes });
+ root.dirty = true;
+ }
+
+ function editGeneric(text) {
+ if (!root.draft) root.begin();
+ root.draft = Object.assign({}, root.draft, { generic: text });
+ root.dirty = true;
+ }
+
+ function discard() {
+ root.draft = null;
+ root.dirty = false;
+ root.reset();
+ }
function save(map) {
- root.map = map;
- fv.setText(Icons.serialize(map));
+ if (!map) return;
+ const out = Icons.normalize(map);
+ root.map = out;
+ fv.setText(Icons.serialize(out));
+ root.draft = null;
+ root.dirty = false;
+ root.reset();
}
FileView {
diff --git a/desktop/modules/obs/ObsScenesModule.qml b/desktop/modules/obs/ObsScenesModule.qml
new file mode 100644
index 0000000..a91149e
--- /dev/null
+++ b/desktop/modules/obs/ObsScenesModule.qml
@@ -0,0 +1,59 @@
+// 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 "../../island"
+
+// Which glyph each OBS scene shows in the island pill, from the same watched
+// map the pill reads. Set once per scene: paste an icon from the Nerd Fonts
+// cheat sheet. OBS is not required to edit the configured rows.
+Module {
+ id: mod
+ name: "obs"
+ label: "OBS scenes"
+ // nf-md-record_circle, present in Inconsolata Nerd Font.
+ icon: "\udb83\udec2"
+ settings: true
+ group: "Desktop"
+ dirty: SceneIcons.dirty
+ keywords: ["obs", "scene", "icon", "glyph", "recording", "webcam", "stream"]
+
+ // The saved keys plus whatever OBS currently reports, so a scene created
+ // in OBS appears without a restart and a configured scene survives OBS
+ // being closed.
+ function names() {
+ const out = [];
+ for (const n in SceneIcons.map.scenes) out.push(n);
+ for (const n of Obs.scenes) if (out.indexOf(n) < 0) out.push(n);
+ return out;
+ }
+
+ function refresh() {
+ if (!SceneIcons.dirty) SceneIcons.begin();
+ mod.searchItems = mod.names().map(n => ({ text: n, key: n }));
+ }
+
+ page: Component {
+ Page {
+ title: "OBS scenes"
+ actions: Component {
+ SaveBar {
+ dirty: SceneIcons.dirty
+ valid: SceneIcons.valid
+ onSave: SceneIcons.save(SceneIcons.draft)
+ onDiscard: SceneIcons.discard()
+ }
+ }
+ ObsScenesPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/obs/ObsScenesPage.qml b/desktop/modules/obs/ObsScenesPage.qml
new file mode 100644
index 0000000..0fb06a9
--- /dev/null
+++ b/desktop/modules/obs/ObsScenesPage.qml
@@ -0,0 +1,138 @@
+// 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 "../../island"
+
+// One row per scene: its name and a field holding its glyph. Paste an icon
+// from the Nerd Fonts cheat sheet; an empty field falls back to the generic.
+Column {
+ id: page
+
+ spacing: 6
+
+ // Bumped on discard/save so the rows are rebuilt. A TextInput breaks its
+ // text binding on user edit, so without this a Discard would leave the
+ // discarded glyph on screen.
+ property int rev: 0
+ Connections {
+ target: SceneIcons
+ function onReset() { page.rev++ }
+ }
+
+ readonly property var rows: {
+ const out = [];
+ const add = n => { if (!out.some(r => r.name === n)) out.push({ name: n, rev: page.rev }); };
+ for (const n in SceneIcons.map.scenes) add(n);
+ for (const n of Obs.scenes) add(n);
+ return out;
+ }
+
+ function glyphOf(name) {
+ const d = SceneIcons.draft;
+ if (d && d.scenes) return d.scenes[name] ?? "";
+ return SceneIcons.map.scenes[name] ?? "";
+ }
+
+ function genericGlyph() {
+ const d = SceneIcons.draft;
+ return d ? (d.generic ?? "") : SceneIcons.map.generic;
+ }
+
+ Text {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: "Paste an icon from the Nerd Fonts cheat sheet. An empty field falls back to the default for unmapped scenes."
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Repeater {
+ model: page.rows
+ delegate: Item {
+ id: row
+ required property var modelData
+ objectName: modelData.name
+ width: page.width
+ height: 40
+
+ Text {
+ anchors { left: parent.left; right: field.left; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: row.modelData.name
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+
+ Rectangle {
+ id: field
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ width: 64
+ height: 34
+ radius: 8
+ color: Theme.surface
+ border.width: 1
+ border.color: field.invalid ? Theme.red : Qt.alpha(Theme.text, 0.15)
+ readonly property string value: page.glyphOf(row.modelData.name)
+ readonly property bool invalid: value !== "" && !SceneIcons.validGlyph(value)
+
+ TextInput {
+ anchors.fill: parent
+ horizontalAlignment: TextInput.AlignHCenter
+ verticalAlignment: TextInput.AlignVCenter
+ selectByMouse: true
+ text: field.value
+ color: Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 4 }
+ onTextEdited: SceneIcons.editScene(row.modelData.name, text)
+ }
+ }
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: 40
+
+ Text {
+ anchors { left: parent.left; right: gfield.left; rightMargin: 10; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ text: "Default for unmapped scenes"
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+
+ Rectangle {
+ id: gfield
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ width: 64
+ height: 34
+ radius: 8
+ color: Theme.surface
+ border.width: 1
+ border.color: gfield.invalid ? Theme.red : Qt.alpha(Theme.text, 0.15)
+ readonly property string value: page.genericGlyph()
+ readonly property bool invalid: value !== "" && !SceneIcons.validGlyph(value)
+
+ TextInput {
+ anchors.fill: parent
+ horizontalAlignment: TextInput.AlignHCenter
+ verticalAlignment: TextInput.AlignVCenter
+ selectByMouse: true
+ text: gfield.value
+ color: Theme.text
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 4 }
+ onTextEdited: SceneIcons.editGeneric(text)
+ }
+ }
+ }
+}
diff --git a/desktop/modules/obs/README.md b/desktop/modules/obs/README.md
new file mode 100644
index 0000000..c114192
--- /dev/null
+++ b/desktop/modules/obs/README.md
@@ -0,0 +1,21 @@
+# OBS scenes
+
+The settings page for the island's OBS pill. It edits
+`~/.config/quickshell/obs-scene-icons.json`, the same watched file the pill
+reads, so saving recolours the scene grid with no restart.
+
+One row per scene: the scene name and a field holding its glyph. Find an icon
+on the Nerd Fonts cheat sheet, copy the glyph, and paste it into the field. The
+glyph renders in Inconsolata Nerd Font; one the font lacks shows as tofu, which
+means pick another. A field holds exactly one code point (a supplementary-plane
+glyph is two UTF-16 units); anything longer turns the border red and blocks
+Save.
+
+An empty scene field means "use the generic glyph"; the "Default for unmapped
+scenes" row edits that, and an empty default falls back to `?` so no tile is
+ever blank.
+
+Rows are the configured keys plus whatever scenes OBS reports, so the page
+works with OBS closed for scenes it already knows. A scene created in OBS
+appears once the pill is connected. The JSON can also be hand-edited; the pill
+reloads it.
diff --git a/desktop/shell.qml b/desktop/shell.qml
index 943d01e..f4912e5 100644
--- a/desktop/shell.qml
+++ b/desktop/shell.qml
@@ -29,6 +29,7 @@ import "modules/kdeconnect"
import "modules/mail"
import "modules/network"
import "modules/notes"
+import "modules/obs"
import "modules/perf"
import "modules/sound"
import "modules/status"
@@ -82,6 +83,7 @@ ShellRoot {
SunsetModule {},
IdleModule {},
AutostartModule {},
+ ObsScenesModule {},
AssistantModule { id: asstSettings },
PresetsModule {},
MailGeneralModule {},