Files
Modus/services/keyboard_layout.py
T
sankalp 34cfb2f49e feat(root): add keyboard layouts, OSDWindow, and update services & utilities
Introduce a new OSDWindow implementation, expose keyboard layout configuration, and modernize dependency handling and helper utilities.

- **New Features**
  - Added `keyboard_layouts` list (`us`, `np`) to `config.json`
  - Created `OSDWindow` class and updated `main.py` to use it
  - Introduced new service modules (`battery`, `brightness`, `capslock`, `network`, `mpris`, `todo`, `modus`) and expanded `services/__init__.py`
  - Added `CommandResult` and `run_command` helper in `utils/functions.py`

- **Enhancements**
  - Upgraded numerous dependencies to newer versions and switched `fabric` to a git source
  - Replaced bare `except:` with `except Exception:` in `WiFiPasswordDialog`
  - Improved type hints (`NamedTuple`, `TypeVar`) and refactored JSON file helpers
  - Added property notifications for battery percentage, state, and presence

- **Bug Fixes**
  - Fixed incorrect return type annotation in `read_json_file`
  - Corrected tick‑handler logic to avoid double registration
  - Properly notify listeners on battery property changes
  - Ensured proper exception handling and cleanup in utility functions
2026-03-23 23:33:53 +05:45

155 lines
5.5 KiB
Python

import json
import socket
import threading
from pathlib import Path
from fabric.core.service import Property, Service, Signal
from fabric.utils import GLib, logger, os
import config.data as data
from services.config import on_config_change
from utils.functions import read_json_file, run_command, write_json_file
HYPRCTL_BIN = "hyprctl"
class KeyboardLayout(Service):
"""Service to manage keyboard layout switching and monitoring via Hyprland."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@staticmethod
def get_initial():
if KeyboardLayout._instance is None:
KeyboardLayout._instance = KeyboardLayout()
return KeyboardLayout._instance
@Signal
def layout_changed(self, layout: str) -> None:
"""Signal emitted when keyboard layout changes."""
def __init__(self, **kwargs):
if getattr(self, "_initialized", False):
return
super().__init__(**kwargs)
self._initialized = True
self.layout_json_file = Path(data.CACHE_DIR) / "kb_layout.json"
self._last_layout = None
self.layouts = []
self.current_index = 0
self._init_layout_config()
self._start_event_listener()
on_config_change(self._on_config_change)
def _init_layout_config(self):
config_layouts = data.load_config().get("keyboard_layouts", ["us", "np"])
json_data = read_json_file(self.layout_json_file)
if json_data:
self.layouts = json_data.get("layouts", config_layouts)
self.current_index = json_data.get("current_index", 0)
else:
self.layouts = config_layouts
self.current_index = 0
self._save_layout_json()
# Sync with actual Hyprland state
self._sync_with_hyprland()
def _sync_with_hyprland(self):
try:
result = run_command([HYPRCTL_BIN, "devices", "-j"])
devices = json.loads(result)
keyboards = devices.get("keyboards", [])
for k in keyboards:
if k.get("main"):
layout_name = k.get("active_keymap")
if layout_name:
self._last_layout = layout_name
# Try to match with our list to update index
for i, layout_item in enumerate(self.layouts):
if layout_item.lower() in layout_name.lower():
self.current_index = i
break
break
except Exception as e:
logger.error(f"[KeyboardLayout] Sync error: {e}")
def _save_layout_json(self):
write_json_file(
{
"layouts": self.layouts,
"current_index": self.current_index,
},
self.layout_json_file,
)
def _on_config_change(self, new_config, old_config):
new_layouts = new_config.get("keyboard_layouts")
if new_layouts and new_layouts != self.layouts:
self.layouts = new_layouts
self._save_layout_json()
logger.info(f"[KeyboardLayout] Layouts updated from config: {self.layouts}")
def _start_event_listener(self):
"""Start a thread to listen for Hyprland layout events."""
thread = threading.Thread(target=self._event_loop, daemon=True)
thread.start()
def _event_loop(self):
his = os.getenv("HYPRLAND_INSTANCE_SIGNATURE")
xdg_runtime_dir = os.getenv("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
socket_path = f"{xdg_runtime_dir}/hypr/{his}/.socket2.sock"
if not os.path.exists(socket_path):
logger.error(f"[KeyboardLayout] Hyprland socket not found: {socket_path}")
return
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(socket_path)
logger.info("[KeyboardLayout] Connected to Hyprland event socket.")
while True:
data = s.recv(1024).decode("utf-8")
if not data:
break
for line in data.split("\n"):
if line.startswith("activelayout>>"):
# Format: activelayout>>keyboardname,layoutname
parts = line.split(">>")[1].split(",")
if len(parts) >= 2:
layout_name = parts[1]
if layout_name != self._last_layout:
self._last_layout = layout_name
GLib.idle_add(
self.emit, "layout_changed", layout_name
)
except Exception as e:
logger.error(f"[KeyboardLayout] Event listener error: {e}")
def switch_to_next(self) -> bool:
if not self.layouts:
return False
self.current_index = (self.current_index + 1) % len(self.layouts)
self.layouts[self.current_index]
# Use hyprctl to switch layout for all devices
# We assume the layouts in Hyprland config match self.layouts in order
run_command([HYPRCTL_BIN, "switchxkblayout", "all", "next"])
self._save_layout_json()
return True
@Property(str, "readable")
def current_layout(self) -> str:
return self._last_layout or "Unknown"