-
Notifications
You must be signed in to change notification settings - Fork 42
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(widget/glazewm): GlazeWM support
- Added GlazeWM Workspaces and Tiling Direction widgets. - Updated readme and added docs.
- Loading branch information
1 parent
a137057
commit 510cb51
Showing
8 changed files
with
608 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
# GlazeWM Tiling Direction Widget | ||
| Option | Type | Default | Description | | ||
|----------------------|--------|-------------------------|-------------------------------------------------| | ||
| `horizontal_label` | string | `'\udb81\udce1'` | The label used for horizontal tiling direction. | | ||
| `vertical_label` | string | `'\udb81\udce2'` | Optional label for populated workspaces. | | ||
| `glazewm_server_uri` | string | `'ws://localhost:6123'` | Optional GlazeWM server uri. | | ||
|
||
## Example Configuration | ||
|
||
```yaml | ||
glazewm_tiling_direction: | ||
type: "glazewm.tiling_direction.GlazewmTilingDirectionWidget" | ||
options: | ||
horizontal_label: "\udb81\udce1" | ||
vertical_label: "\udb81\udce2" | ||
``` | ||
## Description of Options | ||
- **horizontal_label:** Label used for horizontal tiling direction. | ||
- **vertical_label:** Label for vertical tiling direction. | ||
- **glazewm_server_uri:** Optional GlazeWM server uri if it ever changes on GlazeWM side. | ||
## Style | ||
```css | ||
.glazewm-tiling-direction {} /*Style for widget.*/ | ||
.glazewm-tiling-direction .btn {} /*Style for tiling direction button.*/ | ||
``` | ||
|
||
## Example CSS | ||
```css | ||
.glazewm-tiling-direction { | ||
background-color: transparent; | ||
padding: 0; | ||
margin: 0; | ||
} | ||
|
||
.glazewm-tiling-direction .btn { | ||
font-size: 18px; | ||
width: 14px; | ||
padding: 0 4px 0 4px; | ||
color: #CDD6F4; | ||
border: none; | ||
} | ||
|
||
.glazewm-tiling-direction .btn:hover { | ||
background-color: #727272; | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
# GlazeWM Workspaces Widget | ||
| Option | Type | Default | Description | | ||
|-------------------------|---------|-------------------------|----------------------------------------------------------| | ||
| `offline_label` | string | `'GlazeWM Offline'` | The label to display when GlazeWM is offline. | | ||
| `populated_label` | string | `'{index}'` | Optional label for populated workspaces. | | ||
| `empty_label` | string | `'{index}'` | Optional label for empty workspaces. | | ||
| `hide_empty_workspaces` | boolean | `true` | Whether to hide empty workspaces. | | ||
| `hide_if_offline` | boolean | `false` | Whether to hide workspaces widget if GlazeWM is offline. | | ||
| `glazewm_server_uri` | string | `'ws://localhost:6123'` | Optional GlazeWM server uri. | | ||
|
||
|
||
## Example Configuration | ||
|
||
```yaml | ||
glazewm_workspaces: | ||
type: "glazewm.workspaces.GlazewmWorkspacesWidget" | ||
options: | ||
offline_label: "GlazeWM Offline" | ||
hide_empty_workspaces: true | ||
hide_if_offline: false | ||
|
||
# By default workspace names are fetched from GlazeWM and "display_name" option takes priority over "name". | ||
# However, you can customize populated and empty labels here using {name} and {display_name} placeholders if needed. | ||
# {name} will be replaced with workspace name (index) from GlazeWM. | ||
# {display_name} will be replaced with workspace display_name from GlazeWM. | ||
|
||
# populated_label: "{name} {display_name} \uebb4"e | ||
# empty_label: "{name} {display_name} \uebb5" | ||
``` | ||
|
||
## Description of Options | ||
- **offline_label:** The label to display when GlazeWM is offline. | ||
- **populated_label:** Optional label for populated workspaces. If not set, name or display_name from GlazeWM will be used. | ||
- **empty_label:** Optional label for empty workspaces. If not set, name or display_name from GlazeWM will be used. | ||
- **hide_empty_workspaces:** Whether to hide empty workspaces. | ||
- **hide_if_offline:** Whether to hide workspaces widget if GlazeWM is offline. | ||
- **glazewm_server_uri:** Optional GlazeWM server uri if it ever changes on GlazeWM side. | ||
|
||
## Important Note | ||
In GlazeWM config use "1", "2", "3" for workspace "name" and NOT some custom string. This will ensure proper sorting of workspaces. | ||
|
||
If you need a custom name for each workspace - use "display_name". | ||
|
||
**Example:** | ||
|
||
```yaml | ||
workspaces: | ||
- name: "1" | ||
display_name: "Work" # Optional | ||
- name: "2" | ||
display_name: "Browser" # Optional | ||
- name: "3" | ||
display_name: "Music" # Optional | ||
# and so on... | ||
``` | ||
|
||
## Style | ||
```css | ||
.glazewm-workspaces {} /*Style for widget.*/ | ||
.glazewm-workspaces .btn {} /*Style for workspace buttons.*/ | ||
.glazewm-workspaces .btn.active {} /*Style for active workspace button.*/ | ||
.glazewm-workspaces .btn.populated {} /*Style for populated workspace button.*/ | ||
.glazewm-workspaces .btn.empty {} /*Style for empty workspace button.*/ | ||
.glazewm-workspaces .offline-status {} /*Style for offline status label.*/ | ||
``` | ||
|
||
## Example CSS | ||
```css | ||
.glazewm-workspaces { | ||
margin: 0; | ||
} | ||
|
||
.glazewm-workspaces .ws-btn { | ||
font-size: 14px; | ||
background-color: transparent; | ||
border: none; | ||
padding: 0px 4px 0px 4px; | ||
margin: 0 2px 0 2px; | ||
color: #CDD6F4; | ||
} | ||
|
||
.glazewm-workspaces .ws-btn.active { | ||
background-color: #727272; | ||
} | ||
|
||
.glazewm-workspaces .ws-btn.populated { | ||
color: #C2DAF7; | ||
} | ||
|
||
.glazewm-workspaces .ws-btn.empty { | ||
color: #7D8B9D; | ||
} | ||
|
||
.glazewm-workspaces .ws-btn:hover, | ||
.glazewm-workspaces .ws-btn.populated:hover, | ||
.glazewm-workspaces .ws-btn.empty:hover { | ||
background-color: #727272; | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import json | ||
import logging | ||
from dataclasses import dataclass | ||
from enum import StrEnum, auto | ||
from typing import Any | ||
|
||
from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal | ||
from PyQt6.QtNetwork import QAbstractSocket | ||
from PyQt6.QtWebSockets import QWebSocket | ||
|
||
from settings import DEBUG | ||
|
||
logger = logging.getLogger("glazewm_client") | ||
|
||
if DEBUG: | ||
logger.setLevel(logging.DEBUG) | ||
else: | ||
logger.setLevel(logging.CRITICAL) | ||
|
||
|
||
@dataclass | ||
class Workspace: | ||
name: str | ||
display_name: str | ||
focus: bool = False | ||
is_displayed: bool = False | ||
num_windows: int = 0 | ||
|
||
|
||
@dataclass | ||
class Monitor: | ||
name: str | ||
hwnd: int | ||
workspaces: list[Workspace] | ||
|
||
|
||
class MessageType(StrEnum): | ||
EVENT_SUBSCRIPTION = auto() | ||
CLIENT_RESPONSE = auto() | ||
|
||
|
||
class QueryType(StrEnum): | ||
MONITORS = "query monitors" | ||
TILING_DIRECTION = "query tiling-direction" | ||
|
||
|
||
class TilingDirection(StrEnum): | ||
HORIZONTAL = auto() | ||
VERTICAL = auto() | ||
|
||
|
||
class GlazewmClient(QObject): | ||
workspaces_data_processed = pyqtSignal(list) | ||
tiling_direction_processed = pyqtSignal(str) | ||
glazewm_connection_status = pyqtSignal(bool) | ||
|
||
def __init__(self, uri: str, initial_messages: list[str] | None = None, reconnect_interval=4000) -> None: | ||
super().__init__() | ||
self.initial_messages = initial_messages if initial_messages else [] | ||
|
||
self._uri = QUrl(uri) | ||
self._websocket = QWebSocket() | ||
self._websocket.connected.connect(self._on_connected) | ||
self._websocket.textMessageReceived.connect(self._handle_message) | ||
self._websocket.stateChanged.connect(self._on_state_changed) | ||
self._websocket.errorOccurred.connect(self._on_error) | ||
|
||
self._reconnect_timer = QTimer() | ||
self._reconnect_timer.setInterval(reconnect_interval) | ||
self._reconnect_timer.timeout.connect(self.connect) | ||
|
||
def activate_workspace(self, workspace_name: str): | ||
self._websocket.sendTextMessage(f"command focus --workspace {workspace_name}") | ||
|
||
def toggle_tiling_direction(self): | ||
self._websocket.sendTextMessage("command toggle-tiling-direction") | ||
|
||
def connect(self): | ||
logger.debug(f"Connecting to {self._uri}...") | ||
self._websocket.open(self._uri) | ||
|
||
def _on_connected(self) -> None: | ||
logger.debug(f"Connected to {self._uri}") | ||
for message in self.initial_messages: | ||
logger.debug(f"Sent initial message: {message}") | ||
self._websocket.sendTextMessage(message) | ||
|
||
# Stop reconnect timer | ||
self._reconnect_timer.stop() | ||
|
||
def _on_state_changed(self, state: QAbstractSocket.SocketState): | ||
logger.debug(f"WebSocket state changed: {state}") | ||
self.glazewm_connection_status.emit(state == QAbstractSocket.SocketState.ConnectedState) | ||
|
||
def _on_error(self, error: QAbstractSocket.SocketError) -> None: | ||
logger.warning(f"WebSocket error: {error}\nReconnecting...") | ||
self._reconnect_timer.start() | ||
|
||
def _handle_message(self, message: str): | ||
try: | ||
response = json.loads(message) | ||
except json.JSONDecodeError: | ||
logger.warning("Received invalid JSON data.") | ||
return | ||
|
||
if response.get("messageType") == MessageType.EVENT_SUBSCRIPTION: | ||
self._websocket.sendTextMessage(QueryType.MONITORS) | ||
self._websocket.sendTextMessage(QueryType.TILING_DIRECTION) | ||
elif response.get("messageType") == MessageType.CLIENT_RESPONSE: | ||
data = response.get("data", {}) | ||
if response.get("clientMessage") == QueryType.MONITORS: | ||
monitors = data.get("monitors", []) | ||
self.workspaces_data_processed.emit(self._process_workspaces(monitors)) | ||
elif response.get("clientMessage") == QueryType.TILING_DIRECTION: | ||
tiling_direction = TilingDirection(data.get("tilingDirection", TilingDirection.HORIZONTAL)) | ||
self.tiling_direction_processed.emit(tiling_direction) | ||
|
||
def _process_workspaces(self, data: list[dict[str, Any]]) -> list[Monitor]: | ||
monitors: list[Monitor] = [] | ||
for mon in data: | ||
monitor_name: str | None = mon.get("hardwareId") | ||
handle: int | None = mon.get("handle") | ||
if not monitor_name or not handle: | ||
logger.warning("Monitor name or hwnd not found") | ||
continue | ||
workspaces_data = [ | ||
Workspace( | ||
name=child.get("name", ""), | ||
display_name=child.get("displayName", ""), | ||
is_displayed=child.get("isDisplayed", False), | ||
focus=child.get("hasFocus", False), | ||
num_windows=len(child.get("children", [])), | ||
) | ||
for child in mon.get("children", []) | ||
if child.get("type") == "workspace" | ||
] | ||
monitors.append( | ||
Monitor( | ||
name=monitor_name, | ||
hwnd=handle, | ||
workspaces=workspaces_data, | ||
) | ||
) | ||
return monitors |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
DEFAULTS = { | ||
"horizontal_label": "\udb81\udce1", | ||
"vertical_label": "\udb81\udce2", | ||
"glazewm_server_uri": "ws://localhost:6123", | ||
} | ||
|
||
|
||
VALIDATION_SCHEMA = { | ||
"glazewm_server_uri": { | ||
"type": "string", | ||
"default": DEFAULTS["glazewm_server_uri"], | ||
}, | ||
"horizontal_label": { | ||
"type": "string", | ||
"default": DEFAULTS["horizontal_label"] | ||
}, | ||
"vertical_label": { | ||
"type": "string", | ||
"default": DEFAULTS["vertical_label"] | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
DEFAULTS = { | ||
"offline_label": "GlazeWM Offline", | ||
"populated_label": None, | ||
"empty_label": None, | ||
"hide_empty_workspaces": True, | ||
"hide_if_offline": False, | ||
"glazewm_server_uri": "ws://localhost:6123", | ||
} | ||
|
||
VALIDATION_SCHEMA = { | ||
"offline_label": { | ||
"type": "string", | ||
"default": DEFAULTS["offline_label"], | ||
}, | ||
"populated_label": { | ||
"type": "string", | ||
"default": DEFAULTS["populated_label"], | ||
"nullable": True, | ||
}, | ||
"empty_label": { | ||
"type": "string", | ||
"default": DEFAULTS["empty_label"], | ||
"nullable": True, | ||
}, | ||
"hide_empty_workspaces": { | ||
"type": "boolean", | ||
"default": DEFAULTS["hide_empty_workspaces"], | ||
}, | ||
"hide_if_offline": { | ||
"type": "boolean", | ||
"default": DEFAULTS["hide_if_offline"], | ||
}, | ||
"glazewm_server_uri": { | ||
"type": "string", | ||
"default": DEFAULTS["glazewm_server_uri"], | ||
}, | ||
} |
Oops, something went wrong.