diff --git a/README.md b/README.md index 30ed883..eb196f9 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Current Stable Release: v0.3.5

+

+ +

+ +

+ +

+ ## Features @@ -77,6 +85,7 @@ Current Stable Release: v0.3.5 - Installer and uninstaller included - Non-root daily usage via udev permissions - Optional KDE autostart restore +- Optional KDE widget @@ -162,6 +171,11 @@ Clone the repository and run the installer. After installation log out and log back in so group permissions apply. +If want to install optional KDE widget + + cd plasmoid + chmod +x install.sh + ./install.sh **Note:** Keyboard color persists on reboot, but may reset to firmware default after a full power cycle. diff --git a/assets/plasma-widget-colour-pick.png b/assets/plasma-widget-colour-pick.png new file mode 100644 index 0000000..83cd8fb Binary files /dev/null and b/assets/plasma-widget-colour-pick.png differ diff --git a/assets/plasma-widget-demo.png b/assets/plasma-widget-demo.png new file mode 100644 index 0000000..7d0923b Binary files /dev/null and b/assets/plasma-widget-demo.png differ diff --git a/plasmoid/build-translations.sh b/plasmoid/build-translations.sh new file mode 100755 index 0000000..e606198 --- /dev/null +++ b/plasmoid/build-translations.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Compile po/*.po into the applet package. +# +# Plasma looks up an applet's catalogue under the domain +# plasma_applet_, and KPackage adds the installed package's own +# contents/locale directory to the search path, so the .mo files ship with +# the widget instead of needing root access to /usr/share/locale. +set -e + +DOMAIN="plasma_applet_org.vrgb.keyboard" +BASE="$(cd "$(dirname "$0")" && pwd)" + +found=0 +for po in "$BASE"/po/*.po; do + [ -e "$po" ] || continue + found=1 + lang="$(basename "$po" .po)" + dest="$BASE/package/contents/locale/$lang/LC_MESSAGES" + mkdir -p "$dest" + msgfmt --check -o "$dest/$DOMAIN.mo" "$po" + printf '%s -> %s\n' "$lang" "${dest#"$BASE"/}/$DOMAIN.mo" +done + +if [ "$found" -eq 0 ]; then + echo "No po/*.po files found. Run ./extract-messages.sh first." >&2 + exit 1 +fi + +echo +echo "Now reinstall the applet so the catalogues are picked up:" +echo " ./install.sh" diff --git a/plasmoid/extract-messages.sh b/plasmoid/extract-messages.sh new file mode 100755 index 0000000..f70595a --- /dev/null +++ b/plasmoid/extract-messages.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Re-extract translatable strings from the QML sources into po/*.pot, +# then merge the changes into the existing translations. +# +# Run this whenever you add, remove, or reword an i18n() string. +set -e + +DOMAIN="plasma_applet_org.vrgb.keyboard" +BASE="$(cd "$(dirname "$0")" && pwd)" +POT="$BASE/po/$DOMAIN.pot" + +mkdir -p "$BASE/po" + +# Run from the package root so the "#:" source references in the .pot stay +# repo-relative instead of baking in whoever's home directory built it. +cd "$BASE" + +# -C -kde is the KDE extraction mode; it understands the i18n* call family and +# parses QML well enough since the call syntax matches C++. +xgettext --from-code=UTF-8 -C -kde \ + -ci18n -ki18n:1 -ki18nc:1c,2 -ki18np:1,2 -ki18ncp:1c,2,3 \ + --package-name="VRGB Keyboard Lighting" \ + --msgid-bugs-address="https://github.com/vrgb-dev/vrgb/issues" \ + -o "$POT" \ + package/contents/ui/*.qml + +echo "Wrote ${POT#"$BASE"/}" + +for po in "$BASE"/po/*.po; do + [ -e "$po" ] || continue + msgmerge --quiet --update --backup=none "$po" "$POT" + echo "Merged into ${po#"$BASE"/}" +done + +echo +echo "Next: translate any new strings in po/*.po, then run ./build-translations.sh" diff --git a/plasmoid/install.sh b/plasmoid/install.sh new file mode 100755 index 0000000..75bf24e --- /dev/null +++ b/plasmoid/install.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Install or upgrade the VRGB Plasma 6 applet for the current user. +set -e + +APPLET_ID="org.vrgb.keyboard" +PACKAGE_DIR="$(cd "$(dirname "$0")" && pwd)/package" + +if ! command -v kpackagetool6 >/dev/null 2>&1; then + echo "kpackagetool6 not found - this applet requires Plasma 6." >&2 + exit 1 +fi + +if ! command -v vrgb >/dev/null 2>&1 && [ ! -x /usr/local/bin/vrgb ]; then + echo "Warning: vrgb was not found. Install it first with ../install.sh" >&2 +fi + +if kpackagetool6 --type Plasma/Applet --list 2>/dev/null | grep -qx "$APPLET_ID"; then + echo "Upgrading $APPLET_ID..." + kpackagetool6 --type Plasma/Applet --upgrade "$PACKAGE_DIR" +else + echo "Installing $APPLET_ID..." + kpackagetool6 --type Plasma/Applet --install "$PACKAGE_DIR" +fi + +echo +echo "Done. Add it with: right-click the panel -> Add Widgets -> \"VRGB Keyboard Lighting\"" +echo "If you upgraded an already-running applet, restart plasmashell to reload it:" +echo " systemctl --user restart plasma-plasmashell" diff --git a/plasmoid/package/contents/config/main.xml b/plasmoid/package/contents/config/main.xml new file mode 100644 index 0000000..473fd12 --- /dev/null +++ b/plasmoid/package/contents/config/main.xml @@ -0,0 +1,13 @@ + + + + + + + false + + + diff --git a/plasmoid/package/contents/locale/uk/LC_MESSAGES/plasma_applet_org.vrgb.keyboard.mo b/plasmoid/package/contents/locale/uk/LC_MESSAGES/plasma_applet_org.vrgb.keyboard.mo new file mode 100644 index 0000000..8f9c93e Binary files /dev/null and b/plasmoid/package/contents/locale/uk/LC_MESSAGES/plasma_applet_org.vrgb.keyboard.mo differ diff --git a/plasmoid/package/contents/ui/ColorPicker.qml b/plasmoid/package/contents/ui/ColorPicker.qml new file mode 100644 index 0000000..cd5f3e0 --- /dev/null +++ b/plasmoid/package/contents/ui/ColorPicker.qml @@ -0,0 +1,143 @@ +/* + * VRGB - inline HSV colour picker (saturation/value square + hue strip). + * + * Emits picked() continuously while dragging; the caller is expected to + * throttle hardware writes rather than firing one per pixel. + * + * SPDX-License-Identifier: MIT + */ + +import QtQuick +import QtQuick.Layouts +import org.kde.kirigami as Kirigami + +ColumnLayout { + id: picker + + signal picked(color c) + + // Hue is kept separately from the emitted colour: greys report hsvHue == -1, + // so round-tripping through a colour would make the hue marker jump to red. + property real hue: 0 + property real sat: 1 + property real val: 1 + + readonly property color currentColor: Qt.hsva(hue, sat, val, 1) + + // Seed the picker from outside without emitting picked(). + function setColor(c) { + if (Qt.colorEqual(c, picker.currentColor)) { + return; + } + hue = (c.hsvHue < 0) ? hue : c.hsvHue; + sat = c.hsvSaturation; + val = c.hsvValue; + } + + spacing: Kirigami.Units.smallSpacing + + Rectangle { + id: svSquare + + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 7 + + radius: 3 + border.width: 1 + border.color: Qt.rgba(0, 0, 0, 0.25) + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#ffffff" } + GradientStop { position: 1.0; color: Qt.hsva(picker.hue, 1, 1, 1) } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + gradient: Gradient { + orientation: Gradient.Vertical + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: "#000000" } + } + } + + Rectangle { + width: Math.round(Kirigami.Units.gridUnit * 0.8) + height: width + radius: width / 2 + color: "transparent" + border.width: 2 + // Dark ring on pale areas, light ring everywhere else. + border.color: (picker.val > 0.55 && picker.sat < 0.55) ? "#000000" : "#ffffff" + x: picker.sat * svSquare.width - width / 2 + y: (1 - picker.val) * svSquare.height - height / 2 + } + + MouseArea { + anchors.fill: parent + + function pick(mx, my) { + picker.sat = Math.max(0, Math.min(1, mx / width)); + picker.val = 1 - Math.max(0, Math.min(1, my / height)); + picker.picked(picker.currentColor); + } + + onPressed: mouse => pick(mouse.x, mouse.y) + onPositionChanged: mouse => { + if (pressed) { + pick(mouse.x, mouse.y); + } + } + } + } + + Rectangle { + id: hueStrip + + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit + + radius: 3 + border.width: 1 + border.color: Qt.rgba(0, 0, 0, 0.25) + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.000; color: "#ff0000" } + GradientStop { position: 0.167; color: "#ffff00" } + GradientStop { position: 0.333; color: "#00ff00" } + GradientStop { position: 0.500; color: "#00ffff" } + GradientStop { position: 0.667; color: "#0000ff" } + GradientStop { position: 0.833; color: "#ff00ff" } + GradientStop { position: 1.000; color: "#ff0000" } + } + + Rectangle { + width: 3 + height: parent.height + 4 + y: -2 + x: picker.hue * hueStrip.width - width / 2 + color: "#ffffff" + border.width: 1 + border.color: "#000000" + } + + MouseArea { + anchors.fill: parent + + function pick(mx) { + // Clamp below 1.0: a hue of exactly 1.0 wraps back to 0. + picker.hue = Math.max(0, Math.min(0.9999, mx / width)); + picker.picked(picker.currentColor); + } + + onPressed: mouse => pick(mouse.x) + onPositionChanged: mouse => { + if (pressed) { + pick(mouse.x); + } + } + } + } +} diff --git a/plasmoid/package/contents/ui/main.qml b/plasmoid/package/contents/ui/main.qml new file mode 100644 index 0000000..1bb7875 --- /dev/null +++ b/plasmoid/package/contents/ui/main.qml @@ -0,0 +1,663 @@ +/* + * VRGB - Plasma 6 applet for ASUS Vivobook HID LampArray keyboards. + * + * The swatch grid is backed by vrgb profiles rather than hardcoded colours: + * one click loads a profile, a double click opens it in the editor, and "+" + * creates a new one. State is read straight out of ~/.config/vrgb/config.json; + * every write goes through the `vrgb` CLI so the config file has a single + * owner and the applet never touches hidraw itself. + * + * Two different brightnesses are in play, and they multiply: + * - asus::kbd_backlight (0..3), driven by the Fn keys and Plasma. The slider + * on the grid page is bound to it, so the widget and the Fn keys agree. + * - vrgb's own LampArray intensity (0..100), stored per profile. That one + * lives in the editor, since it is part of what a profile *is*. + * + * SPDX-License-Identifier: MIT + */ + +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.plasmoid +import org.kde.plasma.components as PlasmaComponents +import org.kde.plasma.plasma5support as P5Support +import org.kde.plasma.private.brightnesscontrolplugin +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + // Resolved at startup; install.sh puts vrgb in /usr/local/bin, which is not + // always on plasmashell's PATH. + property string vrgbBin: "vrgb" + property color currentColor: "#00aa55" + property int currentPercent: 100 + property string errorText: "" + // Suppresses hardware writes while the UI is being seeded from config.json. + property bool loading: true + + // [{ name, color, percent }], sorted by name. + property var profiles: [] + + // "grid" or "editor" + property string page: "grid" + property string editorName: "" + property bool editorNew: false + property color editorColor: "#ffffff" + property int editorPercent: 100 + // Shown in the header instead of the hex while a swatch is hovered. + property string hoveredName: "" + + // Brightness comes from Plasma so the Fn keys and the widget stay in step. + readonly property int backlight: kbdBacklight.brightness + readonly property int backlightMax: Math.max(1, kbdBacklight.brightnessMax) + readonly property real litFraction: backlight / backlightMax + + readonly property var defaultPalette: [ + { name: "Red", color: "ff0000" }, { name: "Orange", color: "ff7f00" }, + { name: "Amber", color: "ffd400" }, { name: "Lime", color: "7fff00" }, + { name: "Green", color: "00ff5e" }, { name: "Cyan", color: "00e5ff" }, + { name: "Azure", color: "0080ff" }, { name: "Blue", color: "2b3cff" }, + { name: "Violet", color: "8b00ff" }, { name: "Magenta", color: "ff00c8" }, + { name: "Rose", color: "ff4d6d" }, { name: "White", color: "ffffff" } + ] + + readonly property string hexColor: toHex(currentColor) + + // What the keys should actually look like: colour x vrgb intensity x backlight. + readonly property color litColor: { + var f = litFraction * currentPercent / 100; + return Qt.rgba(currentColor.r * f, currentColor.g * f, currentColor.b * f, 1); + } + + readonly property string resolveCmd: "command -v vrgb 2>/dev/null || echo /usr/local/bin/vrgb" + readonly property string readCfgCmd: "cat \"$HOME/.config/vrgb/config.json\" 2>/dev/null" + + Plasmoid.icon: "input-keyboard" + toolTipMainText: i18n("Keyboard Lighting") + toolTipSubText: loading ? i18n("Reading configuration…") + : i18n("#%1 at %2%", hexColor.toUpperCase(), + Math.round(litFraction * 100)) + + preferredRepresentation: compactRepresentation + + function toHex(c) { + function ch(v) { + return Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0"); + } + return ch(c.r) + ch(c.g) + ch(c.b); + } + + // Profile names reach a shell, so quote them properly rather than trusting + // the field validator alone. + function shellQuote(s) { + return "'" + String(s).replace(/'/g, "'\\''") + "'"; + } + + function nameIsValid(name) { + var n = String(name).trim(); + return n.length > 0 && n.length <= 32 && /^[A-Za-z0-9 _-]+$/.test(n); + } + + function profileByName(name) { + for (var i = 0; i < profiles.length; ++i) { + if (profiles[i].name === name) { + return profiles[i]; + } + } + return null; + } + + // --- hardware / CLI --------------------------------------------------- + + // Live preview while the editor is open; snapshotted by "Save". + function applyPreview() { + if (loading) { + return; + } + previewTimer.stop(); + executable.run(vrgbBin + " set " + toHex(editorColor) + " " + editorPercent); + } + + function schedulePreview() { + if (!loading) { + previewTimer.restart(); + } + } + + function loadProfile(name) { + executable.run(vrgbBin + " profile load " + shellQuote(name)); + } + + function saveProfile(name) { + // `profile save` snapshots the current state, so set it first. + executable.run(vrgbBin + " set " + toHex(editorColor) + " " + editorPercent + + " && " + vrgbBin + " profile save " + shellQuote(name)); + } + + function deleteProfile(name) { + executable.run(vrgbBin + " profile delete " + shellQuote(name)); + } + + function seedDefaults() { + var parts = []; + for (var i = 0; i < defaultPalette.length; ++i) { + var p = defaultPalette[i]; + parts.push(vrgbBin + " set " + p.color + " 100"); + parts.push(vrgbBin + " profile save " + shellQuote(p.name)); + } + // Put the keys back where they were rather than leaving them on the + // last seeded colour. + parts.push(vrgbBin + " set " + hexColor + " " + currentPercent); + executable.run(parts.join(" && ")); + } + + // --- editor ----------------------------------------------------------- + + function openEditor(name) { + var p = profileByName(name); + if (!p) { + return; + } + editorNew = false; + editorName = p.name; + editorColor = "#" + p.color; + editorPercent = p.percent; + page = "editor"; + } + + function openNewEditor() { + editorNew = true; + editorName = ""; + editorColor = currentColor; + editorPercent = currentPercent; + page = "editor"; + } + + function closeEditor() { + previewTimer.stop(); + page = "grid"; + } + + // --- config ----------------------------------------------------------- + + function loadConfig(text) { + var cfg = null; + if (text.length > 0) { + try { + cfg = JSON.parse(text); + } catch (e) { + cfg = null; + } + } + + var list = []; + if (cfg) { + if (typeof cfg.color === "string" && /^[0-9a-fA-F]{6}$/.test(cfg.color)) { + currentColor = "#" + cfg.color; + } + var pc = parseInt(cfg.percent, 10); + if (!isNaN(pc)) { + currentPercent = Math.max(0, Math.min(100, pc)); + } + if (cfg.profiles && typeof cfg.profiles === "object") { + for (var name in cfg.profiles) { + var p = cfg.profiles[name]; + if (!p || !/^[0-9a-fA-F]{6}$/.test(String(p.color))) { + continue; + } + var pp = parseInt(p.percent, 10); + list.push({ + name: name, + color: String(p.color), + percent: isNaN(pp) ? 100 : Math.max(0, Math.min(100, pp)) + }); + } + } + } + list.sort(function (a, b) { return a.name.localeCompare(b.name); }); + profiles = list; + loading = false; + + // First run: turn the old hardcoded palette into real profiles so the + // grid is not empty. Guarded by applet config, otherwise deleting every + // profile would resurrect them on the next start. + if (list.length === 0 && !Plasmoid.configuration.seededDefaults) { + Plasmoid.configuration.seededDefaults = true; + seedDefaults(); + } + } + + function handleResult(source, code, out, err) { + if (source === resolveCmd) { + if (out.length > 0) { + vrgbBin = out.split("\n")[0]; + } + executable.run(readCfgCmd); + return; + } + if (source === readCfgCmd) { + loadConfig(out); + return; + } + // Any other command was a write. + if (code === 0) { + errorText = ""; + executable.run(readCfgCmd); + } else { + errorText = err.length > 0 ? err : i18n("vrgb exited with code %1", code); + } + } + + Timer { + id: previewTimer + // vrgb returns in ~25 ms; 60 ms keeps a drag smooth without queueing up + // one process per mouse move. + interval: 60 + onTriggered: root.applyPreview() + } + + Timer { + id: clickTimer + property string pendingName: "" + // Must be at least the system double-click interval, or the single-click + // load would fire before the second click is recognised. + interval: (Qt.styleHints && Qt.styleHints.mouseDoubleClickInterval) + ? Qt.styleHints.mouseDoubleClickInterval : 400 + onTriggered: { + if (pendingName.length > 0) { + root.loadProfile(pendingName); + pendingName = ""; + } + } + } + + KeyboardBrightnessControl { + id: kbdBacklight + // The popup shows the level itself; a second OSD on every drag step + // would just be noise. + isSilent: true + } + + P5Support.DataSource { + id: executable + + engine: "executable" + connectedSources: [] + + onNewData: (source, data) => { + var code = data["exit code"]; + var out = (data["stdout"] || "").trim(); + var err = (data["stderr"] || "").trim(); + disconnectSource(source); + root.handleResult(source, code, out, err); + } + + function run(cmd) { + // The engine keys sources by command string, so re-running an + // identical command needs an explicit disconnect first. + if (connectedSources.indexOf(cmd) !== -1) { + disconnectSource(cmd); + } + connectSource(cmd); + } + } + + Component.onCompleted: executable.run(resolveCmd) + + compactRepresentation: MouseArea { + onClicked: root.expanded = !root.expanded + + Kirigami.Icon { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + // The symbolic variant keeps its key detail when masked; plain + // input-keyboard flattens to a solid block. + source: "input-keyboard-symbolic" + isMask: true + color: root.backlight > 0 ? root.currentColor : Kirigami.Theme.disabledTextColor + } + } + + fullRepresentation: Item { + id: fullRep + + readonly property int pad: Kirigami.Units.largeSpacing + // Height follows the content rather than a fixed guess, so the grid + // page is not padded out to the (much taller) editor page. Anchoring + // the content left/right/top only -- never filling -- keeps + // content.implicitHeight independent of this item's own height, so the + // binding cannot become circular. + readonly property int contentHeight: content.implicitHeight + 2 * pad + + Layout.minimumWidth: Kirigami.Units.gridUnit * 15 + Layout.preferredWidth: Kirigami.Units.gridUnit * 17 + Layout.minimumHeight: contentHeight + Layout.preferredHeight: contentHeight + Layout.maximumHeight: contentHeight + + ColumnLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: fullRep.pad + spacing: Kirigami.Units.smallSpacing + + // --- header --------------------------------------------------- + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing + + PlasmaComponents.ToolButton { + visible: root.page === "editor" + icon.name: "draw-arrow-back" + display: PlasmaComponents.ToolButton.IconOnly + text: i18n("Back") + onClicked: root.closeEditor() + } + + Rectangle { + visible: root.page === "grid" + Layout.preferredWidth: Kirigami.Units.gridUnit * 2.5 + Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5 + radius: 4 + color: root.litColor + border.width: 1 + border.color: Qt.rgba(0, 0, 0, 0.3) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Kirigami.Heading { + Layout.fillWidth: true + level: 4 + elide: Text.ElideRight + text: root.page === "editor" + ? (root.editorNew ? i18n("New profile") : root.editorName) + : i18n("Keyboard Lighting") + } + + PlasmaComponents.Label { + Layout.fillWidth: true + visible: root.page === "grid" + elide: Text.ElideRight + font.family: root.hoveredName.length > 0 + ? Kirigami.Theme.defaultFont.family : "monospace" + opacity: 0.75 + text: root.hoveredName.length > 0 + ? root.hoveredName + : "#" + root.hexColor.toUpperCase() + } + } + } + + Kirigami.Separator { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + } + + // --- pages ---------------------------------------------------- + StackLayout { + Layout.fillWidth: true + // A StackLayout's own implicit height is the tallest page, which + // is what left dead space under the grid. Report just the page + // actually on screen. + Layout.preferredHeight: root.page === "editor" ? editorPage.implicitHeight + : gridPage.implicitHeight + currentIndex: root.page === "editor" ? 1 : 0 + + // page 0: profile grid + ColumnLayout { + id: gridPage + + spacing: Kirigami.Units.smallSpacing + + GridLayout { + Layout.fillWidth: true + columns: 6 + columnSpacing: Kirigami.Units.smallSpacing + rowSpacing: Kirigami.Units.smallSpacing + + Repeater { + model: root.profiles + + delegate: Rectangle { + id: swatch + + required property var modelData + // Marks whichever profile matches the live colour. + // Colours need colorEqual; === compares wrappers. + readonly property bool isCurrent: + Qt.colorEqual(root.currentColor, "#" + modelData.color) + + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 1.3 + radius: 3 + color: "#" + modelData.color + border.width: isCurrent ? 2 : 1 + border.color: isCurrent ? Kirigami.Theme.highlightColor + : Qt.rgba(0, 0, 0, 0.3) + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.hoveredName = swatch.modelData.name + onExited: { + if (root.hoveredName === swatch.modelData.name) { + root.hoveredName = ""; + } + } + // A double click also emits two clicks, so + // defer the load and cancel it if a second + // click arrives. + onClicked: { + clickTimer.pendingName = swatch.modelData.name; + clickTimer.restart(); + } + onDoubleClicked: { + clickTimer.stop(); + clickTimer.pendingName = ""; + root.openEditor(swatch.modelData.name); + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 1.3 + radius: 3 + color: "transparent" + border.width: 1 + border.color: Kirigami.Theme.disabledTextColor + + Kirigami.Icon { + anchors.centerIn: parent + width: Kirigami.Units.iconSizes.small + height: width + source: "list-add" + isMask: true + color: Kirigami.Theme.textColor + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.hoveredName = i18n("Add profile") + onExited: root.hoveredName = "" + onClicked: root.openNewEditor() + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing + + PlasmaComponents.Label { + text: i18n("Brightness") + } + + Item { + Layout.fillWidth: true + } + + PlasmaComponents.Label { + opacity: 0.75 + text: root.backlight + " / " + root.backlightMax + } + } + + PlasmaComponents.Slider { + id: backlightSlider + + Layout.fillWidth: true + enabled: kbdBacklight.isBrightnessAvailable + from: 0 + to: root.backlightMax + stepSize: 1 + snapMode: PlasmaComponents.Slider.SnapAlways + + // Straight to Plasma: this is a D-Bus property rather + // than a subprocess, so it needs no throttling. + onMoved: kbdBacklight.brightness = Math.round(value) + } + + // Follows Plasma -- Fn keys, the brightness applet, anything + // else -- whenever the user is not dragging. Depending on + // backlightMax as well as backlight matters: PowerDevil + // publishes the maximum after the current value on startup, + // and without that dependency the slider can stay clamped + // against a stale range. + Binding { + target: backlightSlider + property: "value" + value: Math.min(root.backlight, root.backlightMax) + when: !backlightSlider.pressed + restoreMode: Binding.RestoreNone + } + } + + // page 1: profile editor + ColumnLayout { + id: editorPage + + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents.TextField { + id: nameField + + Layout.fillWidth: true + visible: root.editorNew + placeholderText: i18n("Profile name") + maximumLength: 32 + // Seeded by onPageChanged, kept one-way so typing does + // not fight a binding. + onTextChanged: root.editorName = text + } + + ColorPicker { + id: picker + + Layout.fillWidth: true + + onPicked: c => { + root.editorColor = c; + root.schedulePreview(); + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing + + PlasmaComponents.Label { + text: i18n("Brightness") + } + + Item { + Layout.fillWidth: true + } + + PlasmaComponents.Label { + opacity: 0.75 + text: root.editorPercent + "%" + } + } + + PlasmaComponents.Slider { + id: percentSlider + + Layout.fillWidth: true + from: 0 + to: 100 + stepSize: 1 + + onMoved: { + root.editorPercent = Math.round(value); + root.schedulePreview(); + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents.Button { + visible: !root.editorNew + icon.name: "edit-delete" + text: i18n("Delete") + onClicked: { + root.deleteProfile(root.editorName); + root.closeEditor(); + } + } + + Item { + Layout.fillWidth: true + } + + PlasmaComponents.Button { + icon.name: "document-save" + text: i18n("Save") + enabled: root.nameIsValid(root.editorName) + onClicked: { + root.saveProfile(root.editorName.trim()); + root.closeEditor(); + } + } + } + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: root.errorText + visible: root.errorText.length > 0 + } + } + + // Seed the editor widgets whenever a profile is opened. They live in a + // StackLayout page that is built once, so this cannot be a + // Component.onCompleted. + Connections { + target: root + + function onPageChanged() { + if (root.page === "editor") { + picker.setColor(root.editorColor); + percentSlider.value = root.editorPercent; + nameField.text = root.editorName; + } + } + } + } +} diff --git a/plasmoid/package/metadata.json b/plasmoid/package/metadata.json new file mode 100644 index 0000000..466ef4f --- /dev/null +++ b/plasmoid/package/metadata.json @@ -0,0 +1,21 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Authors": [ + { + "Name": "vrgb-dev" + } + ], + "Category": "Utilities", + "Description": "Set the colour and brightness of ASUS Vivobook HID LampArray keyboards", + "Description[uk]": "Керування кольором і яскравістю клавіатур ASUS Vivobook з HID LampArray", + "Icon": "input-keyboard", + "Id": "org.vrgb.keyboard", + "License": "MIT", + "Name": "VRGB Keyboard Lighting", + "Name[uk]": "Підсвічування клавіатури VRGB", + "Version": "0.1.0", + "Website": "https://github.com/vrgb-dev/vrgb" + }, + "X-Plasma-API-Minimum-Version": "6.0" +} diff --git a/plasmoid/po/plasma_applet_org.vrgb.keyboard.pot b/plasmoid/po/plasma_applet_org.vrgb.keyboard.pot new file mode 100644 index 0000000..3dbdfef --- /dev/null +++ b/plasmoid/po/plasma_applet_org.vrgb.keyboard.pot @@ -0,0 +1,62 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the VRGB Keyboard Lighting package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: VRGB Keyboard Lighting\n" +"Report-Msgid-Bugs-To: https://github.com/vrgb-dev/vrgb/issues\n" +"POT-Creation-Date: 2026-09-03 22:58+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: package/contents/ui/main.qml:77 package/contents/ui/main.qml:367 +msgid "Colour" +msgstr "" + +#: package/contents/ui/main.qml:78 +msgid "Reading configuration…" +msgstr "" + +#: package/contents/ui/main.qml:79 +msgid "#%1 at %2%" +msgstr "" + +#: package/contents/ui/main.qml:248 +msgid "vrgb exited with code %1" +msgstr "" + +#: package/contents/ui/main.qml:343 +msgid "Back" +msgstr "" + +#: package/contents/ui/main.qml:366 +msgid "New profile" +msgstr "" + +#: package/contents/ui/main.qml:471 +msgid "Add profile" +msgstr "" + +#: package/contents/ui/main.qml:486 package/contents/ui/main.qml:561 +msgid "Brightness" +msgstr "" + +#: package/contents/ui/main.qml:538 +msgid "Profile name" +msgstr "" + +#: package/contents/ui/main.qml:599 +msgid "Delete" +msgstr "" + +#: package/contents/ui/main.qml:612 +msgid "Save" +msgstr "" diff --git a/plasmoid/po/uk.po b/plasmoid/po/uk.po new file mode 100644 index 0000000..00f5461 --- /dev/null +++ b/plasmoid/po/uk.po @@ -0,0 +1,62 @@ +# Ukrainian translation for the VRGB Plasma applet. +# This file is distributed under the same license as the VRGB package. +# SPDX-License-Identifier: MIT +# +msgid "" +msgstr "" +"Project-Id-Version: VRGB Keyboard Lighting\n" +"Report-Msgid-Bugs-To: https://github.com/vrgb-dev/vrgb/issues\n" +"POT-Creation-Date: 2026-09-03 22:58+0300\n" +"PO-Revision-Date: 2026-09-03 22:10+0300\n" +"Last-Translator: vrgb-dev\n" +"Language-Team: Ukrainian\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=n==1 ? 3 : n%10==1 && n%100!=11 ? 0 : " +"n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" + +#: package/contents/ui/main.qml:77 package/contents/ui/main.qml:367 +msgid "Colour" +msgstr "Колір" + +#: package/contents/ui/main.qml:78 +msgid "Reading configuration…" +msgstr "Читання налаштувань…" + +#: package/contents/ui/main.qml:79 +msgid "#%1 at %2%" +msgstr "#%1, яскравість %2%" + +#: package/contents/ui/main.qml:248 +msgid "vrgb exited with code %1" +msgstr "vrgb завершив роботу з кодом %1" + +#: package/contents/ui/main.qml:343 +msgid "Back" +msgstr "Назад" + +#: package/contents/ui/main.qml:366 +msgid "New profile" +msgstr "Новий профіль" + +#: package/contents/ui/main.qml:471 +msgid "Add profile" +msgstr "Додати профіль" + +#: package/contents/ui/main.qml:486 package/contents/ui/main.qml:561 +msgid "Brightness" +msgstr "Яскравість" + +#: package/contents/ui/main.qml:538 +msgid "Profile name" +msgstr "Назва профілю" + +#: package/contents/ui/main.qml:599 +msgid "Delete" +msgstr "Вилучити" + +#: package/contents/ui/main.qml:612 +msgid "Save" +msgstr "Зберегти" diff --git a/plasmoid/uninstall.sh b/plasmoid/uninstall.sh new file mode 100755 index 0000000..9731285 --- /dev/null +++ b/plasmoid/uninstall.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Remove the VRGB Plasma 6 applet for the current user. +set -e + +APPLET_ID="org.vrgb.keyboard" +APPLET_DIR="$HOME/.local/share/plasma/plasmoids/$APPLET_ID" + +echo "VRGB Widget Uninstaller" +echo "-----------------------" + +echo "[1/2] Removing Plasma applet..." + +if ! command -v kpackagetool6 >/dev/null 2>&1; then + echo "kpackagetool6 not found - nothing to remove." + exit 0 +fi + +if kpackagetool6 --type Plasma/Applet --list 2>/dev/null | grep -qx "$APPLET_ID"; then + kpackagetool6 --type Plasma/Applet --remove "$APPLET_ID" + echo "Removed $APPLET_ID" +else + echo "Widget not installed. Skipping." +fi + +echo "[2/2] Checking for leftovers..." + +# kpackagetool6 normally clears this itself; catch a half-removed package. +if [ -d "$APPLET_DIR" ]; then + rm -rf "$APPLET_DIR" + echo "Removed leftover package directory." +else + echo "No leftover directory. Skipping." +fi + +echo +echo "Uninstall complete." +echo +echo "Note:" +echo "If the widget was on a panel, restart Plasma to clear its placeholder:" +echo +echo " systemctl --user restart plasma-plasmashell" +echo +echo "Your vrgb profiles were NOT removed. They belong to vrgb, not the widget," +echo "and remain available from the CLI:" +echo +echo " vrgb profile list" +echo +echo "The vrgb CLI itself is removed by the uninstaller one level up (../uninstall.sh)."