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
1 change: 1 addition & 0 deletions docs/_Sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@
- [Komorebi Layout](./(Widget)-Komorebi-Layout)
- [Komorebi Stack](./(Widget)-Komorebi-Stack)
- [Komorebi Workspaces](./(Widget)-Komorebi-Workspaces)
- [Shellwright Workspaces](./(Widget)-Shellwright-Workspaces)
170 changes: 170 additions & 0 deletions docs/widgets/(Widget)-Shellwright-Workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Shellwright Workspaces Widget

Displays workspace buttons and the active tiling layout for a single monitor, fed live from the [shellwright](https://github.com/your-org/shellwright) window manager over a named pipe (`\\.\pipe\shellwright`). The widget reconnects automatically when shellwright restarts.

| Option | Type | Default | Description |
| ------------------------------- | --------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `label_offline` | string | `''` | Text shown when shellwright is not running. |
| `label_workspace_btn` | string | `''` | Format string for empty/default workspace buttons. Supports `{name}` and `{index}`. |
| `label_workspace_active_btn` | string | `''` | Format string for the active workspace button. Supports `{name}` and `{index}`. |
| `label_workspace_populated_btn` | string | `''` | Format string for populated (non-active) workspace buttons. Supports `{name}` and `{index}`. |
| `monitor_index` | integer | `-1` | 0-based index of the monitor this bar lives on. `-1` = auto-detect from Qt screen API. Override only when auto-detection gives the wrong monitor. |
| `monitor_index_remap` | list[int] | `[]` | Optional remapping when the Qt screen order differs from the shellwright monitor order. Index = Qt screen index, value = shellwright monitor index. Example: `[2, 0, 1]` maps Qt screen 0 → shellwright monitor 2. Empty list uses Qt screen index directly. |
| `hide_if_offline` | boolean | `true` | Hide the entire widget when shellwright is not running. |
| `hide_empty_workspaces` | boolean | `false` | Hide workspace buttons that contain no windows. |
| `show_layout` | boolean | `false` | Show the active tiling layout for this monitor's focused workspace. |
| `label_layout` | string | `'[{layout}]'` | Format string for the layout label. Supports `{layout}` (one of: `fibonacci`, `bsp`, `columns`, `monocle`, `center_main`, `float`). |
| `container_padding` | dict | `None` | Inner padding for the widget container. |
| `btn_shadow` | dict | `None` | Shadow options applied to each workspace button. |
| `label_shadow` | dict | `None` | Shadow options applied to the offline and layout labels. |
| `container_shadow` | dict | `None` | Shadow options applied to the outer widget container. |

## Example Configuration

```yaml
shellwright_workspaces:
type: "shellwright.workspaces.WorkspaceWidget"
options:
label_offline: "SW Offline"
label_workspace_btn: "{index}"
label_workspace_active_btn: "{index}"
label_workspace_populated_btn: "{index}"
hide_if_offline: true
hide_empty_workspaces: true
show_layout: true
label_layout: " [{layout}]"
monitor_index: -1
monitor_index_remap: []
btn_shadow:
enabled: true
color: "black"
radius: 3
offset: [1, 1]
label_shadow:
enabled: true
color: "black"
radius: 3
offset: [1, 1]
```

## Description of Options

- **label_offline:** Text shown in the bar when shellwright is not running. Hidden automatically once the pipe connects.
- **label_workspace_btn:** Format string for workspace buttons in the default (empty) state. Use `{name}` for the workspace name set in `config.toml`, or `{index}` for the 1-based position.
- **label_workspace_active_btn:** Format string for the button of the workspace that is currently focused on this monitor.
- **label_workspace_populated_btn:** Format string for buttons whose workspace contains at least one window but is not currently active.
- **monitor_index:** Which shellwright monitor index this widget instance represents. Use `-1` (default) to let YASB auto-detect the monitor from its own screen position. Set an explicit value only when the Qt screen order and the shellwright monitor order do not match.
- **monitor_index_remap:** Fine-grained override for the Qt → shellwright monitor mapping. Provide a list where position _i_ holds the shellwright monitor index for Qt screen _i_. Takes precedence over `monitor_index` auto-detect when non-empty.
- **hide_if_offline:** When `true`, the widget is completely hidden until shellwright connects. When `false` (default), the `label_offline` text is shown instead.
- **hide_empty_workspaces:** When `true`, buttons for workspaces that contain no windows are hidden.
- **show_layout:** When `true`, a layout label is appended after the workspace buttons showing the tiling strategy active on this monitor's focused workspace.
- **label_layout:** Format string for the layout label. `{layout}` is replaced with the current layout name. Available layout names: `fibonacci`, `bsp`, `columns`, `monocle`, `center_main`, `float`.
- **container_padding:** Padding around the widget's inner layout (top, right, bottom, left in pixels).
- **btn_shadow:** Drop-shadow applied to every workspace button.
- **label_shadow:** Drop-shadow applied to the offline label and the layout label.
- **container_shadow:** Drop-shadow applied to the outer widget frame.

## Style

```css
.shellwright-workspaces {
} /* Outer widget frame */
.shellwright-workspaces .widget-container {
} /* Inner container */
.shellwright-workspaces .sw-offline {
} /* "Offline" label (shown when disconnected) */
.shellwright-workspaces .ws-btn {
} /* Every workspace button */
.shellwright-workspaces .ws-btn.empty {
} /* Empty workspace (no windows) */
.shellwright-workspaces .ws-btn.populated {
} /* Has windows, not the active workspace */
.shellwright-workspaces .ws-btn.active {
} /* Active (focused) workspace on this monitor */
.shellwright-workspaces .sw-layout {
} /* Layout label (visible when show_layout = true) */
```

> [!NOTE]
> You can combine state classes with button position for per-workspace styling, e.g. `.shellwright-workspaces .ws-btn.active` targets only the active workspace button.

## Example Style

```css
/* Minimal dot-style workspace indicators */
.shellwright-workspaces .ws-btn {
background: transparent;
border: none;
min-width: 8px;
min-height: 8px;
border-radius: 4px;
margin: 0 3px;
padding: 0;
font-size: 0; /* hide text, show only background shape */
}
.shellwright-workspaces .ws-btn.empty {
background-color: #555555;
border-radius: 50%;
}
.shellwright-workspaces .ws-btn.populated {
background-color: #ffffff;
border-radius: 50%;
}
.shellwright-workspaces .ws-btn.active {
background-color: #5294e2;
border-radius: 2px;
min-width: 18px;
}
.shellwright-workspaces .sw-layout {
color: #888888;
font-size: 11px;
margin-left: 4px;
}
.shellwright-workspaces .sw-offline {
color: #555555;
font-size: 11px;
}
```

## Named Pipe Protocol

shellwright broadcasts a newline-terminated JSON object on `\\.\pipe\shellwright` after every state change (window create/destroy/focus, workspace switch, layout change, float/fullscreen toggle). The schema mirrors the komorebi pipe format for compatibility:

```json
{
"monitors": {
"elements": [
{
"name": "Monitor 1",
"index": 0,
"workspaces": {
"elements": [
{
"name": "1",
"index": 0,
"layout": "fibonacci",
"focused_window": "Window Title",
"windows": {
"elements": [{ "id": 0 }, { "id": 1 }],
"focused": 0
}
}
],
"focused": 0
}
}
],
"focused": 0
}
}
```

| Field | Description |
| ----------------------------------------- | --------------------------------------------------------------------------- |
| `monitors.focused` | 0-based index of the monitor with keyboard focus. |
| `monitors.elements[i].index` | 0-based monitor index (matches `monitor_index` option). |
| `monitors.elements[i].workspaces.focused` | Local 0-based index of the active workspace on this monitor. |
| `workspaces.elements[j].name` | Workspace name from `config.toml`. |
| `workspaces.elements[j].layout` | Active layout: `fibonacci` `bsp` `columns` `monocle` `center_main` `float`. |
| `workspaces.elements[j].focused_window` | Title of the focused window on this workspace (empty string if none). |
| `workspaces.elements[j].windows.elements` | One `{"id": N}` entry per managed window on this workspace. |
1 change: 1 addition & 0 deletions src/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"core.widgets.yasb",
"core.widgets.komorebi",
"core.widgets.glazewm",
"core.widgets.shellwright",
],
"constants": [f"ARCHITECTURE='{display_arch}'"],
"silent_level": 1,
Expand Down
6 changes: 6 additions & 0 deletions src/core/event_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ class KomorebiEvent(Event):
CycleStack = "CycleStack"
FocusStackWindow = "FocusStackWindow"
TitleUpdate = "TitleUpdate"


