summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--config/BarConfig.qml1
-rw-r--r--config/UtilitiesConfig.qml1
-rw-r--r--modules/bar/popouts/Content.qml7
-rw-r--r--modules/bar/popouts/KbLayout.qml28
-rw-r--r--modules/bar/popouts/kblayout/KbLayout.qml158
-rw-r--r--modules/bar/popouts/kblayout/KbLayoutModel.qml200
7 files changed, 367 insertions, 29 deletions
diff --git a/README.md b/README.md
index 82f4c73..92bfabd 100644
--- a/README.md
+++ b/README.md
@@ -623,6 +623,7 @@ default, you must create it manually.
"dndChanged": true,
"gameModeChanged": true,
"kbLayoutChanged": true,
+ "kbLimit": true,
"numLockChanged": true,
"vpnChanged": true,
"nowPlaying": false
diff --git a/config/BarConfig.qml b/config/BarConfig.qml
index a0ce4be..8226d9e 100644
--- a/config/BarConfig.qml
+++ b/config/BarConfig.qml
@@ -111,5 +111,6 @@ JsonObject {
property int trayMenuWidth: 300
property int batteryWidth: 250
property int networkWidth: 320
+ property int kbLayoutWidth: 320
}
}
diff --git a/config/UtilitiesConfig.qml b/config/UtilitiesConfig.qml
index 5779d88..cf46446 100644
--- a/config/UtilitiesConfig.qml
+++ b/config/UtilitiesConfig.qml
@@ -23,6 +23,7 @@ JsonObject {
property bool capsLockChanged: true
property bool numLockChanged: true
property bool kbLayoutChanged: true
+ property bool kbLimit: true
property bool vpnChanged: true
property bool nowPlaying: false
}
diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml
index da993fa..c9a7c5d 100644
--- a/modules/bar/popouts/Content.qml
+++ b/modules/bar/popouts/Content.qml
@@ -6,6 +6,8 @@ import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
+import "./kblayout"
+
Item {
id: root
@@ -114,9 +116,12 @@ Item {
Popout {
name: "kblayout"
- sourceComponent: KbLayout {}
+ sourceComponent: KbLayout {
+ wrapper: root.wrapper
+ }
}
+
Popout {
name: "lockstatus"
sourceComponent: LockStatus {}
diff --git a/modules/bar/popouts/KbLayout.qml b/modules/bar/popouts/KbLayout.qml
deleted file mode 100644
index ace5af2..0000000
--- a/modules/bar/popouts/KbLayout.qml
+++ /dev/null
@@ -1,28 +0,0 @@
-import qs.components
-import qs.components.controls
-import qs.services
-import qs.config
-import Quickshell
-import QtQuick.Layouts
-
-ColumnLayout {
- id: root
-
- spacing: Appearance.spacing.normal
-
- StyledText {
- Layout.topMargin: Appearance.padding.normal
- Layout.rightMargin: Appearance.padding.normal
- text: qsTr("Keyboard layout: %1").arg(Hypr.kbLayoutFull)
- font.weight: 500
- }
-
- TextButton {
- Layout.bottomMargin: Appearance.padding.normal
- Layout.rightMargin: Appearance.padding.normal
- Layout.fillWidth: true
-
- text: qsTr("Switch layout")
- onClicked: Hypr.extras.message("switchxkblayout all next")
- }
-}
diff --git a/modules/bar/popouts/kblayout/KbLayout.qml b/modules/bar/popouts/kblayout/KbLayout.qml
new file mode 100644
index 0000000..f612f58
--- /dev/null
+++ b/modules/bar/popouts/kblayout/KbLayout.qml
@@ -0,0 +1,158 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+
+import "."
+
+ColumnLayout {
+ id: root
+
+ required property Item wrapper
+
+ spacing: Appearance.spacing.small
+ width: Config.bar.sizes.kbLayoutWidth
+
+ KbLayoutModel { id: kb }
+
+ function refresh() { kb.refresh() }
+ Component.onCompleted: kb.start()
+
+ StyledText {
+ Layout.topMargin: Appearance.padding.normal
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("Keyboard Layouts")
+ font.weight: 500
+ }
+
+ ListView {
+ id: list
+ model: kb.visibleModel
+
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ Layout.topMargin: Appearance.spacing.small
+
+ clip: true
+ interactive: true
+ implicitHeight: Math.min(contentHeight, 320)
+ visible: kb.visibleModel.count > 0
+ spacing: Appearance.spacing.small
+
+ add: Transition {
+ NumberAnimation { properties: "opacity"; from: 0; to: 1; duration: 140 }
+ NumberAnimation { properties: "y"; duration: 180; easing.type: Easing.OutCubic }
+ }
+ remove: Transition { NumberAnimation { properties: "opacity"; to: 0; duration: 100 } }
+ move: Transition { NumberAnimation { properties: "y"; duration: 180; easing.type: Easing.OutCubic } }
+ displaced: Transition { NumberAnimation { properties: "y"; duration: 180; easing.type: Easing.OutCubic } }
+
+ delegate: Item {
+ required property int layoutIndex
+ required property string label
+
+ width: list.width
+ height: Math.max(36, rowText.implicitHeight + Appearance.padding.small * 2)
+
+ readonly property bool isDisabled: layoutIndex > 3
+
+ StateLayer {
+ id: layer
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ implicitHeight: parent.height - 4
+
+ radius: Appearance.rounding.full
+ enabled: !isDisabled
+
+ function onClicked(): void {
+ if (!isDisabled)
+ kb.switchTo(layoutIndex);
+ }
+ }
+
+ StyledText {
+ id: rowText
+ anchors.verticalCenter: layer.verticalCenter
+ anchors.left: layer.left
+ anchors.right: layer.right
+ anchors.leftMargin: Appearance.padding.small
+ anchors.rightMargin: Appearance.padding.small
+ text: label
+ elide: Text.ElideRight
+ opacity: isDisabled ? 0.4 : 1.0
+ }
+
+ ToolTip.visible: isDisabled && layer.containsMouse
+ ToolTip.text: "XKB limitation: maximum 4 layouts allowed"
+ }
+ }
+
+ Rectangle {
+ visible: kb.activeLabel.length > 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ Layout.topMargin: Appearance.spacing.small
+
+ height: 1
+ color: Colours.palette.m3onSurfaceVariant
+ opacity: 0.35
+ }
+
+ RowLayout {
+ id: activeRow
+
+ visible: kb.activeLabel.length > 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ Layout.topMargin: Appearance.spacing.small
+ spacing: Appearance.spacing.small
+
+ opacity: 1
+ scale: 1
+
+ MaterialIcon {
+ text: "keyboard"
+ color: Colours.palette.m3primary
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: kb.activeLabel
+ elide: Text.ElideRight
+ font.weight: 500
+ color: Colours.palette.m3primary
+ }
+
+ Connections {
+ target: kb
+ function onActiveLabelChanged() {
+ if (!activeRow.visible)
+ return;
+ popIn.restart();
+ }
+ }
+
+ SequentialAnimation {
+ id: popIn
+ running: false
+
+ ParallelAnimation {
+ NumberAnimation { target: activeRow; property: "opacity"; to: 0.0; duration: 70 }
+ NumberAnimation { target: activeRow; property: "scale"; to: 0.92; duration: 70 }
+ }
+
+ ParallelAnimation {
+ NumberAnimation { target: activeRow; property: "opacity"; to: 1.0; duration: 160; easing.type: Easing.OutCubic }
+ NumberAnimation { target: activeRow; property: "scale"; to: 1.0; duration: 220; easing.type: Easing.OutBack }
+ }
+ }
+ }
+}
diff --git a/modules/bar/popouts/kblayout/KbLayoutModel.qml b/modules/bar/popouts/kblayout/KbLayoutModel.qml
new file mode 100644
index 0000000..41e45b3
--- /dev/null
+++ b/modules/bar/popouts/kblayout/KbLayoutModel.qml
@@ -0,0 +1,200 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+
+import Quickshell
+import Quickshell.Io
+
+import qs.config
+import Caelestia
+
+Item {
+ id: model
+ visible: false
+
+ ListModel { id: _visibleModel }
+ property alias visibleModel: _visibleModel
+
+ property string activeLabel: ""
+ property int activeIndex: -1
+
+ function start() {
+ _xkbXmlBase.running = true;
+ _getKbLayoutOpt.running = true;
+ }
+
+ function refresh() {
+ _notifiedLimit = false;
+ _getKbLayoutOpt.running = true;
+ }
+
+ function switchTo(idx) {
+ _switchProc.command = ["hyprctl", "switchxkblayout", "all", String(idx)];
+ _switchProc.running = true;
+ }
+
+ ListModel { id: _layoutsModel }
+
+ property var _xkbMap: ({})
+ property bool _notifiedLimit: false
+
+ Process {
+ id: _xkbXmlBase
+ command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/base.xml"]
+ stdout: StdioCollector { onStreamFinished: _buildXmlMap(text) }
+ onRunningChanged: if (!running && (typeof exitCode !== "undefined") && exitCode !== 0) _xkbXmlEvdev.running = true
+ }
+
+ Process {
+ id: _xkbXmlEvdev
+ command: ["xmllint", "--xpath", "//layout/configItem[name and description]", "/usr/share/X11/xkb/rules/evdev.xml"]
+ stdout: StdioCollector { onStreamFinished: _buildXmlMap(text) }
+ }
+
+ function _buildXmlMap(xml) {
+ const map = {};
+
+ const re = /<name>\s*([^<]+?)\s*<\/name>[\s\S]*?<description>\s*([^<]+?)\s*<\/description>/g;
+
+ let m;
+ while ((m = re.exec(xml)) !== null) {
+ const code = (m[1] || "").trim();
+ const desc = (m[2] || "").trim();
+ if (!code || !desc) continue;
+ map[code] = _short(desc);
+ }
+
+ if (Object.keys(map).length === 0)
+ return;
+
+ _xkbMap = map;
+
+ if (_layoutsModel.count > 0) {
+ const tmp = [];
+ for (let i = 0; i < _layoutsModel.count; i++) {
+ const it = _layoutsModel.get(i);
+ tmp.push({ layoutIndex: it.layoutIndex, token: it.token, label: _pretty(it.token) });
+ }
+ _layoutsModel.clear();
+ tmp.forEach(t => _layoutsModel.append(t));
+ _fetchActiveLayouts.running = true;
+ }
+ }
+
+ function _short(desc) {
+ const m = desc.match(/^(.*)\((.*)\)$/);
+ if (!m) return desc;
+ const lang = m[1].trim();
+ const region = m[2].trim();
+ const code = (region.split(/[,\s-]/)[0] || region).slice(0, 2).toUpperCase();
+ return `${lang} (${code})`;
+ }
+
+ Process {
+ id: _getKbLayoutOpt
+ command: ["hyprctl", "-j", "getoption", "input:kb_layout"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const j = JSON.parse(text);
+ const raw = (j?.str || j?.value || "").toString().trim();
+ if (raw.length) {
+ _setLayouts(raw);
+ _fetchActiveLayouts.running = true;
+ return;
+ }
+ } catch (e) {}
+ _fetchLayoutsFromDevices.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: _fetchLayoutsFromDevices
+ command: ["hyprctl", "-j", "devices"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const dev = JSON.parse(text);
+ const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0];
+ const raw = (kb?.layout || "").trim();
+ if (raw.length) _setLayouts(raw);
+ } catch (e) {}
+ _fetchActiveLayouts.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: _fetchActiveLayouts
+ command: ["hyprctl", "-j", "devices"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const dev = JSON.parse(text);
+ const kb = dev?.keyboards?.find(k => k.main) || dev?.keyboards?.[0];
+ const idx = kb?.active_layout_index ?? -1;
+
+ activeIndex = idx >= 0 ? idx : -1;
+ activeLabel =
+ (idx >= 0 && idx < _layoutsModel.count)
+ ? _layoutsModel.get(idx).label
+ : "";
+ } catch (e) {
+ activeIndex = -1;
+ activeLabel = "";
+ }
+
+ _rebuildVisible();
+ }
+ }
+ }
+
+ Process {
+ id: _switchProc
+ onRunningChanged: if (!running) _fetchActiveLayouts.running = true
+ }
+
+ function _setLayouts(raw) {
+ const parts = raw.split(",").map(s => s.trim()).filter(Boolean);
+ _layoutsModel.clear();
+
+ const seen = new Set();
+ let idx = 0;
+
+ for (const p of parts) {
+ if (seen.has(p)) continue;
+ seen.add(p);
+ _layoutsModel.append({ layoutIndex: idx, token: p, label: _pretty(p) });
+ idx++;
+ }
+ }
+
+ function _rebuildVisible() {
+ _visibleModel.clear();
+
+ let arr = [];
+ for (let i = 0; i < _layoutsModel.count; i++)
+ arr.push(_layoutsModel.get(i));
+
+ arr = arr.filter(i => i.layoutIndex !== activeIndex);
+ arr.forEach(i => _visibleModel.append(i));
+
+ if (!Config.utilities.toasts.kbLimit)
+ return;
+
+ if (_layoutsModel.count > 4) {
+ Toaster.toast(
+ qsTr("Keyboard layout limit"),
+ qsTr("XKB supports only 4 layouts at a time"),
+ "warning"
+ );
+ }
+ }
+
+ function _pretty(token) {
+ const code = token.replace(/\(.*\)$/, "").trim();
+ if (_xkbMap[code]) return code.toUpperCase() + " - " + _xkbMap[code];
+ return code.toUpperCase() + " - " + code;
+ }
+}