From 441d8e30e9fdf8cb1215a5b1c45770955758ea26 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 13:15:15 +0200 Subject: settings: OBS scenes icon page --- desktop/island/SceneIcons.qml | 46 ++++++++++- desktop/modules/obs/ObsScenesModule.qml | 59 ++++++++++++++ desktop/modules/obs/ObsScenesPage.qml | 138 ++++++++++++++++++++++++++++++++ desktop/modules/obs/README.md | 21 +++++ desktop/shell.qml | 2 + 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 desktop/modules/obs/ObsScenesModule.qml create mode 100644 desktop/modules/obs/ObsScenesPage.qml create mode 100644 desktop/modules/obs/README.md 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. +// +// 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. +// +// 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 {}, -- cgit v1.2.3