class ShellwrightEvent(Event):
ShellwrightConnect = "ShellwrightConnect"
ShellwrightUpdate = "ShellwrightUpdate"
ShellwrightDisconnect = "ShellwrightDisconnect"
Empty file.
163 changes: 163 additions & 0 deletions src/core/utils/widgets/shellwright/event_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""
Shellwright named-pipe event listener.

shellwright creates a byte-mode server pipe at ``\\\\.\\pipe\\shellwright`` and
writes newline-terminated JSON state blobs whenever workspace or layout state
changes. YASB connects as a read-only byte-stream client, accumulates bytes
until it sees a newline, then parses and emits the Qt event.

Pipe direction
--------------
* **shellwright** — ``CreateNamedPipeW(..., PIPE_ACCESS_OUTBOUND, PIPE_TYPE_BYTE)``
* **YASB** — ``CreateFile(..., GENERIC_READ)`` (byte stream client)

Each JSON message looks like::

{"monitors":{"elements":[{"name":"Monitor 1","index":0,"workspaces":{
"elements":[{"name":"1","index":0,"layout":"fibonacci",
"focused_window":"Title","windows":{"elements":[],"focused":0}}],
"focused":0}}],"focused":0}}\\n
"""

import json
import logging
import threading

import pywintypes
import win32file
from PyQt6.QtCore import QThread

from core.event_enums import ShellwrightEvent
from core.event_service import EventService

SHELLWRIGHT_PIPE_NAME = r"\\.\pipe\shellwright"
SHELLWRIGHT_READ_SIZE = 4096


class ShellwrightEventListener(QThread):
def __init__(
self,
pipe_name: str = SHELLWRIGHT_PIPE_NAME,
read_size: int = SHELLWRIGHT_READ_SIZE,
):
super().__init__()
self._stop_event = threading.Event()
self.pipe_name = pipe_name
self.read_size = read_size
self.event_service = EventService()
self._pipe = None

def __str__(self) -> str:
return "Shellwright Event Listener"

@property
def _app_running(self) -> bool:
return not self._stop_event.is_set()

# ── Connection ────────────────────────────────────────────────────────────

def _connect(self) -> bool:
"""Open shellwright's byte-mode pipe as a read-only client.

