diff --git a/Services/Compositor/HyprlandService.qml b/Services/Compositor/HyprlandService.qml index 46838bbf..f379c5a2 100644 --- a/Services/Compositor/HyprlandService.qml +++ b/Services/Compositor/HyprlandService.qml @@ -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) + } } } diff --git a/Services/Compositor/NiriService.qml b/Services/Compositor/NiriService.qml index 7eb3a6e0..c7f0db11 100644 --- a/Services/Compositor/NiriService.qml +++ b/Services/Compositor/NiriService.qml @@ -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 { diff --git a/Services/Compositor/SwayService.qml b/Services/Compositor/SwayService.qml index e5ac8f6e..582c331f 100644 --- a/Services/Compositor/SwayService.qml +++ b/Services/Compositor/SwayService.qml @@ -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) + } } } diff --git a/Services/Keyboard/KeyboardLayoutService.qml b/Services/Keyboard/KeyboardLayoutService.qml index 5db74868..c449d300 100644 --- a/Services/Keyboard/KeyboardLayoutService.qml +++ b/Services/Keyboard/KeyboardLayoutService.qml @@ -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