Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion modules/bar/popouts/Content.qml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Quickshell
import Quickshell.Services.SystemTray
import QtQuick

import "./kblayout" as KbLayoutMod

Item {
id: root

Expand Down Expand Up @@ -57,7 +59,7 @@ Item {

Popout {
name: "kblayout"
sourceComponent: KbLayout {}
sourceComponent: KbLayoutMod.KbLayout {}
}

Popout {
Expand Down
29 changes: 1 addition & 28 deletions modules/bar/popouts/KbLayout.qml
Original file line number Diff line number Diff line change
@@ -1,28 +1 @@
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")
}
}
Removed and moved to the kblayout folder
118 changes: 118 additions & 0 deletions modules/bar/popouts/kblayout/KbLayout.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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 "."

ColumnLayout {
id: root
spacing: Appearance.spacing.normal

implicitWidth: content.implicitWidth
implicitHeight: content.implicitHeight

KbLayoutModel { id: kb }

function refresh() { kb.refresh() }

Component.onCompleted: kb.start()

Column {
id: content
spacing: Appearance.spacing.normal
Layout.fillWidth: true

StyledText {
text: qsTr("Keyboard Layouts")
font.weight: 600
padding: Appearance.padding.small
}

ListView {
id: list
model: kb.visibleModel
clip: true
interactive: true
implicitWidth: Math.max(240, contentWidth)
implicitHeight: Math.min(contentHeight, 320)
visible: kb.visibleModel.count > 0

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
color: Colours.palette.m3primary

enabled: !isDisabled
opacity: isDisabled ? 0.4 : 1.0

function onClicked(): void {
if (!isDisabled)
kb.switchTo(layoutIndex);
}
}

StyledText {
id: rowText
anchors.verticalCenter: layer.verticalCenter
anchors.left: layer.left
anchors.leftMargin: Appearance.padding.small
text: label
opacity: layer.opacity
}

ToolTip.visible: isDisabled && mouse.containsMouse
ToolTip.text: "XKB limitation: maximum 4 layouts allowed"

MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
enabled: false
}
}
}

Rectangle {
visible: kb.activeLabel.length > 0
width: parent.width
height: 1
color: Colours.palette.m3onSurfaceVariant
opacity: 1.0
}

Item {
visible: kb.activeLabel.length > 0
width: parent.width
height: Math.max(36, footerText.implicitHeight + Appearance.padding.small * 2)

StyledText {
id: footerText
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Appearance.padding.small
text: kb.activeLabel
opacity: 0.85
font.weight: 500
}
}
}
}
182 changes: 182 additions & 0 deletions modules/bar/popouts/kblayout/KbLayoutModel.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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 && 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 = /<configItem>[\s\S]*?<name>\s*([^<\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);
}
_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 (_layoutsModel.count > 4 && !_notifiedLimit) {
Toaster.toast(
qsTr("Keyboard layout limit"),
qsTr("XKB supports only 4 layouts at a time"),
"warning"
);
_notifiedLimit = true;
}
}

function _pretty(token) {
const code = token.replace(/\(.*\)$/, "");
if (_xkbMap[code]) return code.toUpperCase() + " - " + _xkbMap[code];
return code.toUpperCase() + " - " + code;
}
}