Returns True on success.
"""
try:
self._pipe = win32file.CreateFile(
self.pipe_name,
win32file.GENERIC_READ,
0,
None,
win32file.OPEN_EXISTING,
0,
None,
)
logging.info("Connected to shellwright pipe %s", self.pipe_name)
return True
except pywintypes.error as e:
# 2 = ERROR_FILE_NOT_FOUND (shellwright not running)
# 231 = ERROR_PIPE_BUSY (server not yet accepting)
if e.winerror not in (2, 231):
logging.warning("Cannot open shellwright pipe: %s", e)
return False

def _close(self) -> None:
if self._pipe is not None:
try:
win32file.CloseHandle(self._pipe)
except Exception:
pass
self._pipe = None

# ── Main thread ───────────────────────────────────────────────────────────

def run(self) -> None:
while self._app_running:
# Wait until shellwright starts.
while self._app_running and not self._connect():
if self._stop_event.wait(3):
return

if not self._app_running:
break

connected = False
buf = b""
_stopped = False

try:
while self._app_running:
try:
_, chunk = win32file.ReadFile(self._pipe, self.read_size)
except pywintypes.error as e:
if e.winerror in (109, 232, 6):
# 109 = ERROR_BROKEN_PIPE
# 232 = ERROR_NO_DATA
# 6 = ERROR_INVALID_HANDLE
logging.info("Shellwright pipe closed (winerr=%d)", e.winerror)
else:
logging.exception("Unexpected shellwright pipe error: %s", e)
break

if not chunk:
continue

buf += chunk

# A single ReadFile may contain multiple newline-terminated
# JSON objects — process all complete lines.
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.strip()
if not line:
continue
try:
state = json.loads(line.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
logging.warning(
"Shellwright: bad JSON: %r", line[:200]
)
continue

if not connected:
connected = True
self.event_service.emit_event(
ShellwrightEvent.ShellwrightConnect, state
)
else:
self.event_service.emit_event(
ShellwrightEvent.ShellwrightUpdate, state
)

finally:
self._close()
self.event_service.emit_event(ShellwrightEvent.ShellwrightDisconnect)
_stopped = not self._app_running

if _stopped:
break
logging.info("Shellwright disconnected — retrying in 3 s")
self._stop_event.wait(3)

def stop(self) -> None:
self._stop_event.set()
self._close()
Empty file.
25 changes: 25 additions & 0 deletions src/core/validation/widgets/shellwright/workspaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from core.validation.widgets.base_model import CustomBaseModel, PaddingConfig, ShadowConfig


class ShellwrightWorkspacesConfig(CustomBaseModel):
label_offline: str = "SW"
label_workspace_btn: str = ""
label_workspace_active_btn: str = ""
label_workspace_populated_btn: str = ""
# 0-based index of the monitor this bar lives on.
# -1 (default) = auto-detect from Qt screen API.
# Override only if auto-detection gives the wrong monitor.
monitor_index: int = -1
# Optional remapping when Qt screen order != shellwright monitor order.
# Index = Qt screen index, value = shellwright monitor index.
# Example: [2, 0, 1] means Qt screen 0 → shellwright mon 2, etc.
# Empty list (default) = use Qt screen index directly.
monitor_index_remap: list[int] = []
hide_if_offline: bool = True
hide_empty_workspaces: bool = False
show_layout: bool = False
label_layout: str = "[{layout}]"
container_padding: PaddingConfig = PaddingConfig()
btn_shadow: ShadowConfig = ShadowConfig()
label_shadow: ShadowConfig = ShadowConfig()
container_shadow: ShadowConfig = ShadowConfig()
Empty file.
Loading