diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 414b4105..8f445571 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -62,3 +62,4 @@ - [Komorebi Layout](./(Widget)-Komorebi-Layout) - [Komorebi Stack](./(Widget)-Komorebi-Stack) - [Komorebi Workspaces](./(Widget)-Komorebi-Workspaces) + - [Shellwright Workspaces](./(Widget)-Shellwright-Workspaces) diff --git a/docs/widgets/(Widget)-Shellwright-Workspaces.md b/docs/widgets/(Widget)-Shellwright-Workspaces.md new file mode 100644 index 00000000..0eceecc1 --- /dev/null +++ b/docs/widgets/(Widget)-Shellwright-Workspaces.md @@ -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. | diff --git a/src/build.py b/src/build.py index db2a5d79..f9c4494e 100644 --- a/src/build.py +++ b/src/build.py @@ -18,6 +18,7 @@ "core.widgets.yasb", "core.widgets.komorebi", "core.widgets.glazewm", + "core.widgets.shellwright", ], "constants": [f"ARCHITECTURE='{display_arch}'"], "silent_level": 1, diff --git a/src/core/event_enums.py b/src/core/event_enums.py index 2961fdad..2b853e6a 100644 --- a/src/core/event_enums.py +++ b/src/core/event_enums.py @@ -49,3 +49,9 @@ class KomorebiEvent(Event): CycleStack = "CycleStack" FocusStackWindow = "FocusStackWindow" TitleUpdate = "TitleUpdate" + + +class ShellwrightEvent(Event): + ShellwrightConnect = "ShellwrightConnect" + ShellwrightUpdate = "ShellwrightUpdate" + ShellwrightDisconnect = "ShellwrightDisconnect" diff --git a/src/core/utils/widgets/shellwright/__init__.py b/src/core/utils/widgets/shellwright/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/utils/widgets/shellwright/event_listener.py b/src/core/utils/widgets/shellwright/event_listener.py new file mode 100644 index 00000000..4005e986 --- /dev/null +++ b/src/core/utils/widgets/shellwright/event_listener.py @@ -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() diff --git a/src/core/validation/widgets/shellwright/__init__.py b/src/core/validation/widgets/shellwright/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/validation/widgets/shellwright/workspaces.py b/src/core/validation/widgets/shellwright/workspaces.py new file mode 100644 index 00000000..d390abc3 --- /dev/null +++ b/src/core/validation/widgets/shellwright/workspaces.py @@ -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() diff --git a/src/core/widgets/shellwright/__init__.py b/src/core/widgets/shellwright/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/widgets/shellwright/workspaces.py b/src/core/widgets/shellwright/workspaces.py new file mode 100644 index 00000000..0d497523 --- /dev/null +++ b/src/core/widgets/shellwright/workspaces.py @@ -0,0 +1,283 @@ +""" +Shellwright workspace widget for YASB. + +Displays workspace buttons (empty / populated / active) from ALL monitors +in order, and optionally the current tiling layout for the active workspace. + +Config example (config.yaml) +----------------------------- +.. code-block:: yaml + + widgets: + shellwright_workspaces: + type: "shellwright.workspaces.WorkspaceWidget" + options: + label_offline: "SW Offline" + label_workspace_btn: "{name}" + label_workspace_active_btn: "{name}" + label_workspace_populated_btn: "{name}" + hide_if_offline: false + hide_empty_workspaces: true + show_layout: true + label_layout: "[{layout}]" + +CSS classes +----------- +* ``.shellwright-workspaces`` — outer frame +* ``.ws-btn`` — every workspace button +* ``.ws-btn.empty`` — empty workspace (grey dot) +* ``.ws-btn.populated`` — has windows, not active (white dot) +* ``.ws-btn.active`` — active workspace on focused monitor (blue bar) +* ``.sw-layout`` — layout label (when show_layout = true) +* ``.sw-offline`` — offline label + +Example stylesheet snippet +-------------------------- +.. code-block:: css + + .shellwright-workspaces .ws-btn { + background: transparent; + border: none; + min-width: 8px; + min-height: 8px; + border-radius: 4px; + margin: 0 3px; + } + .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; + } +""" + +import logging +from typing import Literal + +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QApplication, QLabel, QPushButton + +from core.event_enums import ShellwrightEvent +from core.event_service import EventService +from core.utils.utilities import add_shadow, refresh_widget_style +from core.validation.widgets.shellwright.workspaces import ShellwrightWorkspacesConfig +from core.widgets.base import BaseWidget + +try: + from core.utils.widgets.shellwright.event_listener import ShellwrightEventListener +except ImportError: + ShellwrightEventListener = None + logging.warning("Failed to load Shellwright Event Listener") + +WorkspaceStatus = Literal["EMPTY", "POPULATED", "ACTIVE"] +WS_EMPTY: WorkspaceStatus = "EMPTY" +WS_POPULATED: WorkspaceStatus = "POPULATED" +WS_ACTIVE: WorkspaceStatus = "ACTIVE" + + +class WorkspaceButton(QPushButton): + """A single workspace button.""" + + def __init__( + self, + ws_index: int, + ws_name: str, + config: ShellwrightWorkspacesConfig, + ) -> None: + super().__init__() + self.ws_index = ws_index + self.ws_name = ws_name + self.status: WorkspaceStatus = WS_EMPTY + self.setProperty("class", "ws-btn empty") + self._default_label = config.label_workspace_btn.format(name=ws_name, index=ws_index + 1) + self._active_label = config.label_workspace_active_btn.format(name=ws_name, index=ws_index + 1) + self._populated_label = config.label_workspace_populated_btn.format(name=ws_name, index=ws_index + 1) + self.setText(self._default_label) + self.hide() + + def update_status(self, status: WorkspaceStatus) -> None: + self.status = status + self.setProperty("class", f"ws-btn {status.lower()}") + if status == WS_ACTIVE: + self.setText(self._active_label) + elif status == WS_POPULATED: + self.setText(self._populated_label) + else: + self.setText(self._default_label) + refresh_widget_style(self) + + +class WorkspaceWidget(BaseWidget): + """YASB widget that displays shellwright workspaces and layout. + + Shows ALL workspaces from ALL monitors in order (e.g. 1-3 from monitor 0, + 4-6 from monitor 1, 7-9 from monitor 2). The active workspace (focused + workspace on the focused monitor) is highlighted with the ``active`` class. + """ + + validation_schema = ShellwrightWorkspacesConfig + event_listener = ShellwrightEventListener + + _connect_signal = pyqtSignal(dict) + _update_signal = pyqtSignal(dict) + _disconnect_signal = pyqtSignal() + + def __init__(self, config: ShellwrightWorkspacesConfig) -> None: + super().__init__(class_name="shellwright-workspaces") + + self._config = config + self._workspace_buttons: list[WorkspaceButton] = [] + + # Offline label — starts hidden; shown only when disconnected and hide_if_offline=False. + self._offline_label = QLabel(config.label_offline) + self._offline_label.setProperty("class", "sw-offline") + add_shadow(self._offline_label, config.label_shadow.model_dump()) + self._offline_label.setVisible(False) + + # Layout label (optional, hidden until connected). + self._layout_label = QLabel("") + self._layout_label.setProperty("class", "sw-layout") + add_shadow(self._layout_label, config.label_shadow.model_dump()) + self._layout_label.setVisible(False) + + # Container padding. + p = config.container_padding + self.widget_layout.setContentsMargins(p.left, p.top, p.right, p.bottom) + + self.widget_layout.addWidget(self._offline_label) + self.widget_layout.addWidget(self._layout_label) + + add_shadow(self._widget_frame, config.container_shadow.model_dump()) + + if config.hide_if_offline: + self.hide() + else: + self._offline_label.setVisible(True) + + # Wire Qt signals → slots. + self._connect_signal.connect(self._on_connect) + self._update_signal.connect(self._on_update) + self._disconnect_signal.connect(self._on_disconnect) + + # Subscribe to shellwright events. + event_service = EventService() + event_service.register_event(ShellwrightEvent.ShellwrightConnect, self._connect_signal) + event_service.register_event(ShellwrightEvent.ShellwrightUpdate, self._update_signal) + event_service.register_event(ShellwrightEvent.ShellwrightDisconnect, self._disconnect_signal) + + # ── Event slots ─────────────────────────────────────────────────────────── + + def _on_connect(self, state: dict) -> None: + self._offline_label.hide() + if self._config.hide_if_offline: + self.show() + self._apply_state(state) + + def _on_update(self, state: dict) -> None: + self._apply_state(state) + + def _on_disconnect(self) -> None: + for btn in self._workspace_buttons: + btn.hide() + self._layout_label.setVisible(False) + if self._config.hide_if_offline: + self.hide() + else: + self._offline_label.show() + + # ── State rendering ─────────────────────────────────────────────────────── + + def _apply_state(self, state: dict) -> None: + try: + monitors = state.get("monitors", {}) + elements = monitors.get("elements", []) + + if not elements: + return + + # ── This bar only cares about its own monitor ───────────────────── + mon_idx = self._config.monitor_index + if mon_idx < 0: + # Auto-detect: find which Qt screen this widget lives on. + screen = self.screen() + if screen is not None: + all_screens = QApplication.screens() + try: + qt_idx = all_screens.index(screen) + except ValueError: + qt_idx = 0 + else: + qt_idx = 0 + # Apply remap if configured, otherwise use Qt index directly. + remap = self._config.monitor_index_remap + if remap and qt_idx < len(remap): + mon_idx = remap[qt_idx] + else: + mon_idx = qt_idx + mon_idx = min(mon_idx, len(elements) - 1) + mon = elements[mon_idx] + ws_data = mon.get("workspaces", {}) + ws_els = ws_data.get("elements", []) + local_focused = ws_data.get("focused", 0) + + if not ws_els: + return + + # Rebuild buttons if workspace count changed. + if len(ws_els) != len(self._workspace_buttons): + self._rebuild_buttons(ws_els) + + # Update each button's status. + for local_idx, (ws, btn) in enumerate(zip(ws_els, self._workspace_buttons)): + n_windows = len(ws.get("windows", {}).get("elements", [])) + is_active = local_idx == local_focused + if is_active: + status = WS_ACTIVE + elif n_windows > 0: + status = WS_POPULATED + else: + status = WS_EMPTY + + btn.update_status(status) + + if self._config.hide_empty_workspaces and status == WS_EMPTY: + btn.hide() + else: + btn.show() + + # Layout label — show layout of this monitor's focused workspace. + if self._config.show_layout: + idx = min(local_focused, len(ws_els) - 1) + layout_name = ws_els[idx].get("layout", "") + self._layout_label.setText( + self._config.label_layout.format(layout=layout_name) + ) + self._layout_label.setVisible(True) + else: + self._layout_label.setVisible(False) + + except Exception: + logging.exception("Failed to apply shellwright state") + + def _rebuild_buttons(self, ws_elements: list) -> None: + """Remove old workspace buttons and create fresh ones.""" + for btn in self._workspace_buttons: + self.widget_layout.removeWidget(btn) + btn.deleteLater() + self._workspace_buttons.clear() + + for global_idx, ws in enumerate(ws_elements): + ws_name = ws.get("name", str(global_idx + 1)) + btn = WorkspaceButton(global_idx, ws_name, self._config) + add_shadow(btn, self._config.btn_shadow.model_dump()) + # Insert before _layout_label (always last in layout). + insert_pos = self.widget_layout.count() - 1 + self.widget_layout.insertWidget(insert_pos, btn) + self._workspace_buttons.append(btn)