feat: round bar on smart corners

also take in the i3 ipc code to support custom messages
This commit is contained in:
2026-05-04 11:07:34 +02:00
parent 6343c91fca
commit af978f5baf
11 changed files with 380 additions and 7 deletions

View File

@@ -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)

View File

@@ -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"))

View File

@@ -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

View File

@@ -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

View File

@@ -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 {{

View File

@@ -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

245
sims/services/i3.py Normal file
View File

@@ -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 | <event_id>`).
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::<name>` (or `event::<name>::<change>` 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("<II", len(payload_bytes), message_type.value)
return SOCKET_MAGIC + header + payload_bytes
@staticmethod
def unpack(connection: socket.socket) -> 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()),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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