diff --git a/sims/cli.py b/sims/cli.py index 0634963..0cec401 100644 --- a/sims/cli.py +++ b/sims/cli.py @@ -72,6 +72,15 @@ def _cmd_screenrec(ns: argparse.Namespace) -> None: invoke_action(mapping[ns.screenrec_cmd]) +def _cmd_corners(ns: argparse.Namespace) -> None: + mapping = { + "rounded": "set-bar-corners-rounded", + "flat": "set-bar-corners-flat", + "toggle": "toggle-bar-corners", + } + invoke_action(mapping[ns.corners_cmd]) + + def _cmd_list(ns: argparse.Namespace) -> None: actions = list_actions() if ns.json: @@ -108,6 +117,18 @@ def build_parser() -> argparse.ArgumentParser: rec_sub.add_parser(sub_name, help=sub_help) rec.set_defaults(func=_cmd_screenrec) + corners = sub.add_parser("corners", help="bar bottom-corner rounding") + corners_sub = corners.add_subparsers( + dest="corners_cmd", required=True, metavar="STATE" + ) + for sub_name, sub_help in [ + ("rounded", "round the bar's bottom corners"), + ("flat", "remove rounding (current default look)"), + ("toggle", "flip the current rounding state"), + ]: + corners_sub.add_parser(sub_name, help=sub_help) + corners.set_defaults(func=_cmd_corners) + lst = sub.add_parser("list", help="list registered actions") lst.add_argument("--json", action="store_true", help="emit JSON array") lst.set_defaults(func=_cmd_list) diff --git a/sims/main.py b/sims/main.py index 3de860c..264038a 100644 --- a/sims/main.py +++ b/sims/main.py @@ -12,7 +12,7 @@ else: logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL, "format": "{time} | {level} | {name}:{function}:{line} - {message}"}]) from fabric import Application -from fabric.i3 import I3, I3MessageType +from sims.services.i3 import I3, I3MessageType from fabric.system_tray.widgets import SystemTray from fabric.widgets.wayland import WaylandWindow as Window from fabric.utils import ( @@ -105,13 +105,34 @@ def screenrec_stop(): if screenrec_service is not None: screenrec_service.stop() + +def _set_all_bars_rounded(rounded: bool): + for bar in bar_windows: + bar.set_corners_rounded(rounded) + + +@Application.action() +def set_bar_corners_rounded(): + _set_all_bars_rounded(True) + + +@Application.action() +def set_bar_corners_flat(): + _set_all_bars_rounded(False) + + +@Application.action() +def toggle_bar_corners(): + new_state = not any(bar.corners_rounded for bar in bar_windows) + _set_all_bars_rounded(new_state) + # Load CSS - use Stylix if enabled, otherwise use default if STYLIX.get("enable", False): stylix_css_path = get_stylix_css_path() if stylix_css_path: logger.info("[Bar] Using Stylix CSS") app.set_stylesheet_from_file(get_relative_path("styles/main.css")) - app.set_stylesheet_from_file(stylix_css_path) + app.set_stylesheet_from_file(stylix_css_path, append=True) else: logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default") app.set_stylesheet_from_file(get_relative_path("styles/main.css")) diff --git a/sims/modules/bar.py b/sims/modules/bar.py index 8018232..abdeb85 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -17,6 +17,7 @@ from fabric.system_tray.widgets import SystemTray from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow from sims.services.fenster import get_i3_connection from sims.services.screenrec import ScreenrecService +from sims.services.smart_corners import get_smart_corners_service from fabric.widgets.circularprogressbar import CircularProgressBar from sims.services.system_stats import SystemStatsService @@ -41,6 +42,8 @@ class StatusBar(Window): all_visible=False, monitor=monitor, ) + self.output = display + self._corners_rounded = False self.workspaces = FensterWorkspaces( output=display, @@ -139,7 +142,7 @@ class StatusBar(Window): if WINDOW_TITLE["enable"]: center_children.append(self.active_window) - self.children = CenterBox( + self.inner = CenterBox( name="sims-inner", start_children=Box( name="start-container", @@ -163,6 +166,7 @@ class StatusBar(Window): children=end_container_children, ), ) + self.children = self.inner # Create system stats service with signal-based updates self.system_stats_service = SystemStatsService(update_interval=3000) @@ -171,6 +175,10 @@ class StatusBar(Window): # Set the bar height self.set_size_request(-1, BAR_HEIGHT) + smart_corners = get_smart_corners_service() + smart_corners.connect("state-changed", self._on_smart_corners_changed) + self.set_corners_rounded(not smart_corners.get(display)) + self.show_all() def __del__(self): @@ -178,6 +186,24 @@ class StatusBar(Window): if hasattr(self, 'calendar_service'): self.calendar_service.stop_monitoring() + @property + def corners_rounded(self) -> bool: + return self._corners_rounded + + def set_corners_rounded(self, rounded: bool) -> None: + if rounded == self._corners_rounded: + return + if rounded: + self.inner.add_style_class("rounded-bottom") + else: + self.inner.remove_style_class("rounded-bottom") + self._corners_rounded = rounded + + def _on_smart_corners_changed(self, _service, output: str, active: bool): + if output != self.output: + return + self.set_corners_rounded(not active) + def update_progress_bars(self, service, cpu_percent, memory_percent): """Update progress bars when system stats change""" self.cpu_progress_bar.value = cpu_percent diff --git a/sims/modules/launcher/windows.py b/sims/modules/launcher/windows.py index 9c2798b..9ead65c 100644 --- a/sims/modules/launcher/windows.py +++ b/sims/modules/launcher/windows.py @@ -1,4 +1,4 @@ -from fabric.i3 import I3, I3MessageType +from sims.services.i3 import I3, I3MessageType from fabric.widgets.box import Box from fabric.widgets.label import Label from gi.repository import Gtk diff --git a/sims/modules/stylix.py b/sims/modules/stylix.py index aab5bc3..27fb657 100644 --- a/sims/modules/stylix.py +++ b/sims/modules/stylix.py @@ -65,6 +65,12 @@ def generate_stylix_css(): border-bottom: solid 2px; border-color: #{colors["base02"]}; background-color: #{colors["base00"]}; + border-radius: 0; + transition: border-radius 200ms ease; +}} + +#sims-inner.rounded-bottom {{ + border-radius: 0 0 28px 28px; }} #center-container {{ diff --git a/sims/services/fenster.py b/sims/services/fenster.py index fe86ee2..3d62c5f 100644 --- a/sims/services/fenster.py +++ b/sims/services/fenster.py @@ -5,7 +5,7 @@ Provides a singleton I3 connection configured for Fenster's SWAYSOCK. """ import os -from fabric.i3 import I3 +from sims.services.i3 import I3 _connection: I3 | None = None diff --git a/sims/services/i3.py b/sims/services/i3.py new file mode 100644 index 0000000..69e4c66 --- /dev/null +++ b/sims/services/i3.py @@ -0,0 +1,245 @@ +"""Vendored i3/sway IPC client (originally from fabric.i3). + +Maintained in-tree so we can extend `I3MessageType` with fenster-specific +event types without monkey-patching upstream fabric. To add a new event: + +1. Add a new `*_EVENT` member to `I3MessageType` with the wire type number + (`0x80000000 | `). +2. Make sure fenster broadcasts it under the matching atom and accepts the + subscription string (the auto-derived name = enum-name lowercased without + `_event`). +3. Subscribers connect to `event::` (or `event::::` for + sub-events). +""" +import os +import json +import socket +import struct +from enum import IntEnum +from loguru import logger +from typing import ParamSpec +from dataclasses import dataclass +from fabric.core.service import Service, Signal, Property +from fabric.utils.helpers import exec_shell_command, idle_add +from gi.repository import GLib + +P = ParamSpec("P") + +SOCKET_MAGIC = b"i3-ipc" + + +# exceptions +class I3Error(Exception): ... + + +class I3SocketError(I3Error): ... + + +class I3SocketNotFoundError(I3SocketError): ... + + +class I3MessageType(IntEnum): + # commands + COMMAND = 0 + GET_WORKSPACES = 1 + SUBSCRIBE = 2 + GET_OUTPUTS = 3 + GET_TREE = 4 + GET_MARKS = 5 + GET_BAR_CONFIG = 6 + GET_VERSION = 7 + GET_BINDING_MODES = 8 + GET_CONFIG = 9 + SEND_TICK = 10 + SYNC = 11 + GET_BINDING_STATE = 12 + # sway only + GET_INPUTS = 100 + GET_SEATS = 101 + + # events + WORKSPACE_EVENT = 0x80000000 + OUTPUT_EVENT = 0x80000001 + MODE_EVENT = 0x80000002 + WINDOW_EVENT = 0x80000003 + BARCONFIG_UPDATE_EVENT = 0x80000004 + BINDING_EVENT = 0x80000005 + SHUTDOWN_EVENT = 0x80000006 + TICK_EVENT = 0x80000007 + # sway only + BAR_STATE_UPDATE_EVENT = 0x80000014 + INPUT_EVENT = 0x80000015 + # fenster extensions (event id 100+) + SMART_CORNERS_EVENT = 0x80000064 + + +@dataclass(frozen=True) +class I3Event: + name: str + "the name of the received event" + data: dict + "the json data gotten from event's body" + raw_data: bytes + "the raw json data" + + +@dataclass(frozen=True) +class I3Reply: + command: str + "the passed in command" + reply: dict | list + "the raw reply from i3/sway as a dict or list" + is_ok: bool + "this indicates if the ran command has returned a success message" + + +class I3(Service): + """ + A connection to the i3/Sway's IPC socket. + This can be used for sending commands and receiving events. + """ + + SOCKET_PATH: str | None = None + + @Property(bool, "readable", "is-ready", default_value=False) + def ready(self) -> bool: + return self._ready + + @Signal("event", flags="detailed") + def event(self, event: object): ... + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._ready = False + self.lookup_socket() + + self.event_socket_thread = GLib.Thread.new( + "i3-socket-service", + self.event_socket_task, # type: ignore + self.SOCKET_PATH, + ) + + self._ready = True + self.notify("ready") + + @staticmethod + def lookup_socket() -> str: + if I3.SOCKET_PATH: + return I3.SOCKET_PATH + + for cmd in ("sway", "i3"): + path = exec_shell_command(f"{cmd} --get-socketpath") + if not path or not (path := path.strip()) or not os.path.exists(path): + continue + + I3.SOCKET_PATH = path + + return I3.SOCKET_PATH + + raise I3SocketNotFoundError( + "Couldn't find i3 or Sway socket, is either of them running?" + ) + + @staticmethod + def pack(message_type: I3MessageType, payload: str = "") -> bytes: + payload_bytes = payload.encode() + header = struct.pack(" tuple[int, str]: + header = connection.recv(14) + if len(header) != 14: + raise I3SocketError("Failed to read IPC header") + + magic, length, message_type = struct.unpack("<6sII", header) + if magic != SOCKET_MAGIC: + raise I3SocketError(f"Invalid IPC magic string ({magic}). Report this!") + + return message_type, connection.recv(length).decode() + + @staticmethod + def send_command( + command: str, message_type: I3MessageType = I3MessageType.COMMAND + ) -> I3Reply: + """ + Sends a command to the i3/sway socket. + + example usage: + ```python + # next workspace... + I3.send_command("workspace next") + ``` + :param command: The command to send. + :type command: str + :param message_type: The type of message to send. + :type message_type: I3MessageType, optional + :return: A reply object containing the data from i3/sway. + :rtype: I3Reply + """ + reply_data = {} + is_ok = False + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.connect(I3.lookup_socket()) + sock.sendall(I3.pack(message_type, command)) + + _, payload = I3.unpack(sock) + reply_data = json.loads(payload) + + # results for any GET_* command is considered ok + # other commands a success reply is a list of dicts with {"success": True} + if (message_type != I3MessageType.COMMAND) or ( + isinstance(reply_data, list) + and reply_data + and reply_data[0].get("success") + ): + is_ok = True + + except Exception as e: + logger.error( + f"[I3Service] got error while sending command via socket ({e})" + ) + + return I3Reply(command=command, reply=reply_data, is_ok=is_ok) + + def event_socket_task(self, socket_addr: str) -> bool: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.connect(socket_addr) + + # subscribe to all events + sock.sendall( + self.pack( + I3MessageType.SUBSCRIBE, + json.dumps( + [ + evnt_name.replace("_event", "") + for mt in I3MessageType + if (evnt_name := mt.name.lower()).endswith("_event") + ] + ), + ) + ) + self.unpack(sock) # success reply + + while True: + idle_add(self.handle_raw_event, *self.unpack(sock)) + + except Exception as e: + logger.warning(f"[I3Service] events socket thread ended with an error: {e}") + + return False + + def handle_raw_event(self, message_type: int, payload: str): + event_data = json.loads(payload) + event_name = I3MessageType(message_type).name.lower().replace("_event", "") + + if "change" in event_data: # subevents + event_name = f"{event_name}::{event_data['change']}" + + return self.emit( + f"event::{event_name}", + I3Event(event_name, event_data, payload.encode()), + ) diff --git a/sims/services/screenrec.py b/sims/services/screenrec.py index 7510407..81e0712 100644 --- a/sims/services/screenrec.py +++ b/sims/services/screenrec.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import Literal from fabric.core.service import Service, Signal -from fabric.i3 import I3, I3MessageType +from sims.services.i3 import I3, I3MessageType from gi.repository import GLib from loguru import logger diff --git a/sims/services/smart_corners.py b/sims/services/smart_corners.py new file mode 100644 index 0000000..88e44c6 --- /dev/null +++ b/sims/services/smart_corners.py @@ -0,0 +1,48 @@ +"""Smart-corners IPC subscriber. + +Listens to fenster's `:smart_corners` event and emits a per-output signal +when the WM's smart-corners state flips. Caches the latest state per output +so widgets created after the event can ask for the current value. +""" +from fabric.core.service import Service, Signal +from loguru import logger + +from sims.services.fenster import get_i3_connection + + +_service: "SmartCornersService | None" = None + + +class SmartCornersService(Service): + @Signal + def state_changed(self, output: str, active: bool) -> None: + """Emitted when a fenster output flips smart-corners state.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._state: dict[str, bool] = {} + i3 = get_i3_connection() + i3.connect("event::smart_corners", self._on_event) + + def get(self, output: str) -> bool: + """Latest known state for an output, or False if unseen.""" + return self._state.get(output, False) + + def _on_event(self, _i3, event): + change = event.data.get("change") + output = event.data.get("output") + if not isinstance(output, str) or change not in ("active", "inactive"): + logger.warning(f"[SmartCorners] unexpected event payload: {event.data!r}") + return + active = change == "active" + if self._state.get(output) == active: + return + self._state[output] = active + self.state_changed(output, active) + + +def get_smart_corners_service() -> SmartCornersService: + global _service + if _service is None: + _service = SmartCornersService() + return _service diff --git a/sims/styles/bar.css b/sims/styles/bar.css index 17b4f2e..4203b6a 100644 --- a/sims/styles/bar.css +++ b/sims/styles/bar.css @@ -3,6 +3,12 @@ border-bottom: solid 2px; border-color: var(--border-color); background-color: var(--window-bg); + border-radius: 0; + transition: border-radius 200ms ease; +} + +#sims-inner.rounded-bottom { + border-radius: 0 0 28px 28px; } #center-container { diff --git a/sims/widgets/fenster.py b/sims/widgets/fenster.py index 80b6050..0b141cf 100644 --- a/sims/widgets/fenster.py +++ b/sims/widgets/fenster.py @@ -4,7 +4,7 @@ Fenster widgets for workspace and window management via sway IPC. from gi.repository import GLib -from fabric.i3 import I3, I3Event, I3MessageType +from sims.services.i3 import I3, I3Event, I3MessageType from fabric.utils.helpers import bulk_connect from fabric.widgets.box import Box from fabric.widgets.button import Button