popouts/kblayout: revamp to be actually functional (#971)

* Replacement KbLayout.qml for keyboard layout management

* Add files via upload

* Update KbLayout source component import

* Remove KbLayout.qml and relocate to kblayout folder

The KbLayout.qml file has been removed and relocated to the kblayout folder.

* Code fixes, and added kbLimit config that toust would be dissableable

* Add 'kbLimit' to configuration options

Added the keyboard limit toast config to the README

* Update KbLayout.qml

* Update KbLayoutModel.qml

* Update BarConfig.qml

* Update Content.qml

* remove old file

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
This commit is contained in:
AleksElixir 2026-01-19 06:51:08 +02:00 committed by GitHub
parent 146aeb7e74
commit 60a567bbba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 367 additions and 29 deletions

View file

@ -623,6 +623,7 @@ default, you must create it manually.
"dndChanged": true, "dndChanged": true,
"gameModeChanged": true, "gameModeChanged": true,
"kbLayoutChanged": true, "kbLayoutChanged": true,
"kbLimit": true,
"numLockChanged": true, "numLockChanged": true,
"vpnChanged": true, "vpnChanged": true,
"nowPlaying": false "nowPlaying": false

View file

@ -111,5 +111,6 @@ JsonObject {
property int trayMenuWidth: 300 property int trayMenuWidth: 300
property int batteryWidth: 250 property int batteryWidth: 250
property int networkWidth: 320 property int networkWidth: 320
property int kbLayoutWidth: 320
} }
} }

View file

@ -23,6 +23,7 @@ JsonObject {
property bool capsLockChanged: true property bool capsLockChanged: true
property bool numLockChanged: true property bool numLockChanged: true
property bool kbLayoutChanged: true property bool kbLayoutChanged: true
property bool kbLimit: true
property bool vpnChanged: true property bool vpnChanged: true
property bool nowPlaying: false property bool nowPlaying: false
} }

View file

@ -6,6 +6,8 @@ import Quickshell
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import QtQuick import QtQuick
import "./kblayout"
Item { Item {
id: root id: root
@ -114,9 +116,12 @@ Item {
Popout { Popout {
name: "kblayout" name: "kblayout"
sourceComponent: KbLayout {} sourceComponent: KbLayout {
wrapper: root.wrapper
}
} }
Popout { Popout {
name: "lockstatus" name: "lockstatus"
sourceComponent: LockStatus {} sourceComponent: LockStatus {}

View file

@ -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")
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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;
}
}