Merge pull request #706 from 3akev/main

KeyboardLayout: update on "keyboard layout changed" events
This commit is contained in:
Lemmy
2025-11-11 06:28:25 -05:00
committed by GitHub
4 changed files with 185 additions and 182 deletions
+72 -1
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
@@ -43,6 +44,7 @@ Item {
safeUpdateWorkspaces()
safeUpdateWindows()
queryDisplayScales()
queryKeyboardLayout()
})
initialized = true
Logger.i("HyprlandService", "Service started")
@@ -56,7 +58,6 @@ Item {
hyprlandMonitorsProcess.running = true
}
// Hyprland monitors process for display scale detection
// Hyprland monitors process for display scale detection
Process {
id: hyprlandMonitorsProcess
@@ -112,6 +113,50 @@ Item {
}
}
}
function queryKeyboardLayout() {
hyprlandDevicesProcess.running = true
}
// Hyprland devices process for keyboard layout detection
Process {
id: hyprlandDevicesProcess
running: false
command: ["hyprctl", "devices", "-j"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
// Accumulate lines instead of parsing each one
hyprlandDevicesProcess.accumulatedOutput += line
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("HyprlandService", "Failed to query devices, exit code:", exitCode)
accumulatedOutput = ""
return
}
try {
const devicesData = JSON.parse(accumulatedOutput)
for (const keyboard of devicesData.keyboards) {
if (keyboard.main) {
const layoutName = keyboard.active_keymap
KeyboardLayoutService.setCurrentLayout(layoutName)
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName)
}
}
} catch (e) {
Logger.e("HyprlandService", "Failed to parse devices:", e)
} finally {
// Clear accumulated output for next query
accumulatedOutput = ""
}
}
}
// Safe update wrapper
function safeUpdate() {
safeUpdateWindows()
@@ -330,6 +375,27 @@ Item {
return defaultValue
}
function handleActiveLayoutEvent(ev) {
try {
let beforeParenthesis
const parenthesisPos = ev.lastIndexOf('(')
if (parenthesisPos === -1) {
beforeParenthesis = ev
} else {
beforeParenthesis = ev.substring(0, parenthesisPos)
}
const layoutNameStart = beforeParenthesis.lastIndexOf(',') + 1
const layoutName = ev.substring(layoutNameStart)
KeyboardLayoutService.setCurrentLayout(layoutName)
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName)
} catch (e) {
Logger.e("HyprlandService", "Error handling activelayout:", e)
}
}
// Connections to Hyprland
Connections {
target: Hyprland.workspaces
@@ -358,9 +424,14 @@ Item {
updateTimer.restart()
const monitorsEvents = ["configreloaded", "monitoradded", "monitorremoved", "monitoraddedv2", "monitorremovedv2"]
if (monitorsEvents.includes(event.name)) {
Qt.callLater(queryDisplayScales)
}
if (event.name == "activelayout") {
handleActiveLayoutEvent(event.data)
}
}
}
+28
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
@@ -16,6 +17,8 @@ Item {
property bool overviewActive: false
property var keyboardLayouts: []
// Signals that match the facade interface
signal workspaceChanged
signal activeWindowChanged
@@ -191,6 +194,10 @@ Item {
queryDisplayScales()
} else if (event.ConfigLoaded) {
queryDisplayScales()
} else if (event.KeyboardLayoutsChanged) {
handleKeyboardLayoutsChanged(event.KeyboardLayoutsChanged)
} else if (event.KeyboardLayoutSwitched) {
handleKeyboardLayoutSwitched(event.KeyboardLayoutSwitched)
}
} catch (e) {
Logger.e("NiriService", "Error parsing event stream:", e, data)
@@ -391,6 +398,27 @@ Item {
}
}
function handleKeyboardLayoutsChanged(eventData) {
try {
keyboardLayouts = eventData.keyboard_layouts.names
const layoutName = keyboardLayouts[eventData.keyboard_layouts.current_idx]
KeyboardLayoutService.setCurrentLayout(layoutName)
Logger.d("NiriService", "Keyboard layouts changed:", keyboardLayouts.toString())
} catch (e) {
Logger.e("NiriService", "Error handling keyboardLayoutsChanged:", e)
}
}
function handleKeyboardLayoutSwitched(eventData) {
try {
const layoutName = keyboardLayouts[eventData.idx]
KeyboardLayoutService.setCurrentLayout(layoutName)
Logger.d("NiriService", "Keyboard layout switched:", layoutName)
} catch (e) {
Logger.e("NiriService", "Error handling KeyboardLayoutSwitched:", e)
}
}
// Public functions
function switchToWorkspace(workspace) {
try {
+82
View File
@@ -4,6 +4,7 @@ import Quickshell.I3
import Quickshell.Wayland
import Quickshell.Io
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
@@ -37,10 +38,12 @@ Item {
try {
I3.refreshWorkspaces()
I3.dispatch('(["input"])')
Qt.callLater(() => {
safeUpdateWorkspaces()
safeUpdateWindows()
queryDisplayScales()
queryKeyboardLayout()
})
initialized = true
Logger.i("SwayService", "Service started")
@@ -109,6 +112,60 @@ Item {
}
}
Timer {
id: keyboardLayoutUpdateTimer
interval: 1000
running: true
repeat: true
onTriggered: {
queryKeyboardLayout()
}
}
function queryKeyboardLayout() {
swayInputsProcess.running = true
}
// Sway inputs process for keyboard layout detection
Process {
id: swayInputsProcess
running: false
command: ["swaymsg", "-t", "get_inputs", "-r"]
property string accumulatedOutput: ""
stdout: SplitParser {
onRead: function (line) {
// Accumulate lines instead of parsing each one
swayInputsProcess.accumulatedOutput += line
}
}
onExited: function (exitCode) {
if (exitCode !== 0 || !accumulatedOutput) {
Logger.e("SwayService", "Failed to query inputs, exit code:", exitCode)
accumulatedOutput = ""
return
}
try {
const inputsData = JSON.parse(accumulatedOutput)
for (const input of inputsData) {
if (input.type == "keyboard") {
const layoutName = input.xkb_active_layout_name
KeyboardLayoutService.setCurrentLayout(layoutName)
Logger.d("SwayService", "Keyboard layout switched:", layoutName)
break
}
}
} catch (e) {
Logger.e("SwayService", "Failed to parse inputs:", e)
} finally {
// Clear accumulated output for next query
accumulatedOutput = ""
}
}
}
// Safe update wrapper
function safeUpdate() {
safeUpdateWindows()
@@ -234,6 +291,27 @@ Item {
return defaultValue
}
function handleInputEvent(ev) {
try {
let beforeParenthesis
const parenthesisPos = ev.lastIndexOf('(')
if (parenthesisPos === -1) {
beforeParenthesis = ev
} else {
beforeParenthesis = ev.substring(0, parenthesisPos)
}
const layoutNameStart = beforeParenthesis.lastIndexOf(',') + 1
const layoutName = ev.substring(layoutNameStart)
KeyboardLayoutService.setCurrentLayout(layoutName)
Logger.d("HyprlandService", "Keyboard layout switched:", layoutName)
} catch (e) {
Logger.e("HyprlandService", "Error handling activelayout:", e)
}
}
// Connections to I3
Connections {
target: I3.workspaces
@@ -263,6 +341,10 @@ Item {
if (event.type === "output") {
Qt.callLater(queryDisplayScales)
}
if (event.type == "get_inputs") {
handleInputEvent(event.data)
}
}
}
+3 -181
View File
@@ -12,162 +12,10 @@ Singleton {
property string currentLayout: I18n.tr("system.unknown-layout")
property string previousLayout: ""
property bool isInitialized: false
property int updateInterval: 1000 // Update every second
// Timer to periodically update the layout
Timer {
id: updateTimer
interval: updateInterval
running: true
repeat: true
onTriggered: {
updateLayout()
}
}
// Process to get current keyboard layout using niri msg (Wayland native)
Process {
id: niriLayoutProcess
running: false
command: ["niri", "msg", "-j", "keyboard-layouts"]
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text)
const layoutName = data.names[data.current_idx]
root.currentLayout = extractLayoutCode(layoutName)
} catch (e) {
root.currentLayout = I18n.tr("system.unknown-layout")
}
}
}
}
// Process to get current keyboard layout using hyprctl (Hyprland)
Process {
id: hyprlandLayoutProcess
running: false
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text)
// Find the main keyboard and get its active keymap
const mainKeyboard = data.keyboards.find(kb => kb.main === true)
if (mainKeyboard && mainKeyboard.active_keymap) {
root.currentLayout = extractLayoutCode(mainKeyboard.active_keymap)
} else {
root.currentLayout = I18n.tr("system.unknown-layout")
}
} catch (e) {
root.currentLayout = I18n.tr("system.unknown-layout")
}
}
}
}
// Process for X11 systems using setxkbmap
Process {
id: x11LayoutProcess
running: false
command: ["setxkbmap", "-query"]
stdout: StdioCollector {
onStreamFinished: {
try {
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('layout:')) {
const layout = line.split(':')[1].trim()
root.currentLayout = layout
return
}
}
root.currentLayout = I18n.tr("system.unknown-layout")
} catch (e) {
root.currentLayout = I18n.tr("system.unknown-layout")
}
}
}
}
// Process for general Wayland using localectl (systemd)
Process {
id: localectlProcess
running: false
command: ["localectl", "status"]
stdout: StdioCollector {
onStreamFinished: {
try {
const lines = text.split('\n')
for (const line of lines) {
if (line.includes("X11 Layout:")) {
const layout = line.split(':')[1].trim()
if (layout && layout !== "n/a") {
root.currentLayout = layout
return
}
}
if (line.includes("VC Keymap:")) {
const keymap = line.split(':')[1].trim()
if (keymap && keymap !== "n/a") {
root.currentLayout = extractLayoutCode(keymap)
return
}
}
}
root.currentLayout = I18n.tr("system.unknown-layout")
} catch (e) {
root.currentLayout = I18n.tr("system.unknown-layout")
}
}
}
}
// Process for generic keyboard layout detection using gsettings (GNOME-based)
Process {
id: gsettingsProcess
running: false
command: ["gsettings", "get", "org.gnome.desktop.input-sources", "current"]
stdout: StdioCollector {
onStreamFinished: {
try {
const currentIndex = parseInt(text.trim())
gsettingsSourcesProcess.running = true
} catch (e) {
fallbackToLocalectl()
}
}
}
}
Process {
id: gsettingsSourcesProcess
running: false
command: ["gsettings", "get", "org.gnome.desktop.input-sources", "sources"]
stdout: StdioCollector {
onStreamFinished: {
try {
// Parse the sources array and extract layout codes
const sourcesText = text.trim()
const matches = sourcesText.match(/\('xkb', '([^']+)'\)/g)
if (matches && matches.length > 0) {
// Get the first layout as default
const layoutMatch = matches[0].match(/\('xkb', '([^']+)'\)/)
if (layoutMatch) {
root.currentLayout = layoutMatch[1].split('+')[0] // Take first part before any variants
}
} else {
fallbackToLocalectl()
}
} catch (e) {
fallbackToLocalectl()
}
}
}
}
function fallbackToLocalectl() {
localectlProcess.running = true
// Updates current layout from various format strings. Called by compositors
function setCurrentLayout(layoutString) {
root.currentLayout = extractLayoutCode(layoutString)
}
// Extract layout code from various format strings using Commons data
@@ -222,7 +70,6 @@ Singleton {
Component.onCompleted: {
Logger.i("KeyboardLayout", "Service started")
updateLayout()
// Mark as initialized after a delay to allow first layout update to complete
// This prevents showing a toast on the initial load
initializationTimer.start()
@@ -239,31 +86,6 @@ Singleton {
}
}
function updateLayout() {
// Try compositor-specific methods first
if (CompositorService.isHyprland) {
hyprlandLayoutProcess.running = true
} else if (CompositorService.isNiri) {
niriLayoutProcess.running = true
} else {
// Try detection methods in order of preference
if (Qt.platform.os === "linux") {
// Check if we're in X11 or Wayland
const sessionType = Qt.application.arguments.find(arg => arg.includes("QT_QPA_PLATFORM")) || process.env.XDG_SESSION_TYPE
if (sessionType && sessionType.includes("xcb") || process.env.DISPLAY) {
// X11 system
x11LayoutProcess.running = true
} else {
// Wayland or unknown - try gsettings first, then localectl
gsettingsProcess.running = true
}
} else {
currentLayout = I18n.tr("system.unknown-layout")
}
}
}
// Comprehensive language name to ISO code mapping
property var languageMap: {
"english"// English variants