Compare commits

27 Commits

Author SHA1 Message Date
6da7e97f19 feat: mail finder 2026-05-06 23:12:57 +02:00
cfdcf0c039 feat: email debt timer 2026-05-06 21:22:27 +02:00
4adace6c4c feat: subscribe to all players 2026-05-05 23:58:15 +02:00
cc3bac5ce7 feat: player animations 2026-05-05 23:47:47 +02:00
81e1a1fc1f feat: mpris player widget 2026-05-05 23:15:09 +02:00
d14a0c9678 fix: start apps not in this service cgroup 2026-05-05 20:50:18 +02:00
47e104465e feat: control center 2026-05-05 00:19:10 +02:00
4a271ac4d8 feat: notification markup support 2026-05-04 23:05:23 +02:00
5e8f9e44bb fix: drop full emacs runtime dep, only emacsclient is used
sims only invokes emacsclient (in modules/notmuch.py) and never the
full emacs binary. Bundling emacs onto the wrapper PATH was forcing
consumers to pull in an extra ~310M emacs closure (often a different
version from the user's actual editor). Rely on the consumer's
emacsclient via PATH, configurable via services.sims.settings.notmuch.emacsclient_command.
2026-05-04 22:56:12 +02:00
047a85925a feat: sims notifications 2026-05-04 21:31:09 +02:00
4c8b9020b0 fix: start apps in subprocess 2026-05-04 20:06:52 +02:00
e7a1a2c0ee feat: spawn layer shells on correct output 2026-05-04 19:50:06 +02:00
cb3b7bf392 fix: correct smart corner events 2026-05-04 19:40:39 +02:00
f83b7ce874 fix: launchers should have exclusive focus 2026-05-04 19:29:56 +02:00
ec7e2cd6fd fix: render more of launchers 2026-05-04 19:15:26 +02:00
6f4e397f6c feat: clipboard menu 2026-05-04 17:01:08 +02:00
aad8121fa4 fix: only show screenrec when recording 2026-05-04 12:09:39 +02:00
709695fd22 fix: dont ship weird default styles 2026-05-04 12:03:48 +02:00
1d98a541de fix: click on workspace 2026-05-04 11:35:14 +02:00
af978f5baf feat: round bar on smart corners
also take in the i3 ipc code to support custom messages
2026-05-04 11:07:34 +02:00
6343c91fca fix: instantly spawn bar 2026-05-04 00:00:11 +02:00
0cd58f4a1f feat: sims cli as python package 2026-05-03 22:19:52 +02:00
afcf8d51fe feat: power and screenshot launchers 2026-05-03 21:53:10 +02:00
7bdf23001f feat: add app launcher 2026-05-03 21:42:30 +02:00
60757ee336 feat: sims launcher 2026-05-03 20:40:21 +02:00
c10709df26 feat: rename to sims 2026-05-03 20:15:43 +02:00
fb206667c4 feat: dim other monitors workspaces 2026-05-03 19:05:46 +02:00
67 changed files with 3834 additions and 1130 deletions

View File

@@ -1,2 +1,15 @@
run: run:
python -m bar.main --config ./example-stylix-dev.yaml python -m sims.main --config ./example-stylix-dev.yaml
# Talk to the running sims daemon over DBus.
# Usage: make cli list
# make cli finder
# make cli screenrec stop
# make cli ARGS="list --json" # for flags (make eats leading dashes)
ifeq (cli,$(firstword $(MAKECMDGOALS)))
CLI_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
$(eval $(CLI_ARGS):;@:)
endif
cli:
@python -m sims.cli $(if $(ARGS),$(ARGS),$(CLI_ARGS))

View File

@@ -1,91 +0,0 @@
from loguru import logger
# Configure logging based on dev flag
from .config import DEV, LOG_LEVEL
if DEV:
# In dev mode, disable fabric logs but keep stylix and bar logs
logger.disable("fabric")
else:
# In production, disable fabric logs but keep bar logs with configurable level
import sys
logger.disable("fabric")
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 fabric.system_tray.widgets import SystemTray
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.utils import (
get_relative_path,
)
from .modules.bar import StatusBar
from .modules.window_fuzzy import FuzzyWindowFinder
from .modules.stylix import get_stylix_css_path
from .config import STYLIX
from .services.fenster import get_i3_connection
tray = SystemTray(name="system-tray", spacing=4)
i3 = get_i3_connection()
dummy = Window(visible=False)
finder = FuzzyWindowFinder()
bar_windows = []
notmuch_widget = None
app = Application("bar", dummy, finder)
# 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")
# Load base styles first for structure
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
# Then apply Stylix theme colors
app.set_stylesheet_from_file(stylix_css_path)
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"))
else:
logger.info("[Bar] Using default CSS")
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
def spawn_bars():
global notmuch_widget
logger.info("[Bar] Spawning bars")
outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)):
logger.warning("[Bar] Failed to get outputs — skipping bar spawn")
return
outputs = [o for o in outputs_reply.reply if o.get("active")]
if not outputs:
logger.warning("[Bar] No active outputs found — skipping bar spawn")
return
for i, output in enumerate(outputs):
output_name = output.get("name", f"Unknown-{i}")
bar = StatusBar(display=output_name, tray=tray if i == 0 else None, monitor=i)
bar_windows.append(bar)
if i == 0 and bar.notmuch:
notmuch_widget = bar.notmuch
return False
def main():
if i3.ready:
spawn_bars()
else:
i3.connect("notify::ready", lambda *_: spawn_bars())
app.run()
if __name__ == "__main__":
main()

View File

@@ -1,283 +0,0 @@
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gtk
from loguru import logger
class QuickMenuItem(Box):
"""Base class for quick menu items"""
def __init__(self, title, icon_name=None, **kwargs):
super().__init__(
orientation="h",
spacing=12,
name="quick-menu-item",
**kwargs
)
self.set_style("padding: 8px 12px; min-width: 280px;")
# Icon and title on the left
left_box = Box(orientation="h", spacing=8)
if icon_name:
icon = Image(icon_name=icon_name, icon_size=16)
left_box.add(icon)
self.title_label = Label(title)
self.title_label.set_style("font-size: 14px;")
left_box.add(self.title_label)
self.add(left_box)
# Derived classes can add controls to the right side
class QuickMenuToggle(QuickMenuItem):
"""A menu item with a toggle switch"""
def __init__(self, title, icon_name=None, active=False, on_toggle=None, **kwargs):
super().__init__(title, icon_name, **kwargs)
# Create a custom toggle using a button with state tracking
self._active = active
self._on_toggle = on_toggle
# Create toggle indicator box
self.toggle_box = Box(
orientation="h",
spacing=0
)
self.toggle_box.set_style("min-width: 44px; min-height: 24px; border-radius: 12px; padding: 2px;")
# Toggle indicator (circle)
self.toggle_indicator = Label("")
self.toggle_indicator.set_style("min-width: 20px; min-height: 20px; border-radius: 10px; background: white;")
self.toggle_box.add(self.toggle_indicator)
# Make it clickable
self.toggle_button = Button(
child=self.toggle_box,
on_clicked=self._on_click
)
self.toggle_button.set_style("background: transparent; border: none; padding: 0;")
# Add spacer to push toggle to the right
spacer = Label("", h_expand=True)
self.add(spacer)
self.add(self.toggle_button)
# Set initial state
self._update_appearance()
def _on_click(self, button):
self._active = not self._active
self._update_appearance()
if self._on_toggle:
self._on_toggle(self._active)
def _update_appearance(self):
if self._active:
self.toggle_box.set_style_classes(["toggle-active"])
self.toggle_box.set_style(
"min-width: 44px; min-height: 24px; border-radius: 12px; padding: 2px; "
"transition: all 0.2s;"
)
self.toggle_indicator.set_style(
"min-width: 20px; min-height: 20px; border-radius: 10px; "
"background: white; margin-left: 20px; transition: all 0.2s;"
)
else:
self.toggle_box.set_style_classes(["toggle-inactive"])
self.toggle_box.set_style(
"min-width: 44px; min-height: 24px; border-radius: 12px; padding: 2px; "
"transition: all 0.2s;"
)
self.toggle_indicator.set_style(
"min-width: 20px; min-height: 20px; border-radius: 10px; "
"background: white; margin-left: 0px; transition: all 0.2s;"
)
def set_active(self, active):
self._active = active
self._update_appearance()
def get_active(self):
return self._active
class QuickMenuButton(QuickMenuItem):
"""A menu item that acts as a button"""
def __init__(self, title, icon_name=None, on_click=None, **kwargs):
super().__init__(title, icon_name, **kwargs)
if on_click:
# Make the entire item clickable
button_overlay = Button(
child=Box(), # Empty box as child
on_clicked=on_click
)
button_overlay.set_style("background: transparent; border: none; padding: 0; margin: 0;")
# Add arrow indicator on the right
arrow = Label("")
arrow.set_style("font-size: 18px; opacity: 0.5;")
spacer = Label("", h_expand=True)
self.add(spacer)
self.add(arrow)
class QuickMenuSection(Box):
"""A section in the quick menu with optional title"""
def __init__(self, title=None, **kwargs):
super().__init__(
orientation="v",
spacing=4,
name="quick-menu-section",
**kwargs
)
if title:
title_label = Label(
title,
name="section-title"
)
title_label.set_style("font-size: 12px; opacity: 0.6; padding: 8px 12px 4px 12px; font-weight: bold;")
self.add(title_label)
self.items_box = Box(orientation="v", spacing=2)
self.add(self.items_box)
def add_item(self, item):
self.items_box.add(item)
class QuickMenu(Window):
def __init__(self, **kwargs):
super().__init__(
name="quick-menu",
layer="overlay", # Changed from 'top' to 'overlay' for better shadow support
anchor="top right",
margin="40px 10px 0px 0px",
exclusivity="none",
visible=False,
all_visible=False,
style_classes=["popup-window"],
**kwargs,
)
# Main container
self.main_box = Box(
orientation="v",
spacing=8,
name="quick-menu-container"
)
# Remove redundant styling since it's handled in stylix.css
pass
# Title
title_box = Box(
orientation="h",
spacing=8
)
title_box.set_style("padding: 12px;")
title = Label("Quick Menu")
title.set_style("font-size: 16px; font-weight: bold;")
title_box.add(title)
self.main_box.add(title_box)
# Add a simple divider line
divider = Label("")
divider.set_style("min-height: 1px; background: rgba(255,255,255,0.1); margin: 0px 12px;")
self.main_box.add(divider)
# Sections container
self.sections_container = Box(
orientation="v",
spacing=8
)
self.sections_container.set_style("padding: 8px 0px;")
self.main_box.add(self.sections_container)
self.children = self.main_box
self.set_size_request(360, -1)
# Store references to dynamic items
self.vinyl_toggle = None
self.sections = {}
def add_section(self, section_id, title=None):
"""Add a new section to the menu"""
section = QuickMenuSection(title=title)
self.sections[section_id] = section
self.sections_container.add(section)
# Add separator before section if not the first
if len(self.sections) > 1:
separator = Label("")
separator.set_style("min-height: 1px; background: rgba(255,255,255,0.1); margin: 4px 12px;")
self.sections_container.add(separator)
return section
def setup_audio_section(self, vinyl_service=None):
"""Setup the audio controls section"""
audio_section = self.add_section("audio", None) # No section title since it's the only section
# Vinyl passthrough toggle
if vinyl_service:
self.vinyl_toggle = QuickMenuToggle(
title="Vinyl Passthrough",
icon_name="folder-music-symbolic",
active=vinyl_service.active,
on_toggle=lambda active: self._on_vinyl_toggle(active, vinyl_service)
)
audio_section.add_item(self.vinyl_toggle)
# Store reference to vinyl service
self.vinyl_service = vinyl_service
def _on_vinyl_toggle(self, active, vinyl_service):
"""Handle vinyl toggle"""
logger.info(f"[QuickMenu] Vinyl toggled: {active}")
vinyl_service.active = active
def setup_system_section(self):
"""Setup system controls section"""
# Removed for now - can add system controls later
pass
def update_vinyl_state(self, active):
"""Update vinyl toggle state from external source"""
if self.vinyl_toggle:
self.vinyl_toggle.set_active(active)
class QuickMenuOpener(Button):
"""Button to open the quick menu"""
def __init__(self, icon_name="open-menu-symbolic", **kwargs):
super().__init__(
name="quick-menu-button",
child=Image(icon_name=icon_name, icon_size=16),
on_clicked=self.toggle_menu,
**kwargs
)
self.menu = QuickMenu()
self.menu_visible = False
def toggle_menu(self, button=None):
"""Toggle the quick menu visibility"""
if self.menu_visible:
logger.info("[QuickMenu] Hiding menu")
self.menu.set_visible(False)
self.menu_visible = False
else:
logger.info("[QuickMenu] Showing menu")
self.menu.set_visible(True)
self.menu.show_all()
self.menu_visible = True
def get_menu(self):
"""Get the menu instance for configuration"""
return self.menu

View File

@@ -1,128 +0,0 @@
from fabric.i3 import I3, I3MessageType
from fabric.widgets.wayland import WaylandWindow as Window
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.entry import Entry
from gi.repository import Gdk
from bar.services.fenster import get_i3_connection
class FuzzyWindowFinder(Window):
def __init__(
self,
monitor: int = 0,
):
super().__init__(
name="finder",
anchor="center",
monitor=monitor,
keyboard_mode="on-demand",
type="popup",
visible=False,
)
self._i3 = get_i3_connection()
self._all_windows = []
self._refresh_windows()
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
placeholder="Search Windows...",
h_expand=True,
editable=True,
notify_text=self.notify_text,
on_activate=lambda entry, *_: self.on_search_entry_activate(
entry.get_text()
),
on_key_press_event=self.on_search_entry_key_press,
)
self.picker_box = Box(
name="picker-box",
spacing=4,
orientation="v",
children=[self.search_entry, self.viewport],
)
self.add(self.picker_box)
self.arrange_viewport("")
def _refresh_windows(self):
"""Refresh the window list via GET_TREE"""
self._all_windows = []
tree_reply = I3.send_command("", I3MessageType.GET_TREE)
if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)):
return
tree = tree_reply.reply
# Traverse: root → outputs → workspaces → containers
for output_node in tree.get("nodes", []):
for ws_node in output_node.get("nodes", []):
ws_num = ws_node.get("num", 0)
for con in ws_node.get("nodes", []):
if con.get("type") == "con":
self._all_windows.append({
"id": con.get("id"),
"app_id": con.get("app_id", ""),
"title": con.get("name", ""),
"workspace": ws_num,
})
for con in ws_node.get("floating_nodes", []):
if con.get("type") == "con":
self._all_windows.append({
"id": con.get("id"),
"app_id": con.get("app_id", ""),
"title": con.get("name", ""),
"workspace": ws_num,
})
def show(self):
"""Override show to refresh windows before displaying"""
self._refresh_windows()
self.arrange_viewport(self.search_entry.get_text())
super().show()
def notify_text(self, entry, *_):
text = entry.get_text()
self.arrange_viewport(text)
def on_search_entry_key_press(self, widget, event):
if event.keyval in [Gdk.KEY_Escape, 103]:
self.hide()
return True
return False
def on_search_entry_activate(self, text):
"""Focus the first matching window"""
filtered = self._filter_windows(text)
if filtered:
window_id = filtered[0].get("id")
if window_id is not None:
I3.send_command(f"[con_id={window_id}] focus")
self.hide()
def _filter_windows(self, query: str) -> list:
"""Filter windows based on query matching title or app_id"""
if not query:
return self._all_windows
query_lower = query.lower()
return [
w for w in self._all_windows
if query_lower in w.get("title", "").lower()
or query_lower in w.get("app_id", "").lower()
]
def arrange_viewport(self, query: str = ""):
self.viewport.children = [] # Clear previous entries
filtered = self._filter_windows(query)
for window in filtered:
title = window.get("title", "")
app_id = window.get("app_id", "")
ws_num = window.get("workspace", 0)
display_text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}"
self.viewport.add(
Box(name="slot-box", orientation="h", children=[Label(label=display_text)])
)

View File

@@ -1,29 +0,0 @@
"""
Fenster/Sway IPC connection helper.
Provides a singleton I3 connection configured for Fenster's SWAYSOCK.
"""
import os
from fabric.i3 import I3
_connection: I3 | None = None
def get_i3_connection() -> I3:
"""Get the singleton I3 connection, configured for Fenster."""
global _connection
if _connection is None:
swaysock = os.environ.get("SWAYSOCK")
if swaysock:
I3.SOCKET_PATH = swaysock
elif not I3.SOCKET_PATH:
runtime_dir = os.environ.get(
"XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"
)
fallback = os.path.join(runtime_dir, "fenster.sock")
if os.path.exists(fallback):
I3.SOCKET_PATH = fallback
_connection = I3()
return _connection

View File

@@ -1,29 +0,0 @@
#picker-box {
padding: 12px;
background-color: rgba(40, 40, 40, 0.95); /* darker for contrast */
border-radius: 8px;
font-family: sans-serif;
font-size: 14px;
color: white;
}
#viewport {
padding: 8px;
background-color: rgba(30, 30, 30, 0.9); /* dark background for contrast */
border-radius: 6px;
font-family: sans-serif;
font-size: 14px;
color: white; /* ensure contrast */
}
#viewport > * {
padding: 6px 10px;
margin-bottom: 4px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
}
#viewport:hover {
background-color: rgba(255, 255, 255, 0.15); /* hover feedback */
}

View File

@@ -1,9 +1,9 @@
height: 42 height: 42
dev: true dev: true
window_title: window_title:
enable: false enable: true
vinyl: vinyl:
enable: false enable: true
battery: battery:
enable: true enable: true
calendar: calendar:
@@ -13,6 +13,18 @@ notmuch:
enable: true enable: true
notmuch_path: "notmuch" # or full path like "/home/user/.nix-profile/bin/notmuch" notmuch_path: "notmuch" # or full path like "/home/user/.nix-profile/bin/notmuch"
emacsclient_command: "emacsclient" # or full path like "/home/user/.nix-profile/bin/emacsclient" emacsclient_command: "emacsclient" # or full path like "/home/user/.nix-profile/bin/emacsclient"
screenrec:
enable: true
output_dir: "~/Videos/wl-screenrec"
notifications:
enable: true
anchor: "top center"
margin: "8px"
width: 360
timeout_ms: 10000
history_size: 50
image_max_px: 128
center_width: 380
stylix: stylix:
enable: true enable: true
colors: colors:

146
flake.nix
View File

@@ -1,5 +1,5 @@
{ {
description = "Fabric Bar Example"; description = "sims status bar (companion to fenster WM).";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/24.11"; nixpkgs.url = "github:NixOS/nixpkgs/24.11";
@@ -32,24 +32,19 @@
{ {
formatter = pkgs.nixfmt-rfc-style; formatter = pkgs.nixfmt-rfc-style;
devShells.default = pkgs.callPackage ./nix/shell.nix { inherit pkgs; }; devShells.default = pkgs.callPackage ./nix/shell.nix { inherit pkgs; };
packages = { packages = rec {
default = pkgs.callPackage ./nix/derivation.nix { inherit (pkgs) lib python3Packages; }; default = pkgs.callPackage ./nix/derivation.nix { inherit (pkgs) lib python3Packages; };
makku = pkgs.writeShellScriptBin "makku" '' sims = default;
dbus-send --session --print-reply --dest=org.Fabric.fabric.bar /org/Fabric/fabric org.Fabric.fabric.Evaluate string:"finder.show()" > /dev/null 2>&1
'';
notmuch-refresh = pkgs.writeShellScriptBin "notmuch-refresh" ''
dbus-send --session --print-reply --dest=org.Fabric.fabric.bar /org/Fabric/fabric org.Fabric.fabric.Evaluate string:"notmuch_widget.service.update_unread_count() if notmuch_widget else None" > /dev/null 2>&1
'';
}; };
apps.default = { apps.default = {
type = "app"; type = "app";
program = "${self.packages.${system}.default}/bin/bar"; program = "${self.packages.${system}.default}/bin/sims";
}; };
} }
) )
// { // {
homeManagerModules = { homeManagerModules = {
makku-bar = sims =
{ {
config, config,
lib, lib,
@@ -57,18 +52,18 @@
... ...
}: }:
let let
cfg = config.services.makku-bar; cfg = config.services.sims;
settingsFormat = pkgs.formats.yaml { }; settingsFormat = pkgs.formats.yaml { };
in in
{ {
options.services.makku-bar = { options.services.sims = {
enable = lib.mkEnableOption "makku-bar status bar"; enable = lib.mkEnableOption "sims status bar";
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = self.packages.${pkgs.system}.default; default = self.packages.${pkgs.system}.default;
description = "The makku-bar package to use."; description = "The sims package to use.";
}; };
settings = lib.mkOption { settings = lib.mkOption {
@@ -136,6 +131,98 @@
default = "emacsclient"; default = "emacsclient";
description = "Path to the emacsclient binary"; description = "Path to the emacsclient binary";
}; };
debt_query = lib.mkOption {
type = lib.types.str;
default = "tag:unread and date:..1w";
description = "notmuch query whose count drives the mail-debt severity color on the bar widget";
};
debt_warn_at = lib.mkOption {
type = lib.types.int;
default = 1;
description = "Debt count at which the widget switches to the warn (orange) color";
};
debt_alarm_at = lib.mkOption {
type = lib.types.int;
default = 6;
description = "Debt count at which the widget switches to the alarm (red) color";
};
saved_searches = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display label shown in the search launcher";
};
query = lib.mkOption {
type = lib.types.str;
description = "notmuch query to run when this saved search is activated";
};
};
});
default = [ ];
description = "Saved searches shown in the notmuch search launcher when the entry is empty";
};
};
screenrec = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the screenrec widget and menu";
};
output_dir = lib.mkOption {
type = lib.types.str;
default = "~/Videos/wl-screenrec";
description = "Directory to save recordings into";
};
};
power = {
lock_command = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "waylock" ];
description = "argv for the Lock action in the power menu";
};
};
notifications = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
};
anchor = lib.mkOption {
type = lib.types.str;
default = "top center";
description = "Layer-shell anchor for the toast stack";
};
margin = lib.mkOption {
type = lib.types.str;
default = "8px";
description = "Layer-shell margin for the toast stack";
};
width = lib.mkOption {
type = lib.types.int;
default = 360;
description = "Width of each notification toast in pixels";
};
timeout_ms = lib.mkOption {
type = lib.types.int;
default = 10000;
description = "Auto-close timeout for notifications in milliseconds";
};
history_size = lib.mkOption {
type = lib.types.int;
default = 50;
description = "How many past notifications the in-memory center keeps";
};
image_max_px = lib.mkOption {
type = lib.types.int;
default = 128;
description = "Max edge in pixels for stored notification thumbnails";
};
center_width = lib.mkOption {
type = lib.types.int;
default = 380;
description = "Width of the notification center side rail in pixels";
};
}; };
}; };
}; };
@@ -154,24 +241,45 @@
enable = true; enable = true;
notmuch_path = "notmuch"; notmuch_path = "notmuch";
emacsclient_command = "emacsclient"; emacsclient_command = "emacsclient";
debt_query = "tag:unread and date:..1w";
debt_warn_at = 1;
debt_alarm_at = 6;
saved_searches = [ ];
};
screenrec = {
enable = false;
output_dir = "~/Videos/wl-screenrec";
};
power = {
lock_command = [ "waylock" ];
};
notifications = {
enable = false;
anchor = "top center";
margin = "8px";
width = 360;
timeout_ms = 10000;
history_size = 50;
image_max_px = 128;
center_width = 380;
}; };
}; };
}; };
}; };
config = lib.mkIf config.services.makku-bar.enable { config = lib.mkIf config.services.sims.enable {
systemd.user.services.makku-bar = systemd.user.services.sims =
let let
configFile = settingsFormat.generate "config.yaml" cfg.settings; configFile = settingsFormat.generate "config.yaml" cfg.settings;
in in
{ {
Unit = { Unit = {
Description = "Makku Status Bar"; Description = "sims status bar";
After = [ "graphical-session.target" ]; After = [ "graphical-session.target" ];
}; };
Service = { Service = {
ExecStart = "${config.services.makku-bar.package}/bin/bar --config ${configFile}"; ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
Restart = "on-failure"; Restart = "on-failure";
}; };
@@ -181,7 +289,7 @@
}; };
}; };
}; };
stylix-makku-bar = import ./nix/stylix/hm.nix; stylix-sims = import ./nix/stylix/hm.nix;
}; };
}; };
} }

View File

@@ -14,12 +14,12 @@
webp-pixbuf-loader, webp-pixbuf-loader,
notmuch, notmuch,
khal, khal,
emacs, dbus,
... ...
}: }:
python3Packages.buildPythonApplication { python3Packages.buildPythonApplication {
pname = "fabric-nix-example"; pname = "sims";
version = "0.0.1"; version = "0.0.1";
pyproject = true; pyproject = true;
@@ -58,12 +58,14 @@ python3Packages.buildPythonApplication {
runHook preInstall runHook preInstall
mkdir -p $out/${python3Packages.python.sitePackages} mkdir -p $out/${python3Packages.python.sitePackages}
cp -r bar $out/${python3Packages.python.sitePackages}/ cp -r sims $out/${python3Packages.python.sitePackages}/
# If you have any scripts to install # If you have any scripts to install
mkdir -p $out/bin mkdir -p $out/bin
cp scripts/launcher.py $out/bin/bar cp scripts/launcher.py $out/bin/sims
chmod +x $out/bin/bar chmod +x $out/bin/sims
cp scripts/cli_launcher.py $out/bin/sims-cli
chmod +x $out/bin/sims-cli
runHook postInstall runHook postInstall
@@ -71,17 +73,23 @@ python3Packages.buildPythonApplication {
preFixup = '' preFixup = ''
makeWrapperArgs+=("''${gappsWrapperArgs[@]}") makeWrapperArgs+=("''${gappsWrapperArgs[@]}")
makeWrapperArgs+=(--prefix PATH : ${lib.makeBinPath [ khal notmuch emacs ]}) makeWrapperArgs+=(--prefix PATH : ${
lib.makeBinPath [
khal
notmuch
dbus
]
})
''; '';
passthru = { passthru = {
inherit khal notmuch emacs; inherit khal notmuch;
}; };
meta = { meta = {
changelog = ""; changelog = "";
description = '' description = ''
Fabrix Bar Example sims status bar (companion to fenster WM).
''; '';
homepage = "https://github.com/wholikeel/fabric"; homepage = "https://github.com/wholikeel/fabric";
license = lib.licenses.agpl3Only; license = lib.licenses.agpl3Only;

View File

@@ -22,6 +22,7 @@ pkgs.mkShell {
gobject-introspection gobject-introspection
libdbusmenu-gtk3 libdbusmenu-gtk3
gdk-pixbuf gdk-pixbuf
librsvg
gnome-bluetooth gnome-bluetooth
cinnamon-desktop cinnamon-desktop
wayland-scanner wayland-scanner
@@ -46,4 +47,8 @@ pkgs.mkShell {
] ]
)) ))
]; ];
shellHook = ''
export GDK_PIXBUF_MODULE_FILE=${pkgs.librsvg}/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache
'';
} }

View File

@@ -1,14 +1,14 @@
{ config, lib, ... }: { config, lib, ... }:
let let
cfg = config.stylix.targets.makku-bar; cfg = config.stylix.targets.sims;
in in
{ {
options.stylix.targets.makku-bar.enable = options.stylix.targets.sims.enable =
config.lib.stylix.mkEnableTarget "Makku Bar" true; config.lib.stylix.mkEnableTarget "sims" true;
config = lib.mkIf (config.stylix.enable && cfg.enable) { config = lib.mkIf (config.stylix.enable && cfg.enable) {
services.makku-bar.settings.stylix = { services.sims.settings.stylix = {
enable = true; enable = true;
colors = { colors = {
base00 = config.lib.stylix.colors.base00; # background base00 = config.lib.stylix.colors.base00; # background

View File

@@ -3,14 +3,14 @@ requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "fabric-nix-example" name = "sims"
version = "0.0.1" version = "0.0.1"
requires-python = ">= 3.11" requires-python = ">= 3.11"
authors = [ authors = [
] ]
maintainers = [ maintainers = [
] ]
description = "Fabric using Nix example." description = "sims status bar (companion to fenster WM)."
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}

20
scripts/cli_launcher.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import os
import sys
script_dir = os.path.dirname(os.path.abspath(__file__))
site_packages_dir = os.path.join(
script_dir,
os.pardir,
"lib",
f"python{sys.version_info.major}.{sys.version_info.minor}",
"site-packages",
)
if site_packages_dir not in sys.path:
sys.path.insert(0, site_packages_dir)
from sims.cli import main
sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__))
sys.exit(main())

2
scripts/launcher.py Normal file → Executable file
View File

@@ -15,7 +15,7 @@ if site_packages_dir not in sys.path:
sys.path.insert(0, site_packages_dir) sys.path.insert(0, site_packages_dir)
from bar.main import * from sims.main import *
sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__)) sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__))
sys.exit(main()) sys.exit(main())

171
sims/cli.py Normal file
View File

@@ -0,0 +1,171 @@
"""sims-cli — talk to the running sims status bar over DBus."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Callable
DEST = "org.Fabric.fabric.sims"
OBJ = "/org/Fabric/fabric"
IFACE = "org.Fabric.fabric"
def _dbus_send(*args: str) -> str:
proc = subprocess.run(
["dbus-send", "--session", "--print-reply", f"--dest={DEST}", OBJ, *args],
capture_output=True,
text=True,
)
if proc.returncode != 0:
sys.stderr.write(proc.stderr)
sys.exit(proc.returncode)
return proc.stdout
def invoke_action(action: str) -> None:
_dbus_send(f"{IFACE}.InvokeAction", f"string:{action}", "array:string:")
_ACTION_RE = re.compile(r'dict entry\(\s*string "([^"]+)"')
def list_actions() -> list[str]:
out = _dbus_send(
"org.freedesktop.DBus.Properties.Get",
f"string:{IFACE}",
"string:Actions",
)
return _ACTION_RE.findall(out)
@dataclass
class Command:
name: str
help: str
run: Callable[[argparse.Namespace], None]
def _action(name: str) -> Callable[[argparse.Namespace], None]:
return lambda _ns: invoke_action(name)
COMMANDS: list[Command] = [
Command("finder", "open window finder", _action("open-finder")),
Command("apps", "open application launcher", _action("open-app-launcher")),
Command("clipboard", "open clipboard history picker", _action("open-clipboard-menu")),
Command("power", "open power menu", _action("open-power-menu")),
Command("screenshot", "open screenshot menu", _action("open-screenshot-menu")),
Command("notmuch-refresh", "refresh unread mail count", _action("refresh-notmuch")),
]
def _cmd_screenrec(ns: argparse.Namespace) -> None:
mapping = {
"menu": "open-screenrec-menu",
"start-monitor": "screenrec-start-monitor",
"start-region": "screenrec-start-region",
"stop": "screenrec-stop",
}
invoke_action(mapping[ns.screenrec_cmd])
def _cmd_mail(ns: argparse.Namespace) -> None:
mapping = {
"search": "open-notmuch-search",
"refresh": "refresh-notmuch",
}
invoke_action(mapping[ns.mail_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:
json.dump(actions, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
for a in actions:
print(a)
def _cmd_invoke(ns: argparse.Namespace) -> None:
invoke_action(ns.action)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="sims-cli",
description="Talk to the running sims status bar over DBus.",
)
sub = parser.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
for cmd in COMMANDS:
p = sub.add_parser(cmd.name, help=cmd.help)
p.set_defaults(func=cmd.run)
rec = sub.add_parser("screenrec", help="screen recording controls")
rec_sub = rec.add_subparsers(dest="screenrec_cmd", required=True, metavar="ACTION")
for sub_name, sub_help in [
("menu", "open screenrec menu (auto-detects state)"),
("start-monitor", "start recording the focused monitor"),
("start-region", "start recording a slurp-selected region"),
("stop", "stop active recording"),
]:
rec_sub.add_parser(sub_name, help=sub_help)
rec.set_defaults(func=_cmd_screenrec)
mail = sub.add_parser("mail", help="notmuch mail controls")
mail_sub = mail.add_subparsers(dest="mail_cmd", required=True, metavar="ACTION")
for sub_name, sub_help in [
("search", "open the live notmuch search launcher"),
("refresh", "refresh the bar's unread/debt counts"),
]:
mail_sub.add_parser(sub_name, help=sub_help)
mail.set_defaults(func=_cmd_mail)
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)
inv = sub.add_parser(
"invoke",
help="invoke a raw action by name (escape hatch for actions without a dedicated subcommand)",
)
inv.add_argument("action", help="dbus action name, e.g. open-finder")
inv.set_defaults(func=_cmd_invoke)
return parser
def main() -> None:
parser = build_parser()
ns = parser.parse_args()
ns.func(ns)
if __name__ == "__main__":
main()

View File

@@ -4,7 +4,7 @@ from platformdirs import user_config_dir
import argparse import argparse
APP_NAME = "makku_bar" APP_NAME = "sims"
XDG_CONFIG_HOME = user_config_dir(appname=APP_NAME) XDG_CONFIG_HOME = user_config_dir(appname=APP_NAME)
XDG_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, "config.yaml") XDG_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, "config.yaml")
@@ -32,7 +32,7 @@ def load_config(config_path=XDG_CONFIG_FILE):
def load_args(): def load_args():
parser = argparse.ArgumentParser(description="makku_bar") parser = argparse.ArgumentParser(description="sims")
parser.add_argument( parser.add_argument(
"-c", "-c",
"--config", "--config",
@@ -54,7 +54,25 @@ BATTERY = app_config.get("battery", {"enable": False})
WINDOW_TITLE = app_config.get("window_title", {"enable": True}) WINDOW_TITLE = app_config.get("window_title", {"enable": True})
STYLIX = app_config.get("stylix", {"enable": False}) STYLIX = app_config.get("stylix", {"enable": False})
CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"}) CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"})
NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient"}) NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient", "saved_searches": []})
NOTMUCH.setdefault("saved_searches", [])
SCREENREC = app_config.get("screenrec", {
"enable": False,
"output_dir": "~/Videos/wl-screenrec",
})
POWER = app_config.get("power", {
"lock_command": ["waylock"],
})
NOTIFICATIONS = app_config.get("notifications", {
"enable": False,
"anchor": "top center",
"margin": "8px",
"width": 360,
"timeout_ms": 10_000,
"history_size": 50,
"image_max_px": 128,
"center_width": 380,
})
BAR_HEIGHT = app_config.get("height", 40) BAR_HEIGHT = app_config.get("height", 40)
LOG_LEVEL = app_config.get("logLevel", "WARNING") LOG_LEVEL = app_config.get("logLevel", "WARNING")
DEV = app_config.get("dev", False) DEV = app_config.get("dev", False)

244
sims/main.py Normal file
View File

@@ -0,0 +1,244 @@
from loguru import logger
# Configure logging based on dev flag
from .config import DEV, LOG_LEVEL
if DEV:
# In dev mode, disable fabric logs but keep stylix and bar logs
logger.disable("fabric")
else:
# In production, disable fabric logs but keep bar logs with configurable level
import sys
logger.disable("fabric")
logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL, "format": "{time} | {level} | {name}:{function}:{line} - {message}"}])
from fabric import Application
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 (
get_relative_path,
)
from .modules.bar import StatusBar
from .modules.window_fuzzy import FuzzyWindowFinder
from .modules.launcher.apps import AppLauncher
from .modules.launcher.clipboard import ClipboardMenu
from .modules.launcher.notmuch_search import NotmuchSearchMenu
from .modules.launcher.power import PowerMenu
from .modules.launcher.screenrec import ScreenrecMenu
from .modules.launcher.screenshot import ScreenshotMenu
from .modules.calendar import CalendarService
from .modules.control_center import ControlCenter
from .modules.notifications import NotificationToasts
from .modules.stylix import get_stylix_css_path
from .modules.vinyl import VinylButton
from .config import CALENDAR, NOTIFICATIONS, POWER, SCREENREC, STYLIX, VINYL
from .services.fenster import get_i3_connection
from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService
from fabric.notifications import Notifications
tray = SystemTray(name="system-tray", spacing=4)
get_i3_connection()
dummy = Window(visible=False)
finder = FuzzyWindowFinder()
app_launcher = AppLauncher()
clipboard_menu = ClipboardMenu()
power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"]))
screenshot_menu = ScreenshotMenu()
notmuch_search_menu = NotmuchSearchMenu()
screenrec_service: ScreenrecService | None = None
screenrec_menu = None
if SCREENREC.get("enable", False):
screenrec_service = ScreenrecService(
output_dir=SCREENREC.get("output_dir", "~/Videos/wl-screenrec")
)
screenrec_menu = ScreenrecMenu(screenrec_service)
notifications_service: Notifications | None = None
notification_history: NotificationHistoryService | None = None
notification_toasts: NotificationToasts | None = None
if NOTIFICATIONS.get("enable", False):
notifications_service = Notifications()
notification_history = NotificationHistoryService(
notifications_service,
history_size=NOTIFICATIONS.get("history_size", 50),
image_max_px=NOTIFICATIONS.get("image_max_px", 128),
)
notification_toasts = NotificationToasts(
notifications_service,
monitor=0,
anchor=NOTIFICATIONS.get("anchor", "top center"),
margin=NOTIFICATIONS.get("margin", "8px"),
width=NOTIFICATIONS.get("width", 360),
timeout_ms=NOTIFICATIONS.get("timeout_ms", 10_000),
)
vinyl_button: VinylButton | None = VinylButton() if VINYL.get("enable", False) else None
calendar_service: CalendarService | None = (
CalendarService(update_interval=120000) if CALENDAR.get("enable", True) else None
)
control_center: ControlCenter | None = None
if notification_history is not None:
control_center = ControlCenter(
history=notification_history,
calendar_service=calendar_service,
vinyl_button=vinyl_button,
monitor=0,
width=NOTIFICATIONS.get("center_width", 380),
)
bar_windows = []
notmuch_widget = None
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu, notmuch_search_menu]
if screenrec_menu is not None:
_app_windows.append(screenrec_menu)
if notification_toasts is not None:
_app_windows.append(notification_toasts)
if control_center is not None:
_app_windows.append(control_center)
app = Application("sims", *_app_windows)
@Application.action()
def open_finder():
finder.show()
@Application.action()
def open_app_launcher():
app_launcher.show()
@Application.action()
def open_clipboard_menu():
clipboard_menu.show()
@Application.action()
def open_power_menu():
power_menu.show()
@Application.action()
def open_screenshot_menu():
screenshot_menu.show()
@Application.action()
def refresh_notmuch():
if notmuch_widget is not None:
notmuch_widget.service.update_counts()
@Application.action()
def open_notmuch_search():
notmuch_search_menu.show()
@Application.action()
def open_screenrec_menu():
if screenrec_menu is not None:
screenrec_menu.show()
@Application.action()
def screenrec_start_monitor():
if screenrec_service is not None:
screenrec_service.start_monitor("videos")
@Application.action()
def screenrec_start_region():
if screenrec_service is not None:
screenrec_service.start_region("videos")
@Application.action()
def screenrec_stop():
if screenrec_service is not None:
screenrec_service.stop()
@Application.action()
def toggle_control_center():
if control_center is not None:
control_center.toggle()
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, 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"))
else:
logger.info("[Bar] Using default CSS")
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
def spawn_bars():
global notmuch_widget
logger.info("[Bar] Spawning bars")
outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)):
logger.warning("[Bar] Failed to get outputs — skipping bar spawn")
return
outputs = [o for o in outputs_reply.reply if o.get("active")]
if not outputs:
logger.warning("[Bar] No active outputs found — skipping bar spawn")
return
for i, output in enumerate(outputs):
output_name = output.get("name", f"Unknown-{i}")
bar = StatusBar(
display=output_name,
tray=tray if i == 0 else None,
monitor=i,
screenrec_service=screenrec_service if i == 0 else None,
control_center=control_center if i == 0 else None,
)
bar_windows.append(bar)
if i == 0 and bar.notmuch:
notmuch_widget = bar.notmuch
def main():
spawn_bars()
app.run()
if __name__ == "__main__":
main()

View File

@@ -1,24 +1,24 @@
import psutil
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.label import Label from fabric.widgets.label import Label
from fabric.widgets.image import Image from fabric.widgets.image import Image
from fabric.widgets.overlay import Overlay from fabric.widgets.overlay import Overlay
from fabric.widgets.datetime import DateTime from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox from fabric.widgets.centerbox import CenterBox
from bar.modules.player import Player from sims.modules.player import Player, PlayerSmall
from bar.modules.vinyl import VinylButton from sims.modules.battery import Battery
from bar.modules.quick_menu import QuickMenuOpener from sims.modules.control_center import ControlCenter
from bar.modules.battery import Battery from sims.modules.notmuch import NotmuchWidget
from bar.modules.calendar import CalendarService, CalendarPopup from sims.modules.screenrec import ScreenrecWidget
from bar.modules.notmuch import NotmuchWidget
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from bar.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
from bar.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.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from bar.services.system_stats import SystemStatsService from sims.services.system_stats import SystemStatsService
from bar.config import VINYL, BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
class StatusBar(Window): class StatusBar(Window):
@@ -27,9 +27,11 @@ class StatusBar(Window):
display: str, display: str,
tray: SystemTray | None = None, tray: SystemTray | None = None,
monitor: int = 1, monitor: int = 1,
screenrec_service: ScreenrecService | None = None,
control_center: ControlCenter | None = None,
): ):
super().__init__( super().__init__(
name="bar", name="sims",
layer="top", layer="top",
anchor="left top right", anchor="left top right",
margin="0px 0px -2px 0px", margin="0px 0px -2px 0px",
@@ -38,29 +40,25 @@ class StatusBar(Window):
all_visible=False, all_visible=False,
monitor=monitor, monitor=monitor,
) )
self.output = display
self._corners_rounded = False
self._right_flat = False
self.workspaces = FensterWorkspaces( self.workspaces = FensterWorkspaces(
output=display, output=display,
name="workspaces", name="workspaces",
spacing=4, spacing=4,
) )
# Create calendar components (refresh every 2 minutes)
self.calendar_service = CalendarService(update_interval=120000)
self.calendar_popup = CalendarPopup()
self.calendar_popup_visible = False
# Create clickable datetime widget
from fabric.widgets.button import Button
datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M") datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M")
self.date_time = Button( self.date_time = Button(
name="date-time-button", name="date-time-button",
child=datetime_widget, child=datetime_widget,
on_clicked=self.toggle_calendar, on_clicked=self._on_date_time_clicked,
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;" style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
) )
self.control_center = control_center
# Connect calendar service to popup
self.calendar_service.connect("events-changed", self.update_calendar_display)
self.system_tray = tray self.system_tray = tray
self.active_window = FensterActiveWindow( self.active_window = FensterActiveWindow(
@@ -84,15 +82,7 @@ class StatusBar(Window):
overlays=[self.cpu_progress_bar, self.progress_label], overlays=[self.cpu_progress_bar, self.progress_label],
) )
self.player = Player() self.player = Player()
self.vinyl = None self.player_small = PlayerSmall()
if VINYL["enable"]:
self.vinyl = VinylButton()
# Create quick menu button
self.quick_menu = QuickMenuOpener(icon_name="open-menu-symbolic")
# Setup audio section with vinyl if enabled
if self.vinyl:
self.quick_menu.get_menu().setup_audio_section(vinyl_service=self.vinyl)
self.battery = None self.battery = None
if BATTERY["enable"]: if BATTERY["enable"]:
@@ -102,6 +92,10 @@ class StatusBar(Window):
if NOTMUCH["enable"]: if NOTMUCH["enable"]:
self.notmuch = NotmuchWidget() self.notmuch = NotmuchWidget()
self.screenrec = None
if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service)
self.status_container = Box( self.status_container = Box(
name="widgets-container", name="widgets-container",
spacing=4, spacing=4,
@@ -121,16 +115,17 @@ class StatusBar(Window):
if self.notmuch: if self.notmuch:
end_container_children.append(self.notmuch) end_container_children.append(self.notmuch)
# Add quick menu button next to time if self.screenrec:
end_container_children.append(self.quick_menu) end_container_children.append(self.screenrec)
end_container_children.append(self.date_time) end_container_children.append(self.date_time)
center_children = [] center_children = []
if WINDOW_TITLE["enable"]: if WINDOW_TITLE["enable"]:
center_children.append(self.active_window) center_children.append(self.active_window)
self.children = CenterBox( self.inner = CenterBox(
name="bar-inner", name="sims-inner",
start_children=Box( start_children=Box(
name="start-container", name="start-container",
spacing=6, spacing=6,
@@ -138,6 +133,7 @@ class StatusBar(Window):
children=[ children=[
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20), Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
self.workspaces, self.workspaces,
self.player_small,
], ],
), ),
center_children=Box( center_children=Box(
@@ -153,45 +149,53 @@ class StatusBar(Window):
children=end_container_children, children=end_container_children,
), ),
) )
self.children = self.inner
# Create system stats service with signal-based updates
self.system_stats_service = SystemStatsService(update_interval=3000) self.system_stats_service = SystemStatsService(update_interval=3000)
self.system_stats_service.connect("stats-changed", self.update_progress_bars) self.system_stats_service.connect("stats-changed", self.update_progress_bars)
# Set the bar height
self.set_size_request(-1, 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))
if self.control_center is not None:
self.control_center.add_visibility_listener(self.set_right_flat)
self.show_all() self.show_all()
def __del__(self): @property
"""Cleanup when bar is destroyed""" def corners_rounded(self) -> bool:
if hasattr(self, 'calendar_service'): return self._corners_rounded
self.calendar_service.stop_monitoring()
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 set_right_flat(self, flat: bool) -> None:
if flat == self._right_flat:
return
if flat:
self.inner.set_style("border-radius: 0 0 0 28px;")
else:
self.inner.set_style("")
self._right_flat = flat
def update_progress_bars(self, service, cpu_percent, memory_percent): def update_progress_bars(self, service, cpu_percent, memory_percent):
"""Update progress bars when system stats change"""
self.cpu_progress_bar.value = cpu_percent self.cpu_progress_bar.value = cpu_percent
self.ram_progress_bar.value = memory_percent self.ram_progress_bar.value = memory_percent
def toggle_calendar(self, button=None): def _on_date_time_clicked(self, _button=None):
"""Toggle the calendar popup when datetime is clicked""" if self.control_center is not None:
from loguru import logger self.control_center.toggle()
logger.info(f"[Calendar] DateTime clicked, popup_visible: {self.calendar_popup_visible}")
if self.calendar_popup_visible:
logger.info("[Calendar] Hiding calendar popup")
self.calendar_popup.set_visible(False)
self.calendar_popup_visible = False
else:
logger.info("[Calendar] Showing calendar popup")
# Use cached events - no need to refresh on click
cached_events = self.calendar_service.get_cached_events()
logger.info(f"[Calendar] Using {len(cached_events)} cached events")
self.calendar_popup.update_events_display(cached_events)
self.calendar_popup.set_visible(True)
self.calendar_popup.show_all()
self.calendar_popup_visible = True
def update_calendar_display(self, service, events):
"""Update the calendar popup with events"""
self.calendar_popup.update_events_display(events)

View File

@@ -2,7 +2,7 @@ from gi.repository import GLib
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.label import Label from fabric.widgets.label import Label
from fabric.widgets.image import Image from fabric.widgets.image import Image
from bar.services.battery import BatteryService from sims.services.battery import BatteryService
class Battery(Box): class Battery(Box):
@@ -32,7 +32,9 @@ class Battery(Box):
level = max(10, min(100, round(bat / 10) * 10)) level = max(10, min(100, round(bat / 10) * 10))
if charging: if charging:
return f"battery-level-{level}-charging-symbolic" # Adwaita ships battery-level-100-charged-symbolic, not -charging.
suffix = "charged" if level == 100 else "charging"
return f"battery-level-{level}-{suffix}-symbolic"
else: else:
return f"battery-level-{level}-symbolic" return f"battery-level-{level}-symbolic"

View File

@@ -2,17 +2,12 @@ import json
import os import os
import subprocess import subprocess
import shutil import shutil
from datetime import datetime, date from datetime import date
# Add common binary paths to PATH for user binaries # Add common binary paths to PATH for user binaries
os.environ['PATH'] = '/run/current-system/sw/bin:/home/' + os.environ.get('USER', 'user') + '/.nix-profile/bin:' + os.environ.get('PATH', '') os.environ['PATH'] = '/run/current-system/sw/bin:/home/' + os.environ.get('USER', 'user') + '/.nix-profile/bin:' + os.environ.get('PATH', '')
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.wayland import WaylandWindow as Window
from loguru import logger from loguru import logger
from bar.config import CALENDAR from sims.config import CALENDAR
# Try to import khal as a Python library # Try to import khal as a Python library
try: try:
@@ -203,203 +198,3 @@ class CalendarService:
else: else:
logger.info("[Calendar] Using khal subprocess") logger.info("[Calendar] Using khal subprocess")
self.update_events_subprocess() self.update_events_subprocess()
class CalendarPopup(Window):
def __init__(self, **kwargs):
super().__init__(
name="calendar-popup",
layer="top",
anchor="top right",
margin="10px 10px 0px 0px", # Just a few pixels under the bar
exclusivity="none",
visible=False,
all_visible=False,
**kwargs,
)
# Events container
self.events_box = Box(
name="events-box",
orientation="v",
spacing=6,
style="min-width: 450px; min-height: 200px;",
)
# Add a test label to make sure popup is working
test_label = Label("Calendar Events", name="calendar-title")
container = Box(
orientation="v", spacing=4, children=[test_label, self.events_box]
)
self.children = container
# Set explicit size - much bigger
self.set_size_request(500, 400)
def update_events_display(self, events):
"""Update the events display"""
logger.info(f"[Calendar] Updating popup with {len(events)} events")
# Clear existing children first
self.events_box.children = []
if not events:
logger.info("[Calendar] No events, showing 'no events' message")
no_events_label = Label("No events today", name="no-events")
self.events_box.add(no_events_label)
return
# Check current time for time indicator placement
now = datetime.now()
current_time = now.strftime("%H:%M")
current_time_added = False
for i, event in enumerate(events):
logger.info(f"[Calendar] Processing event {i+1} for display")
title = event.get("title", "No title")
start_time = event.get("start", "").split()[1] if event.get("start") else ""
end_time = event.get("end", "").split()[1] if event.get("end") else ""
location = event.get("location", "")
# Check if we should add current time indicator before this event
if not current_time_added and start_time and start_time > current_time:
self.add_current_time_indicator(current_time)
current_time_added = True
# Format time display
time_str = ""
if start_time and end_time:
time_str = f"{start_time} - {end_time}"
elif start_time:
time_str = start_time
logger.info(f"[Calendar] Creating widget for: {title} ({time_str})")
# Create event item with horizontal layout - time on left, content on right
event_box = Box(
name="event-item",
orientation="h", # Horizontal layout
spacing=12,
style_classes=["event-item"],
)
# Left side: Time display (fixed width for alignment)
time_display = time_str if time_str else "All day"
time_label = Label(
time_display,
name="event-time",
style_classes=["event-time"],
style="min-width: 100px;" # Fixed width for consistent alignment
)
# Right side: Content (title and location)
content_box = Box(
name="event-content",
orientation="v",
spacing=2
)
# Title (no more status prefix)
title_label = Label(
title,
name="event-title",
style_classes=["event-title"],
)
content_box.add(title_label)
if location:
location_label = Label(
f"📍 {location}",
name="event-location",
style_classes=["event-location"],
)
content_box.add(location_label)
# Add time and content to the main event box
event_box.add(time_label)
event_box.add(content_box)
self.events_box.add(event_box)
logger.info(f"[Calendar] Added event widget to events_box")
# Add current time indicator at the end if not added yet
if not current_time_added:
self.add_current_time_indicator(current_time)
# Force refresh the popup display
self.events_box.show_all()
logger.info(f"[Calendar] Finished updating popup")
def add_current_time_indicator(self, current_time):
"""Add a current time indicator to the events list"""
time_indicator = Box(
name="current-time-indicator",
orientation="h",
spacing=8,
style_classes=["current-time-indicator"],
)
# Current time label
time_label = Label(
current_time,
name="current-time-label",
style_classes=["current-time-label"],
style="min-width: 100px; font-weight: bold;"
)
# Line indicator
line_label = Label(
"━━━ NOW",
name="current-time-line",
style_classes=["current-time-line"],
)
time_indicator.add(time_label)
time_indicator.add(line_label)
self.events_box.add(time_indicator)
logger.info(f"[Calendar] Added current time indicator at {current_time}")
class CalendarWidget(Button):
def __init__(self, **kwargs):
super().__init__(
name="calendar-widget",
child=Image(icon_name="x-office-calendar-symbolic", icon_size=16),
on_clicked=self.toggle_events,
**kwargs,
)
self.service = CalendarService()
self.service.connect("events-changed", self.update_events_display)
# Create popup window
self.popup = CalendarPopup()
self.popup_visible = False
logger.info("[Calendar] Calendar widget initialized with popup")
# Initial update
self.update_events_display(self.service, self.service.events)
def toggle_events(self, button=None):
"""Toggle the visibility of the events popup"""
logger.info(f"[Calendar] Button clicked, popup_visible: {self.popup_visible}")
if self.popup_visible:
logger.info("[Calendar] Hiding popup")
self.popup.set_visible(False)
self.popup_visible = False
else:
logger.info("[Calendar] Showing popup")
# Refresh events when opening
self.service.update_events()
self.popup.set_visible(True)
self.popup.show_all()
self.popup_visible = True
def update_events_display(self, service, events):
"""Update the events display in popup"""
self.popup.update_events_display(events)

View File

@@ -0,0 +1,352 @@
from datetime import datetime
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk
from sims.modules.calendar import CalendarService
from sims.modules.vinyl import VinylButton
from sims.services.notification_history import NotificationHistoryService
from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget
class ControlCenter(Window):
def __init__(
self,
history: NotificationHistoryService,
calendar_service: CalendarService | None = None,
vinyl_button: VinylButton | None = None,
monitor: int = 0,
width: int = 380,
):
super().__init__(
name="control-center",
anchor="top right bottom",
monitor=monitor,
margin="0",
exclusivity="none",
keyboard_mode="on-demand",
visible=False,
)
self._history = history
self._calendar_service = calendar_service
self._vinyl_button = vinyl_button
self._width = width
self._visibility_listeners: list = []
close_button = Button(
name="control-center-close",
image=Image(icon_name="window-close-symbolic", icon_size=16),
on_clicked=lambda *_: self.hide(),
)
header = Box(
name="control-center-header",
orientation="h",
spacing=8,
)
header.pack_start(
Label(name="control-center-title", label="Control Center", h_align="start"),
True,
True,
0,
)
header.pack_end(close_button, False, False, 0)
sections: list = []
if vinyl_button is not None:
sections.append(self._build_settings_section())
if calendar_service is not None:
sections.append(self._build_calendar_section())
sections.append(self._build_notifications_section())
sections_box = Box(
name="control-center-sections",
orientation="v",
spacing=12,
children=sections,
h_expand=True,
)
scroll = ScrolledWindow(
name="control-center-scroll",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=sections_box,
h_expand=True,
v_expand=True,
)
body = Box(
name="control-center-body",
orientation="v",
spacing=8,
children=[header, scroll],
h_expand=True,
v_expand=True,
)
body.set_size_request(self._width, -1)
self.add(body)
self.connect("key-press-event", self._on_key_press)
self._history.connect("changed", lambda *_: self._refresh_notifications())
if self._calendar_service is not None:
self._calendar_service.connect(
"events-changed",
lambda _service, events: self._refresh_calendar(events),
)
self._refresh_notifications()
if self._calendar_service is not None:
self._refresh_calendar(self._calendar_service.get_cached_events())
def _build_section(self, name: str, title: str | None) -> Box:
section = Box(
name=name,
orientation="v",
spacing=6,
h_expand=True,
)
if title is not None:
section.add(
Label(
name="control-center-section-title",
label=title,
h_align="start",
)
)
return section
def _build_settings_section(self) -> Box:
section = self._build_section("control-center-settings", "Settings")
row = Box(
name="control-center-settings-row",
orientation="h",
spacing=8,
)
row.pack_start(
Label(
name="control-center-settings-label",
label="Vinyl Passthrough",
h_align="start",
),
True,
True,
0,
)
row.pack_end(self._vinyl_button, False, False, 0)
section.add(row)
return section
def _build_calendar_section(self) -> Box:
section = self._build_section("control-center-calendar", "Calendar")
self._calendar_events_box = Box(
name="control-center-events",
orientation="v",
spacing=4,
h_expand=True,
)
section.add(self._calendar_events_box)
return section
def _build_notifications_section(self) -> Box:
section = self._build_section("control-center-notifications", None)
clear_button = Button(
name="control-center-notifications-clear",
label="Clear all",
on_clicked=lambda *_: self._history.clear(),
)
header = Box(
name="control-center-notifications-header",
orientation="h",
spacing=8,
)
header.pack_start(
Label(
name="control-center-section-title",
label="Notifications",
h_align="start",
),
True,
True,
0,
)
header.pack_end(clear_button, False, False, 0)
self._notifications_empty = Label(
name="control-center-notifications-empty",
label="No notifications",
h_align="start",
)
self._notifications_list = Box(
name="control-center-notifications-list",
orientation="v",
spacing=6,
h_expand=True,
)
section.add(header)
section.add(self._notifications_list)
return section
def add_visibility_listener(self, callback) -> None:
self._visibility_listeners.append(callback)
def _notify_visibility(self, visible: bool) -> None:
for callback in self._visibility_listeners:
callback(visible)
def toggle(self) -> None:
if self.get_visible():
self.hide()
else:
self.show()
def show(self) -> None: # type: ignore[override]
self._history.mark_all_seen()
super().show()
self.show_all()
self._notify_visibility(True)
def hide(self) -> None: # type: ignore[override]
super().hide()
self._notify_visibility(False)
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.hide()
return True
return False
def _refresh_notifications(self) -> None:
for child in self._notifications_list.get_children():
self._notifications_list.remove(child)
if child is not self._notifications_empty:
child.destroy()
entries = self._history.entries
if not entries:
self._notifications_list.add(self._notifications_empty)
self._notifications_empty.show_all()
return
for entry in entries:
self._notifications_list.add(
NotificationHistoryEntryWidget(
entry, on_dismiss=self._history.remove
)
)
self._notifications_list.show_all()
def _refresh_calendar(self, events) -> None:
for child in self._calendar_events_box.get_children():
self._calendar_events_box.remove(child)
child.destroy()
if not events:
self._calendar_events_box.add(
Label(
name="control-center-no-events",
label="No events today",
h_align="start",
)
)
self._calendar_events_box.show_all()
return
now = datetime.now()
current_time = now.strftime("%H:%M")
current_time_added = False
for event in events:
title = event.get("title", "No title")
start_raw = event.get("start", "")
end_raw = event.get("end", "")
start_time = start_raw.split()[1] if start_raw else ""
end_time = end_raw.split()[1] if end_raw else ""
location = event.get("location", "")
if not current_time_added and start_time and start_time > current_time:
self._calendar_events_box.add(self._build_now_indicator(current_time))
current_time_added = True
if start_time and end_time:
time_str = f"{start_time} - {end_time}"
elif start_time:
time_str = start_time
else:
time_str = "All day"
event_box = Box(
name="event-item",
orientation="h",
spacing=12,
style_classes=["event-item"],
)
event_box.add(
Label(
time_str,
name="event-time",
style_classes=["event-time"],
style="min-width: 90px;",
)
)
content_box = Box(
name="event-content",
orientation="v",
spacing=2,
)
content_box.add(
Label(
title,
name="event-title",
style_classes=["event-title"],
h_align="start",
)
)
if location:
content_box.add(
Label(
f"📍 {location}",
name="event-location",
style_classes=["event-location"],
h_align="start",
)
)
event_box.add(content_box)
self._calendar_events_box.add(event_box)
if not current_time_added:
self._calendar_events_box.add(self._build_now_indicator(current_time))
self._calendar_events_box.show_all()
def _build_now_indicator(self, current_time: str) -> Box:
indicator = Box(
name="current-time-indicator",
orientation="h",
spacing=8,
style_classes=["current-time-indicator"],
)
indicator.add(
Label(
current_time,
name="current-time-label",
style_classes=["current-time-label"],
style="min-width: 90px; font-weight: bold;",
)
)
indicator.add(
Label(
"━━━ NOW",
name="current-time-line",
style_classes=["current-time-line"],
)
)
return indicator

View File

@@ -0,0 +1,10 @@
from .base import FuzzyMenu, LauncherProvider, StaticAction, StaticActionProvider
from .windows import WindowProvider
__all__ = [
"FuzzyMenu",
"LauncherProvider",
"StaticAction",
"StaticActionProvider",
"WindowProvider",
]

View File

@@ -0,0 +1,87 @@
import re
import shlex
import subprocess
from fabric.utils.helpers import DesktopApp, get_desktop_applications
from fabric.widgets.box import Box
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from gi.repository import Gtk
from .base import FuzzyMenu
ICON_SIZE = 32
_FIELD_CODE_RE = re.compile(r"^%[fFuUickdDnNvm]$")
class AppProvider:
def items(self) -> list[DesktopApp]:
return get_desktop_applications()
def filter(self, items: list[DesktopApp], query: str) -> list[DesktopApp]:
if not query:
return items
q = query.lower()
return [a for a in items if _matches(a, q)]
def render(self, item: DesktopApp) -> Gtk.Widget:
children: list[Gtk.Widget] = []
pixbuf = item.get_icon_pixbuf(size=ICON_SIZE)
if pixbuf is not None:
children.append(Image(pixbuf=pixbuf, name="app-icon"))
primary = item.display_name or item.name or ""
text_box = Box(name="app-text", orientation="v", spacing=0)
text_box.add(Label(label=primary, name="app-name", h_align="start"))
if item.generic_name and item.generic_name != primary:
text_box.add(
Label(label=item.generic_name, name="app-generic", h_align="start")
)
children.append(text_box)
return Box(name="slot-box", orientation="h", spacing=10, children=children)
def activate(self, item: DesktopApp) -> None:
# Launch in a transient systemd --user scope so the app gets its own
# cgroup instead of inheriting sims.service's. start_new_session alone
# only changes POSIX session/pgid; systemd tracks units by cgroup and
# would kill children with sims on stop (default KillMode=control-group).
if item.command_line:
argv = [
t for t in shlex.split(item.command_line) if not _FIELD_CODE_RE.match(t)
]
if argv:
subprocess.Popen(
[
"systemd-run",
"--quiet",
"--user",
"--scope",
"--collect",
"--",
*argv,
],
start_new_session=True,
)
return
item.launch()
def _matches(app: DesktopApp, q: str) -> bool:
for field in (app.name, app.display_name, app.generic_name, app.executable):
if field and q in field.lower():
return True
return False
def AppLauncher(monitor: int = 0) -> FuzzyMenu:
return FuzzyMenu(
provider=AppProvider(),
monitor=monitor,
placeholder="Search Apps...",
window_name="app-launcher",
max_results=8,
)

View File

@@ -0,0 +1,205 @@
from dataclasses import dataclass
from typing import Any, Callable, Protocol
from fabric.widgets.box import Box
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk, Gtk
from sims.services.fenster import focused_output_index
class LauncherProvider(Protocol):
def items(self) -> list[Any]: ...
def filter(self, items: list[Any], query: str) -> list[Any]: ...
def render(self, item: Any) -> Gtk.Widget: ...
def activate(self, item: Any) -> None: ...
@dataclass
class StaticAction:
label: str
handler: Callable[[], None]
class StaticActionProvider:
"""Provider for menus whose items are a fixed list of (label, handler) pairs.
Pass either StaticAction instances or (label, handler) tuples; tuples are
coerced. items_factory lets the list re-evaluate on each open (e.g. for
state-dependent menus) — otherwise the list is captured at construction.
"""
def __init__(
self,
actions: list[StaticAction | tuple[str, Callable[[], None]]] | None = None,
items_factory: Callable[[], list[StaticAction | tuple[str, Callable[[], None]]]] | None = None,
):
if (actions is None) == (items_factory is None):
raise ValueError("pass exactly one of actions or items_factory")
self._static = [_coerce(a) for a in actions] if actions is not None else None
self._factory = items_factory
def items(self) -> list[StaticAction]:
if self._factory is not None:
return [_coerce(a) for a in self._factory()]
return list(self._static or [])
def filter(self, items: list[StaticAction], query: str) -> list[StaticAction]:
if not query:
return items
q = query.lower()
return [i for i in items if q in i.label.lower()]
def render(self, item: StaticAction) -> Gtk.Widget:
return Box(
name="slot-box",
orientation="h",
children=[Label(label=item.label, h_align="start")],
)
def activate(self, item: StaticAction) -> None:
item.handler()
def _coerce(a: StaticAction | tuple[str, Callable[[], None]]) -> StaticAction:
if isinstance(a, StaticAction):
return a
label, handler = a
return StaticAction(label=label, handler=handler)
class FuzzyMenu(Window):
def __init__(
self,
provider: LauncherProvider,
monitor: int = 0,
placeholder: str = "Search...",
window_name: str = "finder",
max_results: int | None = None,
):
self._max_results = max_results
super().__init__(
name=window_name,
anchor="center",
monitor=monitor,
keyboard_mode="exclusive",
type="popup",
visible=False,
)
self._provider = provider
self._items: list[Any] = []
self._filtered: list[Any] = []
self._selected_index: int = 0
self._scroll_offset: int = 0
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
placeholder=placeholder,
h_expand=True,
editable=True,
notify_text=self._on_text_changed,
)
self.picker_box = Box(
name="picker-box",
spacing=4,
orientation="v",
children=[self.search_entry, self.viewport],
)
self.add(self.picker_box)
self.connect("key-press-event", self._on_key_press)
self._refresh_viewport("")
def show(self):
self._items = self._provider.items()
self.search_entry.set_text("")
self._selected_index = 0
self._scroll_offset = 0
self._refresh_viewport("")
self.monitor = focused_output_index()
super().show()
self.search_entry.grab_focus()
def _on_text_changed(self, entry, *_):
self._selected_index = 0
self._scroll_offset = 0
self._refresh_viewport(entry.get_text())
def _on_key_press(self, _widget, event):
ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK)
keyval = event.keyval
if keyval == Gdk.KEY_Escape:
self.hide()
return True
if ctrl and keyval in (Gdk.KEY_g, Gdk.KEY_G):
self.hide()
return True
if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self._activate_selected()
return True
if keyval == Gdk.KEY_Down or (ctrl and keyval in (Gdk.KEY_n, Gdk.KEY_N)):
self._move_selection(1)
return True
if keyval == Gdk.KEY_Up or (ctrl and keyval in (Gdk.KEY_p, Gdk.KEY_P)):
self._move_selection(-1)
return True
return False
def _window_size(self) -> int:
return self._max_results if self._max_results is not None else len(self._filtered)
def _move_selection(self, delta: int):
if not self._filtered:
return
new_index = self._selected_index + delta
new_index = max(0, min(new_index, len(self._filtered) - 1))
if new_index == self._selected_index:
return
self._selected_index = new_index
window = self._window_size()
if window <= 0:
self._scroll_offset = 0
elif self._selected_index < self._scroll_offset:
self._scroll_offset = self._selected_index
elif self._selected_index >= self._scroll_offset + window:
self._scroll_offset = self._selected_index - window + 1
self._render_visible()
def _refresh_viewport(self, query: str):
self._filtered = self._provider.filter(self._items, query)
if self._selected_index >= len(self._filtered):
self._selected_index = 0
self._scroll_offset = 0
self._render_visible()
def _render_visible(self):
window = self._window_size()
if window <= 0:
visible: list[Any] = []
else:
max_offset = max(0, len(self._filtered) - window)
self._scroll_offset = min(self._scroll_offset, max_offset)
visible = self._filtered[self._scroll_offset : self._scroll_offset + window]
self.viewport.children = []
for item in visible:
self.viewport.add(self._provider.render(item))
self._update_selection_highlight()
def _update_selection_highlight(self):
visible_index = self._selected_index - self._scroll_offset
for i, child in enumerate(self.viewport.get_children()):
ctx = child.get_style_context()
if i == visible_index:
ctx.add_class("selected")
else:
ctx.remove_class("selected")
def _activate_selected(self):
if self._filtered and 0 <= self._selected_index < len(self._filtered):
self._provider.activate(self._filtered[self._selected_index])
self.hide()

View File

@@ -0,0 +1,75 @@
import subprocess
from dataclasses import dataclass
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from gi.repository import Gtk
from .base import FuzzyMenu
@dataclass
class ClipEntry:
raw: str # full "<id>\t<preview>" line as emitted by cliphist
preview: str
class ClipboardProvider:
def items(self) -> list[ClipEntry]:
proc = subprocess.run(
["cliphist", "list"],
capture_output=True,
text=True,
)
if proc.returncode != 0:
return []
entries: list[ClipEntry] = []
for line in proc.stdout.splitlines():
if not line:
continue
_id, sep, preview = line.partition("\t")
if not sep:
continue
entries.append(ClipEntry(raw=line, preview=preview))
return entries
def filter(self, items: list[ClipEntry], query: str) -> list[ClipEntry]:
if not query:
return items
q = query.lower()
return [e for e in items if q in e.preview.lower()]
def render(self, item: ClipEntry) -> Gtk.Widget:
return Box(
name="slot-box",
orientation="h",
children=[
Label(
label=item.preview,
h_align="start",
ellipsization="end",
max_chars_width=120,
name="clip-preview",
),
],
)
def activate(self, item: ClipEntry) -> None:
decoded = subprocess.run(
["cliphist", "decode"],
input=item.raw.encode("utf-8"),
capture_output=True,
)
if decoded.returncode != 0:
return
subprocess.run(["wl-copy"], input=decoded.stdout)
def ClipboardMenu(monitor: int = 0) -> FuzzyMenu:
return FuzzyMenu(
provider=ClipboardProvider(),
monitor=monitor,
placeholder="Clipboard...",
window_name="clipboard-menu",
max_results=12,
)

View File

@@ -0,0 +1,284 @@
"""Live notmuch search launcher.
A FuzzyMenu variant that runs `notmuch search` per keystroke (debounced),
renders thread summaries, and on activation opens the thread in emacs notmuch.
A bare-query handoff item is always appended so the user can defer to
notmuch-search inside emacs without having a matching result selected.
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from typing import Any
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from gi.repository import GLib, Gtk
from loguru import logger
from sims.config import NOTMUCH
from .base import FuzzyMenu
DEBOUNCE_MS = 120
MIN_QUERY_LEN = 2
SEARCH_LIMIT = 30
@dataclass
class NotmuchHit:
thread: str
subject: str
authors: str
date_relative: str
@property
def query(self) -> str:
return f"thread:{self.thread}"
@dataclass
class SavedSearch:
name: str
query: str
@dataclass
class BareQueryHandoff:
query: str
def _elisp_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
class NotmuchSearchProvider:
def __init__(self):
self._notmuch_path = NOTMUCH.get("notmuch_path", "notmuch")
self._emacsclient = NOTMUCH.get("emacsclient_command", "emacsclient")
raw_saved = NOTMUCH.get("saved_searches", []) or []
self._saved: list[SavedSearch] = []
for entry in raw_saved:
query = (entry.get("query") or "").strip()
if not query:
continue
name = entry.get("name") or query
self._saved.append(SavedSearch(name=name, query=query))
def saved_searches(self) -> list[SavedSearch]:
return list(self._saved)
def search(self, query: str) -> list[NotmuchHit]:
cmd = [
self._notmuch_path,
"search",
"--format=json",
"--output=summary",
f"--limit={SEARCH_LIMIT}",
query,
]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, check=True, timeout=2
)
except subprocess.CalledProcessError as e:
logger.warning(
f"[NotmuchSearch] search failed for {query!r}: {e.stderr.strip()}"
)
return []
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.warning(f"[NotmuchSearch] {e}")
return []
try:
raw = json.loads(proc.stdout) or []
except json.JSONDecodeError:
return []
hits: list[NotmuchHit] = []
for r in raw:
hits.append(
NotmuchHit(
thread=str(r.get("thread", "")),
subject=r.get("subject") or "(no subject)",
authors=r.get("authors") or "",
date_relative=r.get("date_relative") or "",
)
)
return hits
def render_hit(self, hit: NotmuchHit) -> Gtk.Widget:
text = Box(name="notmuch-text", orientation="v", spacing=0, h_expand=True)
text.add(
Label(
label=hit.subject,
name="notmuch-subject",
h_align="start",
ellipsization="end",
max_chars_width=80,
)
)
text.add(
Label(
label=hit.authors,
name="notmuch-authors",
h_align="start",
ellipsization="end",
max_chars_width=80,
)
)
meta = Label(
label=hit.date_relative, name="notmuch-date", h_align="end"
)
return Box(
name="slot-box",
orientation="h",
spacing=10,
children=[text, meta],
)
def render_saved(self, item: SavedSearch) -> Gtk.Widget:
text = Box(name="notmuch-text", orientation="v", spacing=0, h_expand=True)
text.add(
Label(label=item.name, name="notmuch-saved-name", h_align="start")
)
text.add(
Label(
label=item.query,
name="notmuch-saved-query",
h_align="start",
ellipsization="end",
max_chars_width=80,
)
)
return Box(
name="slot-box", orientation="h", spacing=10, children=[text]
)
def render_bare(self, item: BareQueryHandoff) -> Gtk.Widget:
return Box(
name="slot-box",
orientation="h",
children=[
Label(
label=f"→ Search '{item.query}' in emacs",
name="notmuch-bare",
h_align="start",
),
],
)
def open_thread(self, hit: NotmuchHit) -> None:
self._emacs_eval(f'(notmuch-show "{_elisp_escape(hit.query)}")')
def open_search(self, query: str) -> None:
self._emacs_eval(f'(notmuch-search "{_elisp_escape(query)}")')
def _emacs_eval(self, sexp: str) -> None:
try:
subprocess.Popen(
[self._emacsclient, "-c", "-e", sexp],
start_new_session=True,
)
except Exception as e:
logger.error(f"[NotmuchSearch] failed to launch emacsclient: {e}")
class _MenuProvider:
"""LauncherProvider that defers to NotmuchSearchProvider.
items() returns saved searches (shown when the entry is empty).
filter() is identity — NotmuchSearchMenu manages self._items directly
on every keystroke (live search instead of in-memory filtering).
"""
def __init__(self, search: NotmuchSearchProvider):
self._search = search
def items(self) -> list[Any]:
return list(self._search.saved_searches())
def filter(self, items: list[Any], query: str) -> list[Any]:
return items
def render(self, item: Any) -> Gtk.Widget:
if isinstance(item, NotmuchHit):
return self._search.render_hit(item)
if isinstance(item, SavedSearch):
return self._search.render_saved(item)
if isinstance(item, BareQueryHandoff):
return self._search.render_bare(item)
return Box()
def activate(self, item: Any) -> None:
if isinstance(item, NotmuchHit):
self._search.open_thread(item)
elif isinstance(item, SavedSearch):
self._search.open_search(item.query)
elif isinstance(item, BareQueryHandoff):
self._search.open_search(item.query)
class NotmuchSearchMenu(FuzzyMenu):
def __init__(self, monitor: int = 0):
self._search = NotmuchSearchProvider()
super().__init__(
provider=_MenuProvider(self._search),
monitor=monitor,
placeholder="notmuch search…",
window_name="notmuch-search",
max_results=12,
)
self._debounce_id: int | None = None
def show(self):
self._cancel_debounce()
super().show()
def hide(self):
self._cancel_debounce()
super().hide()
def _on_text_changed(self, entry, *_):
text = entry.get_text()
self._cancel_debounce()
self._selected_index = 0
self._scroll_offset = 0
if not text:
# Empty query: re-show saved searches via the provider.
self._items = self._search.saved_searches()
self._refresh_viewport(text)
return
if len(text) < MIN_QUERY_LEN:
# Too short to query notmuch; offer just the bare-query handoff.
self._items = [BareQueryHandoff(query=text)]
self._refresh_viewport(text)
return
# Show the bare-query handoff immediately while we wait for the search
# — gives the user a way to commit before the debounce fires.
self._items = [BareQueryHandoff(query=text)]
self._refresh_viewport(text)
self._debounce_id = GLib.timeout_add(
DEBOUNCE_MS, self._on_debounce_fire, text
)
def _cancel_debounce(self):
if self._debounce_id is not None:
GLib.source_remove(self._debounce_id)
self._debounce_id = None
def _on_debounce_fire(self, text: str):
self._debounce_id = None
if self.search_entry.get_text() != text:
return False
hits = self._search.search(text)
items: list[Any] = list(hits)
items.append(BareQueryHandoff(query=text))
self._items = items
self._selected_index = 0
self._scroll_offset = 0
self._refresh_viewport(text)
return False

View File

@@ -0,0 +1,25 @@
import subprocess
from .base import FuzzyMenu, StaticActionProvider
def _spawn(argv: list[str]) -> None:
subprocess.Popen(argv, start_new_session=True)
def PowerMenu(monitor: int = 0, lock_command: list[str] | None = None) -> FuzzyMenu:
lock = lock_command or ["waylock"]
provider = StaticActionProvider(
actions=[
("⏻ Poweroff", lambda: _spawn(["systemctl", "poweroff"])),
("🔁 Reboot", lambda: _spawn(["systemctl", "reboot"])),
("⏾ Suspend", lambda: _spawn(["systemctl", "suspend"])),
("Lock", lambda: _spawn(lock)),
]
)
return FuzzyMenu(
provider=provider,
monitor=monitor,
placeholder="Power Menu...",
window_name="power-menu",
)

View File

@@ -0,0 +1,30 @@
from sims.services.screenrec import ScreenrecService
from .base import FuzzyMenu, StaticActionProvider
def _idle_actions(service: ScreenrecService):
return [
("Monitor → Videos", lambda: service.start_monitor("videos")),
("Region → Videos", lambda: service.start_region("videos")),
("Monitor → Clipboard", lambda: service.start_monitor("clipboard")),
("Region → Clipboard", lambda: service.start_region("clipboard")),
]
def ScreenrecProvider(service: ScreenrecService) -> StaticActionProvider:
def items():
if service.recording:
return [("Stop Recording", service.stop)]
return _idle_actions(service)
return StaticActionProvider(items_factory=items)
def ScreenrecMenu(service: ScreenrecService, monitor: int = 0) -> FuzzyMenu:
return FuzzyMenu(
provider=ScreenrecProvider(service),
monitor=monitor,
placeholder="Screen Recording...",
window_name="screenrec-menu",
)

View File

@@ -0,0 +1,23 @@
import subprocess
from .base import FuzzyMenu, StaticActionProvider
def _spawn(argv: list[str]) -> None:
subprocess.Popen(argv, start_new_session=True)
def ScreenshotMenu(monitor: int = 0) -> FuzzyMenu:
provider = StaticActionProvider(
actions=[
("Normal", lambda: _spawn(["grimnorm"])),
("To Clipboard", lambda: _spawn(["grim2clip"])),
("To Imv", lambda: _spawn(["grim2imv"])),
]
)
return FuzzyMenu(
provider=provider,
monitor=monitor,
placeholder="Screenshot...",
window_name="screenshot-menu",
)

View File

@@ -0,0 +1,60 @@
from sims.services.i3 import I3, I3MessageType
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from gi.repository import Gtk
class WindowProvider:
def items(self) -> list[dict]:
windows: list[dict] = []
tree_reply = I3.send_command("", I3MessageType.GET_TREE)
if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)):
return windows
tree = tree_reply.reply
for output_node in tree.get("nodes", []):
for ws_node in output_node.get("nodes", []):
ws_num = ws_node.get("num", 0)
for con in ws_node.get("nodes", []):
if con.get("type") == "con":
windows.append({
"id": con.get("id"),
"app_id": con.get("app_id") or "",
"title": con.get("name") or "",
"workspace": ws_num,
})
for con in ws_node.get("floating_nodes", []):
if con.get("type") == "con":
windows.append({
"id": con.get("id"),
"app_id": con.get("app_id") or "",
"title": con.get("name") or "",
"workspace": ws_num,
})
return windows
def filter(self, items: list[dict], query: str) -> list[dict]:
if not query:
return items
q = query.lower()
return [
w for w in items
if q in w.get("title", "").lower()
or q in w.get("app_id", "").lower()
]
def render(self, item: dict) -> Gtk.Widget:
title = item.get("title", "")
app_id = item.get("app_id", "")
ws_num = item.get("workspace", 0)
text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}"
return Box(
name="slot-box",
orientation="h",
children=[Label(label=text)],
)
def activate(self, item: dict) -> None:
window_id = item.get("id")
if window_id is not None:
I3.send_command(f"[con_id={window_id}] focus")

View File

@@ -0,0 +1,54 @@
from typing import cast
from fabric.notifications import Notification, Notifications
from fabric.widgets.box import Box
from fabric.widgets.wayland import WaylandWindow as Window
from loguru import logger
from sims.widgets.notification import NotificationWidget
class NotificationToasts(Window):
def __init__(
self,
service: Notifications,
monitor: int = 0,
anchor: str = "top center",
margin: str = "8px",
width: int = 360,
timeout_ms: int = 10_000,
):
super().__init__(
name="notification-toasts",
anchor=anchor,
monitor=monitor,
margin=margin,
exclusivity="none",
visible=True,
all_visible=True,
)
self._width = width
self._timeout_ms = timeout_ms
self._stack = Box(
size=2, # so the compositor doesn't optimize the empty surface away
spacing=4,
orientation="v",
)
self.add(self._stack)
self._service = service
self._service.connect("notification-added", self._on_added)
def _on_added(self, service: Notifications, nid: int):
notification = cast(Notification, service.get_notification_from_id(nid))
if notification is None:
logger.warning(f"[Notifications] no notification for id {nid}")
return
self._stack.add(
NotificationWidget(
notification,
width=self._width,
timeout_ms=self._timeout_ms,
)
)

View File

@@ -9,30 +9,36 @@ from fabric.widgets.label import Label
from fabric.widgets.button import Button from fabric.widgets.button import Button
from fabric.widgets.image import Image from fabric.widgets.image import Image
from loguru import logger from loguru import logger
from bar.config import NOTMUCH from sims.config import NOTMUCH
DEFAULT_DEBT_QUERY = "tag:unread and date:..1w"
DEFAULT_DEBT_WARN_AT = 1
DEFAULT_DEBT_ALARM_AT = 6
class NotmuchService: class NotmuchService:
def __init__(self, update_interval=60000): # 1 minute default def __init__(self, update_interval=60000): # 1 minute default
self.unread_count = 0 self.unread_count = 0
self.debt_count = 0
self.callbacks = [] self.callbacks = []
self._update_interval = update_interval self._update_interval = update_interval
self._timer_id = None self._timer_id = None
# Initial load # Initial load
self.update_unread_count() self.update_counts()
# Start periodic updates # Start periodic updates
self.start_monitoring() self.start_monitoring()
def connect(self, signal_name, callback): def connect(self, signal_name, callback):
"""Simple callback system to replace signals""" """Simple callback system to replace signals"""
if signal_name == "unread-changed": if signal_name == "counts-changed":
self.callbacks.append(callback) self.callbacks.append(callback)
def emit_unread_changed(self, count): def emit_counts_changed(self):
"""Emit unread changed to all callbacks""" """Emit counts changed to all callbacks"""
for callback in self.callbacks: for callback in self.callbacks:
callback(self, count) callback(self, self.unread_count, self.debt_count)
def start_monitoring(self): def start_monitoring(self):
"""Start periodic unread count updates""" """Start periodic unread count updates"""
@@ -57,21 +63,38 @@ class NotmuchService:
def _periodic_update(self): def _periodic_update(self):
"""Periodic update callback""" """Periodic update callback"""
logger.info("[Notmuch] Performing periodic unread count update") logger.info("[Notmuch] Performing periodic count update")
self.update_unread_count() self.update_counts()
return True # Keep the timer running return True # Keep the timer running
def get_cached_count(self): def get_cached_count(self):
"""Get cached unread count without triggering update""" """Get cached unread count without triggering update"""
return self.unread_count return self.unread_count
def update_unread_count(self): def get_cached_debt_count(self):
"""Fetch unread email count from notmuch""" """Get cached debt count without triggering update"""
return self.debt_count
def _run_count(self, notmuch_path, query):
cmd = [notmuch_path, "count", query]
logger.info(f"[Notmuch] Running command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
out = result.stdout.strip()
return int(out) if out else 0
def update_counts(self):
"""Fetch unread + debt counts from notmuch"""
# Check if notmuch is enabled # Check if notmuch is enabled
if not NOTMUCH.get("enable", True): if not NOTMUCH.get("enable", True):
logger.info("[Notmuch] Notmuch is disabled in config") logger.info("[Notmuch] Notmuch is disabled in config")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
return return
# Get notmuch path from config # Get notmuch path from config
@@ -81,42 +104,34 @@ class NotmuchService:
if not shutil.which(notmuch_path): if not shutil.which(notmuch_path):
logger.warning(f"[Notmuch] notmuch not found at '{notmuch_path}'. Please install notmuch or configure the correct path.") logger.warning(f"[Notmuch] notmuch not found at '{notmuch_path}'. Please install notmuch or configure the correct path.")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
return return
debt_query = NOTMUCH.get("debt_query", DEFAULT_DEBT_QUERY)
try: try:
# Get unread email count self.unread_count = self._run_count(notmuch_path, "tag:unread")
cmd = [notmuch_path, "count", "tag:unread"] self.debt_count = self._run_count(notmuch_path, debt_query)
logger.info(f"[Notmuch] Running command: {' '.join(cmd)}") logger.info(
result = subprocess.run( f"[Notmuch] {self.unread_count} unread, {self.debt_count} aging (debt query: {debt_query!r})"
cmd,
capture_output=True,
text=True,
check=True,
) )
logger.info(f"[Notmuch] Command stdout: '{result.stdout.strip()}'") self.emit_counts_changed()
logger.info(f"[Notmuch] Command stderr: '{result.stderr.strip()}'")
if result.stdout.strip():
self.unread_count = int(result.stdout.strip())
logger.info(f"[Notmuch] Found {self.unread_count} unread emails")
self.emit_unread_changed(self.unread_count)
else:
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"[Notmuch] Failed to fetch unread count: {e}") logger.error(f"[Notmuch] Failed to fetch counts: {e}")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
except ValueError as e: except ValueError as e:
logger.error(f"[Notmuch] Error parsing unread count: {e}") logger.error(f"[Notmuch] Error parsing count: {e}")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
except Exception as e: except Exception as e:
logger.error(f"[Notmuch] Error getting unread count: {e}") logger.error(f"[Notmuch] Error getting counts: {e}")
self.unread_count = 0 self.unread_count = 0
self.emit_unread_changed(self.unread_count) self.debt_count = 0
self.emit_counts_changed()
class NotmuchWidget(Button): class NotmuchWidget(Button):
@@ -141,12 +156,14 @@ class NotmuchWidget(Button):
# Initialize the service # Initialize the service
self.service = NotmuchService() self.service = NotmuchService()
self.service.connect("unread-changed", self.update_display) self.service.connect("counts-changed", self.update_display)
logger.info("[Notmuch] Notmuch widget initialized") logger.info("[Notmuch] Notmuch widget initialized")
# Initial update # Initial update
self.update_display(self.service, self.service.unread_count) self.update_display(
self.service, self.service.unread_count, self.service.debt_count
)
def open_email_client(self, button=None): def open_email_client(self, button=None):
"""Open notmuch in emacsclient""" """Open notmuch in emacsclient"""
@@ -161,18 +178,31 @@ class NotmuchWidget(Button):
except Exception as e: except Exception as e:
logger.error(f"[Notmuch] Failed to open notmuch in emacsclient '{emacsclient_command}': {e}") logger.error(f"[Notmuch] Failed to open notmuch in emacsclient '{emacsclient_command}': {e}")
def update_display(self, service, count): def update_display(self, service, unread, debt):
"""Update the widget display with unread count""" """Update the widget display with unread + debt counts"""
# Only show count if there are unread emails warn_at = NOTMUCH.get("debt_warn_at", DEFAULT_DEBT_WARN_AT)
if count > 0: alarm_at = NOTMUCH.get("debt_alarm_at", DEFAULT_DEBT_ALARM_AT)
self.label.set_text(str(count))
classes = ["notmuch-widget"]
if unread > 0:
self.label.set_text(str(unread))
self.label.set_visible(True) self.label.set_visible(True)
self.icon.set_from_icon_name("mail-unread-symbolic", 16) self.icon.set_property("icon-name", "mail-unread-symbolic")
self.set_style_classes(["notmuch-widget", "has-unread"]) classes.append("has-unread")
else: else:
self.label.set_text("") self.label.set_text("")
self.label.set_visible(False) self.label.set_visible(False)
self.icon.set_from_icon_name("mail-read-symbolic", 16) self.icon.set_property("icon-name", "mail-read-symbolic")
self.set_style_classes(["notmuch-widget", "no-unread"]) classes.append("no-unread")
logger.info(f"[Notmuch] Updated display: {count} unread emails") if debt >= alarm_at:
classes.append("debt-alarm")
elif debt >= warn_at:
classes.append("debt-warn")
self.set_style_classes(classes)
self.set_tooltip_text(f"{unread} unread · {debt} aging")
logger.info(
f"[Notmuch] Updated display: {unread} unread, {debt} aging — classes={classes}"
)

View File

@@ -1,21 +1,23 @@
import contextlib
import os import os
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import tempfile import tempfile
from gi.repository import Gtk, GLib, Gio, Gdk from gi.repository import Gtk, GLib, Gio, Gdk, Pango
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label from fabric.widgets.label import Label
from fabric.widgets.button import Button from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.overlay import Overlay from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack from fabric.widgets.stack import Stack
from ..widgets.circle_image import CircleImage from ..widgets.circle_image import CircleImage
import bar.modules.icons as icons import sims.modules.icons as icons
from bar.services.mpris import MprisPlayerManager, MprisPlayer from sims.services.mpris import MprisPlayerManager, MprisPlayer
from fabric import Fabricator from fabric import Fabricator
# from bar.modules.cavalcade import SpectrumRender # from sims.modules.cavalcade import SpectrumRender
def get_player_progress(fabricator, mpris_player): def get_player_progress(fabricator, mpris_player):
@@ -522,30 +524,52 @@ class Player(Box):
return False return False
class PlayerSmall(CenterBox): class PlayerSmall(Box):
PLAY_ICON = "media-playback-start-symbolic"
PAUSE_ICON = "media-playback-pause-symbolic"
PREV_ICON = "media-skip-backward-symbolic"
NEXT_ICON = "media-skip-forward-symbolic"
FALLBACK_ICON = "audio-x-generic-symbolic"
COVER_SIZE = 22
def __init__(self): def __init__(self):
super().__init__( super().__init__(
name="player-small", orientation="h", h_align="fill", v_align="center" name="player-small", orientation="h", v_align="center"
) )
self._show_artist = False # toggle flag self._display_options = ["title", "artist"]
self._display_options = ["cavalcade", "title", "artist"]
self._display_index = 0 self._display_index = 0
self._current_display = "cavalcade" self._current_display = "title"
self._spin_timer_id = None
self._spin_step = 2 # deg per tick → ~9s per rotation at 50ms
self._spin_interval_ms = 50
self.cover = CircleImage(
name="compact-mpris-cover",
size=self.COVER_SIZE,
h_align="center",
v_align="center",
)
self.fallback_icon = Image(
name="compact-mpris-fallback",
icon_name=self.FALLBACK_ICON,
icon_size=16,
)
self.cover_stack = Stack(
name="compact-mpris-cover-stack",
transition_type="crossfade",
transition_duration=200,
children=[self.fallback_icon, self.cover],
)
self.cover_stack.set_visible_child(self.fallback_icon)
self.mpris_icon = Button( self.mpris_icon = Button(
name="compact-mpris-icon", name="compact-mpris-icon",
h_align="center", h_align="center",
v_align="center", v_align="center",
child=Label(name="compact-mpris-icon-label", markup=icons.disc), child=self.cover_stack,
) )
# Remove scroll events; instead, add button press events.
self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.mpris_icon.connect("button-press-event", self._on_icon_button_press) self.mpris_icon.connect("button-press-event", self._on_icon_button_press)
# Prevent the child from propagating events.
child = self.mpris_icon.get_child()
child.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
child.connect("button-press-event", lambda widget, event: True)
# Add hover effect
add_hover_cursor(self.mpris_icon) add_hover_cursor(self.mpris_icon)
self.mpris_label = Label( self.mpris_label = Label(
@@ -555,36 +579,36 @@ class PlayerSmall(CenterBox):
max_chars_width=26, max_chars_width=26,
h_align="center", h_align="center",
) )
self._width_tween_id = None
self._current_label_width_px = None
self._width_tween_duration_ms = 220
self.play_image = Image(
name="compact-mpris-button-icon",
icon_name=self.PLAY_ICON,
icon_size=16,
)
self.mpris_button = Button( self.mpris_button = Button(
name="compact-mpris-button", name="compact-mpris-button",
h_align="center", h_align="center",
v_align="center", v_align="center",
child=Label(name="compact-mpris-button-label", markup=icons.play), child=self.play_image,
) )
self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.mpris_button.connect( self.mpris_button.connect(
"button-press-event", self._on_play_pause_button_press "button-press-event", self._on_play_pause_button_press
) )
# Add hover effect
add_hover_cursor(self.mpris_button) add_hover_cursor(self.mpris_button)
# self.cavalcade = SpectrumRender()
# self.cavalcade_box = self.cavalcade.get_spectrum_box()
self.center_stack = Stack( self.center_stack = Stack(
name="compact-mpris", name="compact-mpris",
transition_type="crossfade", transition_type="crossfade",
transition_duration=100, transition_duration=100,
v_align="center", v_align="center",
v_expand=False, v_expand=False,
children=[ children=[self.mpris_label],
# self.cavalcade_box,
self.mpris_label,
],
) )
# self.center_stack.set_visible_child(self.cavalcade_box) # default to cavalcade
# Create additional compact view.
self.mpris_small = CenterBox( self.mpris_small = CenterBox(
name="compact-mpris", name="compact-mpris",
orientation="h", orientation="h",
@@ -593,7 +617,7 @@ class PlayerSmall(CenterBox):
v_align="center", v_align="center",
v_expand=False, v_expand=False,
start_children=self.mpris_icon, start_children=self.mpris_icon,
center_children=self.center_stack, # Changed to center_stack to handle stack switching center_children=self.center_stack,
end_children=self.mpris_button, end_children=self.mpris_button,
) )
@@ -601,157 +625,307 @@ class PlayerSmall(CenterBox):
self.mpris_manager = MprisPlayerManager() self.mpris_manager = MprisPlayerManager()
self.mpris_player = None self.mpris_player = None
# Almacenar el índice del reproductor actual self._players = {}
self.current_index = 0 self._player_handlers = {}
self._last_status = {}
players = self.mpris_manager.players for p in self.mpris_manager.players:
if players: self._track_player(p)
mp = MprisPlayer(players[self.current_index]) self._select_initial_player()
self.mpris_player = mp
self._apply_mpris_properties()
self.mpris_player.connect("changed", self._on_mpris_changed)
else:
self._apply_mpris_properties() self._apply_mpris_properties()
self.mpris_manager.connect("player-appeared", self.on_player_appeared) self.mpris_manager.connect("player-appeared", self.on_player_appeared)
self.mpris_manager.connect("player-vanished", self.on_player_vanished) self.mpris_manager.connect("player-vanished", self.on_player_vanished)
self.mpris_button.connect("clicked", self._on_play_pause_clicked)
def _char_width_px(self):
metrics = self.mpris_label.get_pango_context().get_metrics(None, None)
return max(1.0, metrics.get_approximate_char_width() / Pango.SCALE)
def _measure_text_width(self, text):
if not text:
return 0
layout = self.mpris_label.create_pango_layout(text)
text_width_px, _ = layout.get_pixel_size()
max_px = int(self._char_width_px() * 26)
return min(text_width_px, max_px)
def _apply_label_width(self, width_px):
if width_px <= 0:
self.mpris_label.set_size_request(0, -1)
if self.mpris_label.get_margin_start() != 0:
self.mpris_label.set_margin_start(0)
self.mpris_label.set_margin_end(0)
if self.mpris_label.get_visible():
self.mpris_label.set_visible(False)
self._current_label_width_px = 0
return
if not self.mpris_label.get_visible():
self.mpris_label.set_visible(True)
chars = max(1, int(round(width_px / self._char_width_px())))
if chars != self.mpris_label.get_max_width_chars():
self.mpris_label.set_max_width_chars(chars)
self.mpris_label.set_size_request(width_px, -1)
margin = min(6, width_px // 2)
if self.mpris_label.get_margin_start() != margin:
self.mpris_label.set_margin_start(margin)
self.mpris_label.set_margin_end(margin)
self._current_label_width_px = width_px
def _set_label_text(self, text):
target_text = text or ""
target_px = self._measure_text_width(target_text)
if self._width_tween_id is not None:
GLib.source_remove(self._width_tween_id)
self._width_tween_id = None
# First call — snap to target without animation.
if self._current_label_width_px is None:
self.mpris_label.set_text(target_text)
self._apply_label_width(target_px)
return
start_px = self._current_label_width_px
# Growing from collapsed: set new text first so it's ready to reveal.
# Cross-fading between two non-empty texts: also swap text immediately.
# Shrinking to empty: keep old text visible while it shrinks, clear at end.
if target_px > 0:
self.mpris_label.set_text(target_text)
if start_px == target_px:
self._apply_label_width(target_px)
return
duration_ms = self._width_tween_duration_ms
start_time = GLib.get_monotonic_time()
def tick():
elapsed_ms = (GLib.get_monotonic_time() - start_time) / 1000.0
progress = min(1.0, elapsed_ms / duration_ms)
t = 1 - (1 - progress) ** 3 # ease-out cubic
cur_px = int(start_px + (target_px - start_px) * t)
self._apply_label_width(cur_px)
if progress >= 1.0:
self._apply_label_width(target_px)
if target_px == 0:
self.mpris_label.set_text("")
self._width_tween_id = None
return False
return True
self._width_tween_id = GLib.timeout_add(16, tick)
def _set_cover_from_path(self, image_path):
if image_path and os.path.isfile(image_path):
try:
self.cover.set_image_from_file(image_path)
self.cover_stack.set_visible_child(self.cover)
self._update_spin()
return False
except Exception:
pass
self.cover_stack.set_visible_child(self.fallback_icon)
self._update_spin()
return False
def _start_spin(self):
if self._spin_timer_id is None:
self._spin_timer_id = GLib.timeout_add(
self._spin_interval_ms, self._advance_spin
)
def _stop_spin(self):
if self._spin_timer_id is not None:
GLib.source_remove(self._spin_timer_id)
self._spin_timer_id = None
def _advance_spin(self):
self.cover.angle = (self.cover.angle + self._spin_step) % 360
return True
def _update_spin(self):
showing_cover = self.cover_stack.get_visible_child() is self.cover
is_playing = (
self.mpris_player is not None
and self.mpris_player.playback_status == "playing"
)
if showing_cover and is_playing:
self._start_spin()
else:
self._stop_spin()
def _download_artwork(self, arturl):
try:
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl) as response:
data = response.read()
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(data)
tmp.close()
local_path = tmp.name
except Exception:
local_path = None
GLib.idle_add(self._set_cover_from_path, local_path)
return None
def _update_cover(self, mp):
if not mp or not mp.arturl:
self.cover_stack.set_visible_child(self.fallback_icon)
return
parsed = urllib.parse.urlparse(mp.arturl)
if parsed.scheme == "file":
self._set_cover_from_path(urllib.parse.unquote(parsed.path))
elif parsed.scheme in ("http", "https"):
GLib.Thread.new("compact-artwork", self._download_artwork, mp.arturl)
else:
self._set_cover_from_path(mp.arturl)
def _apply_mpris_properties(self): def _apply_mpris_properties(self):
if not self.mpris_player: if not self.mpris_player:
self.mpris_label.set_text("Nothing Playing") self._set_label_text("")
self.mpris_button.get_child().set_markup(icons.stop) self.play_image.set_property("icon-name", self.PLAY_ICON)
self.mpris_icon.get_child().set_markup(icons.disc) self.cover_stack.set_visible_child(self.fallback_icon)
if self._current_display != "cavalcade": self.center_stack.set_visible_child(self.mpris_label)
self.center_stack.set_visible_child( self._update_spin()
self.mpris_label
) # if was title or artist, keep showing label
# else:
# self.center_stack.set_visible_child(
# self.cavalcade_box
# ) # default to cavalcade if no player
return return
mp = self.mpris_player mp = self.mpris_player
self._update_cover(mp)
# Choose icon based on player name.
player_name = (
mp.player_name.lower()
if hasattr(mp, "player_name") and mp.player_name
else ""
)
icon_markup = get_player_icon_markup_by_name(player_name)
self.mpris_icon.get_child().set_markup(icon_markup)
self.update_play_pause_icon() self.update_play_pause_icon()
if self._current_display == "title": if self._current_display == "title":
text = mp.title if mp.title and mp.title.strip() else "Nothing Playing" text = mp.title.strip() if mp.title and mp.title.strip() else ""
self.mpris_label.set_text(text) else: # "artist"
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
self._set_label_text(text)
self.center_stack.set_visible_child(self.mpris_label) self.center_stack.set_visible_child(self.mpris_label)
elif self._current_display == "artist":
text = mp.artist if mp.artist else "Nothing Playing"
self.mpris_label.set_text(text)
self.center_stack.set_visible_child(self.mpris_label)
# else: # default cavalcade
# self.center_stack.set_visible_child(self.cavalcade_box)
def _on_icon_button_press(self, widget, event): def _on_icon_button_press(self, widget, event):
from gi.repository import Gdk if event.type != Gdk.EventType.BUTTON_PRESS:
return True
if event.type == Gdk.EventType.BUTTON_PRESS: if event.button == 2:
players = self.mpris_manager.players if not self.mpris_player:
return True
self._display_index = (self._display_index + 1) % len(self._display_options)
self._current_display = self._display_options[self._display_index]
self._apply_mpris_properties()
return True
players = list(self._players.values())
if not players: if not players:
return True return True
if event.button == 2: # Middle-click: cycle display idx = players.index(self.mpris_player) if self.mpris_player in players else -1
self._display_index = (self._display_index + 1) % len( if event.button == 1:
self._display_options idx = (idx + 1) % len(players)
) elif event.button == 3:
self._current_display = self._display_options[self._display_index] idx = (idx - 1) % len(players)
self._apply_mpris_properties() # Re-apply to update label/cavalcade else:
return True return True
# Cambiar de reproductor según el botón presionado. self.mpris_player = players[idx]
if event.button == 1: # Left-click: next player
self.current_index = (self.current_index + 1) % len(players)
elif event.button == 3: # Right-click: previous player
self.current_index = (self.current_index - 1) % len(players)
if self.current_index < 0:
self.current_index = len(players) - 1
mp_new = MprisPlayer(players[self.current_index])
self.mpris_player = mp_new
# Conectar el evento "changed" para que se actualice
self.mpris_player.connect("changed", self._on_mpris_changed)
self._apply_mpris_properties() self._apply_mpris_properties()
return True # Se consume el evento
return True return True
def _on_play_pause_button_press(self, widget, event): def _on_play_pause_button_press(self, widget, event):
if event.type == Gdk.EventType.BUTTON_PRESS: if event.type != Gdk.EventType.BUTTON_PRESS or not self.mpris_player:
if event.button == 1: # Click izquierdo -> track anterior return True
if self.mpris_player: if event.button == 1:
self.mpris_player.previous()
self.mpris_button.get_child().set_markup(icons.prev)
GLib.timeout_add(500, self._restore_play_pause_icon)
elif event.button == 3: # Click derecho -> siguiente track
if self.mpris_player:
self.mpris_player.next()
self.mpris_button.get_child().set_markup(icons.next)
GLib.timeout_add(500, self._restore_play_pause_icon)
elif event.button == 2: # Click medio -> play/pausa
if self.mpris_player:
self.mpris_player.play_pause() self.mpris_player.play_pause()
self.update_play_pause_icon() self.update_play_pause_icon()
return True elif event.button == 3:
self.mpris_player.next()
self.play_image.set_property("icon-name", self.NEXT_ICON)
GLib.timeout_add(500, self._restore_play_pause_icon)
elif event.button == 2:
self.mpris_player.previous()
self.play_image.set_property("icon-name", self.PREV_ICON)
GLib.timeout_add(500, self._restore_play_pause_icon)
return True return True
def _restore_play_pause_icon(self): def _restore_play_pause_icon(self):
self.update_play_pause_icon() self.update_play_pause_icon()
return False return False
def _on_icon_clicked(
self, widget
): # No longer used, logic moved to _on_icon_button_press
pass
def update_play_pause_icon(self): def update_play_pause_icon(self):
if self.mpris_player and self.mpris_player.playback_status == "playing": playing = (
self.mpris_button.get_child().set_markup(icons.pause) self.mpris_player
else: and self.mpris_player.playback_status == "playing"
self.mpris_button.get_child().set_markup(icons.play) )
self.play_image.set_property(
"icon-name", self.PAUSE_ICON if playing else self.PLAY_ICON
)
self._update_spin()
def _on_play_pause_clicked(self, button): def _track_player(self, playerctl_player):
if self.mpris_player: mp = MprisPlayer(playerctl_player)
self.mpris_player.play_pause() name = mp.player_name
self.update_play_pause_icon() handler_id = mp.connect("changed", self._on_any_player_changed)
self._players[name] = mp
self._player_handlers[name] = handler_id
self._last_status[name] = mp.playback_status
def _on_mpris_changed(self, *args): def _untrack_player(self, name):
# Update properties when the player's state changes. mp = self._players.pop(name, None)
handler_id = self._player_handlers.pop(name, None)
self._last_status.pop(name, None)
if mp and handler_id is not None:
with contextlib.suppress(Exception):
mp.disconnect(handler_id)
def _select_initial_player(self):
for mp in self._players.values():
if mp.playback_status == "playing":
self.mpris_player = mp
return
if self._players:
self.mpris_player = next(iter(self._players.values()))
def _on_any_player_changed(self, player):
name = player.player_name
prev_status = self._last_status.get(name)
cur_status = player.playback_status
self._last_status[name] = cur_status
if player is self.mpris_player:
self._apply_mpris_properties()
return
# Auto-follow: only on a fresh transition into "playing", and only
# if the active player isn't already playing (so a manual selection
# of a paused player isn't overridden by the player it was already
# competing with).
if cur_status == "playing" and prev_status != "playing":
active_playing = (
self.mpris_player
and self.mpris_player.playback_status == "playing"
)
if not active_playing:
self.mpris_player = player
self._apply_mpris_properties() self._apply_mpris_properties()
def on_player_appeared(self, manager, player): def on_player_appeared(self, manager, player):
# When a new player appears, use it if no player is active. self._track_player(player)
if not self.mpris_player: if not self.mpris_player:
mp = MprisPlayer(player) name = player.get_property("player-name")
self.mpris_player = mp self.mpris_player = self._players.get(name)
self._apply_mpris_properties() self._apply_mpris_properties()
self.mpris_player.connect("changed", self._on_mpris_changed)
def on_player_vanished(self, manager, player_name): def on_player_vanished(self, manager, player_name):
players = self.mpris_manager.players was_active = (
if ( self.mpris_player and self.mpris_player.player_name == player_name
players )
and self.mpris_player self._untrack_player(player_name)
and self.mpris_player.player_name == player_name if was_active:
): replacement = next(
if players: # Check if players is not empty after vanishing (mp for mp in self._players.values() if mp.playback_status == "playing"),
self.current_index = self.current_index % len(players) None,
new_player = MprisPlayer(players[self.current_index]) )
self.mpris_player = new_player if replacement is None and self._players:
self.mpris_player.connect("changed", self._on_mpris_changed) replacement = next(iter(self._players.values()))
else: self.mpris_player = replacement
self.mpris_player = None # No players left
elif not players:
self.mpris_player = None
self._apply_mpris_properties() self._apply_mpris_properties()

59
sims/modules/screenrec.py Normal file
View File

@@ -0,0 +1,59 @@
import time
from fabric.utils import invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from gi.repository import GLib
from sims.services.screenrec import ScreenrecService
class ScreenrecWidget(Button):
def __init__(self, service: ScreenrecService, **kwargs):
self._service = service
self._timer_id: int | None = None
self._dot = Label(name="screenrec-dot", label="")
self._elapsed = Label(name="screenrec-elapsed", label="00:00")
super().__init__(
name="screenrec-widget",
child=Box(
orientation="h",
spacing=6,
children=[self._dot, self._elapsed],
),
on_clicked=lambda *_: self._service.stop(),
visible=False,
**kwargs,
)
self.set_no_show_all(True)
self._service.connect("recording-changed", self._on_recording_changed)
if self._service.recording:
self._on_recording_changed(self._service, True)
def _on_recording_changed(self, _service, recording: bool):
if recording:
self._update_elapsed()
self.set_visible(True)
if self._timer_id is None:
self._timer_id = invoke_repeater(1000, self._update_elapsed)
else:
self.set_visible(False)
if self._timer_id is not None:
try:
GLib.source_remove(self._timer_id)
except Exception:
pass
self._timer_id = None
def _update_elapsed(self) -> bool:
started = self._service.started_at
if started is None:
self._elapsed.set_text("00:00")
return True
secs = int(time.monotonic() - started)
self._elapsed.set_text(f"{secs // 60:02d}:{secs % 60:02d}")
return True

View File

@@ -1,4 +1,4 @@
from bar.config import STYLIX from sims.config import STYLIX
import tempfile import tempfile
import os import os
@@ -60,11 +60,17 @@ def generate_stylix_css():
}} }}
/* Bar styling */ /* Bar styling */
#bar-inner {{ #sims-inner {{
padding: 4px; padding: 4px;
border-bottom: solid 2px; border-bottom: solid 2px;
border-color: #{colors["base02"]}; border-color: #{colors["base02"]};
background-color: #{colors["base00"]}; background-color: #{colors["base00"]};
border-radius: 0;
transition: border-radius 200ms ease;
}}
#sims-inner.rounded-bottom {{
border-radius: 0 0 28px 28px;
}} }}
#center-container {{ #center-container {{
@@ -267,9 +273,6 @@ tooltip>* {{
#workspaces>button {{ #workspaces>button {{
background-color: #{colors["base05"]}; background-color: #{colors["base05"]};
border-radius: 100px;
padding: 0px 4px;
transition: padding 0.05s steps(8);
}} }}
#workspaces>button.empty:not(.active):not(.visible) {{ #workspaces>button.empty:not(.active):not(.visible) {{
@@ -282,18 +285,58 @@ tooltip>* {{
#workspaces>button.active {{ #workspaces>button.active {{
background-color: #{colors["base0D"]}; background-color: #{colors["base0D"]};
padding: 0px 16px;
border-radius: 100px;
}} }}
#workspaces>button.urgent {{ #workspaces>button.urgent {{
background-color: #{colors["base08"]}; background-color: #{colors["base08"]};
}} }}
/* Workspace shown on a different monitor than this bar dim it. */
#workspaces>button.foreign {{
opacity: 0.45;
}}
#workspaces>button>label {{ #workspaces>button>label {{
font-size: 0px; font-size: 0px;
}} }}
/* Compact MPRIS player */
#player-small {{
background-color: #{colors["base01"]};
padding: 6px;
border-radius: 100px;
}}
#compact-mpris-icon,
#compact-mpris-button {{
background: transparent;
border: none;
padding: 0 4px;
margin: 0;
box-shadow: none;
min-height: 0;
min-width: 0;
}}
#compact-mpris-button-icon,
#compact-mpris-fallback {{
color: #{colors["base05"]};
}}
#compact-mpris-button:hover #compact-mpris-button-icon {{
color: #{colors["base0D"]};
}}
#compact-mpris-fallback {{
opacity: 0.7;
}}
#compact-mpris-label {{
color: #{colors["base05"]};
font-size: {font_size}px;
margin: 0 6px;
}}
/* Quick Menu styling */ /* Quick Menu styling */
#quick-menu-container {{ #quick-menu-container {{
background-color: #{colors["base00"]}; background-color: #{colors["base00"]};
@@ -356,6 +399,64 @@ tooltip>* {{
.toggle-inactive {{ .toggle-inactive {{
background-color: #{colors["base02"]}; background-color: #{colors["base02"]};
}} }}
/* Launcher (FuzzyMenu finder, app launcher, screenrec menu) */
#picker-box {{
background-color: #{colors["base00"]};
border: solid 1px #{colors["base02"]};
border-radius: 8px;
padding: 12px;
color: #{colors["base05"]};
}}
#search-entry {{
background-color: #{colors["base01"]};
color: #{colors["base05"]};
border: solid 1px #{colors["base02"]};
border-radius: 6px;
padding: 6px 10px;
}}
#viewport {{
background-color: #{colors["base00"]};
border-radius: 6px;
padding: 4px;
color: #{colors["base05"]};
}}
#viewport > * {{
background-color: #{colors["base01"]};
border-left: 3px solid transparent;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 4px;
transition: background-color 80ms ease, border-color 80ms ease;
}}
#viewport > *.selected {{
background-color: alpha(#{colors["base0D"]}, 0.28);
border-left-color: #{colors["base0D"]};
color: #{colors["base05"]};
font-weight: 600;
}}
#viewport > *.selected label {{
color: #{colors["base05"]};
}}
#app-icon {{
margin-right: 4px;
}}
#app-name {{
color: #{colors["base05"]};
font-weight: 500;
}}
#app-generic {{
color: #{colors["base04"]};
font-size: {small_font}px;
}}
""" """
# Write to temporary file # Write to temporary file

View File

@@ -0,0 +1,9 @@
from sims.modules.launcher import FuzzyMenu, WindowProvider
def FuzzyWindowFinder(monitor: int = 0) -> FuzzyMenu:
return FuzzyMenu(
provider=WindowProvider(),
monitor=monitor,
placeholder="Search Windows...",
)

55
sims/services/fenster.py Normal file
View File

@@ -0,0 +1,55 @@
"""
Fenster/Sway IPC connection helper.
Provides a singleton I3 connection configured for Fenster's SWAYSOCK.
"""
import os
from sims.services.i3 import I3, I3MessageType
_connection: I3 | None = None
def get_i3_connection() -> I3:
"""Get the singleton I3 connection, configured for Fenster."""
global _connection
if _connection is None:
swaysock = os.environ.get("SWAYSOCK")
if swaysock:
I3.SOCKET_PATH = swaysock
elif not I3.SOCKET_PATH:
runtime_dir = os.environ.get(
"XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"
)
fallback = os.path.join(runtime_dir, "fenster.sock")
if os.path.exists(fallback):
I3.SOCKET_PATH = fallback
_connection = I3()
return _connection
def focused_output_index() -> int:
"""Index of the currently focused output in active GET_OUTPUTS order.
Matches the indexing main.spawn_bars uses for `monitor=` so layer-shell
windows opened with the same index land on the focused output. Returns 0
on any IPC failure or if the focused output cannot be located.
"""
ws_reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
if not (ws_reply.is_ok and isinstance(ws_reply.reply, list)):
return 0
focused_output = next(
(ws.get("output") for ws in ws_reply.reply if ws.get("focused")),
None,
)
if not focused_output:
return 0
out_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
if not (out_reply.is_ok and isinstance(out_reply.reply, list)):
return 0
active = [o for o in out_reply.reply if o.get("active")]
for i, o in enumerate(active):
if o.get("name") == focused_output:
return i
return 0

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

@@ -265,7 +265,11 @@ class MprisPlayerManager(Service):
def on_name_appeard(self, manager, player_name: Playerctl.PlayerName): def on_name_appeard(self, manager, player_name: Playerctl.PlayerName):
logger.info(f"[MprisPlayer] {player_name.name} appeared") logger.info(f"[MprisPlayer] {player_name.name} appeared")
try:
new_player = Playerctl.Player.new_from_name(player_name) new_player = Playerctl.Player.new_from_name(player_name)
except GLib.Error as e:
logger.warning(f"[MprisPlayer] could not attach to {player_name.name}: {e}")
return
manager.manage_player(new_player) manager.manage_player(new_player)
self.emit("player-appeared", new_player) # type: ignore self.emit("player-appeared", new_player) # type: ignore
@@ -275,7 +279,10 @@ class MprisPlayerManager(Service):
def add_players(self): def add_players(self):
for player in self._manager.get_property("player-names"): # type: ignore for player in self._manager.get_property("player-names"): # type: ignore
try:
self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore
except GLib.Error as e:
logger.warning(f"[MprisPlayer] could not attach to {getattr(player, 'name', player)}: {e}")
@Property(object, "readable") @Property(object, "readable")
def players(self): def players(self):

View File

@@ -0,0 +1,106 @@
"""In-memory notification history.
Subscribes to a fabric Notifications service and snapshots each incoming
notification before fabric drops it on close. Snapshots scale image pixbufs
down to a bounded edge so a flood of high-res previews can't balloon memory.
"""
import time
from collections import deque
from dataclasses import dataclass
from fabric.core.service import Service, Signal
from fabric.notifications import Notification, Notifications
from gi.repository import GdkPixbuf
@dataclass
class HistoryEntry:
id: int
summary: str
body: str
urgency: int
timestamp: float
pixbuf: GdkPixbuf.Pixbuf | None
def _scale_pixbuf(
pixbuf: GdkPixbuf.Pixbuf | None, max_edge: int
) -> GdkPixbuf.Pixbuf | None:
if pixbuf is None:
return None
w, h = pixbuf.get_width(), pixbuf.get_height()
if w <= max_edge and h <= max_edge:
return pixbuf
scale = max_edge / max(w, h)
return pixbuf.scale_simple(
max(1, int(w * scale)),
max(1, int(h * scale)),
GdkPixbuf.InterpType.BILINEAR,
)
class NotificationHistoryService(Service):
@Signal
def changed(self) -> None: ...
def __init__(
self,
notifications: Notifications,
history_size: int = 50,
image_max_px: int = 128,
):
super().__init__()
self._entries: deque[HistoryEntry] = deque(maxlen=history_size)
self._image_max_px = image_max_px
self._unseen_count = 0
notifications.connect("notification-added", self._on_added)
def _on_added(self, service: Notifications, nid: int) -> None:
notification: Notification | None = service.get_notification_from_id(nid)
if notification is None:
return
self._entries.appendleft(
HistoryEntry(
id=nid,
summary=notification.summary or "",
body=notification.body or "",
urgency=notification.urgency,
timestamp=time.time(),
pixbuf=_scale_pixbuf(
notification.image_pixbuf, self._image_max_px
),
)
)
self._unseen_count += 1
self.changed()
@property
def entries(self) -> list[HistoryEntry]:
return list(self._entries)
@property
def unseen_count(self) -> int:
return self._unseen_count
def mark_all_seen(self) -> None:
if self._unseen_count == 0:
return
self._unseen_count = 0
self.changed()
def remove(self, entry_id: int) -> None:
before = len(self._entries)
self._entries = deque(
(e for e in self._entries if e.id != entry_id),
maxlen=self._entries.maxlen,
)
if len(self._entries) != before:
self.changed()
def clear(self) -> None:
if not self._entries:
return
self._entries.clear()
self._unseen_count = 0
self.changed()

230
sims/services/screenrec.py Normal file
View File

@@ -0,0 +1,230 @@
"""wl-screenrec process manager.
Owns the wl-screenrec subprocess so the bar reflects real recording state
without polling. Survives bar restarts via orphan adoption.
"""
import os
import signal
import subprocess
import time
from datetime import datetime
from typing import Literal
from fabric.core.service import Service, Signal
from sims.services.i3 import I3, I3MessageType
from gi.repository import GLib
from loguru import logger
Destination = Literal["videos", "clipboard"]
class ScreenrecService(Service):
@Signal
def recording_changed(self, recording: bool) -> None: ...
def __init__(self, output_dir: str = "~/Videos/wl-screenrec"):
super().__init__()
self._output_dir = os.path.expanduser(output_dir)
self._proc: subprocess.Popen | None = None
self._adopted_pid: int | None = None
self._started_at: float | None = None
self._output_path: str | None = None
self._destination: Destination | None = None
self._watch_id: int | None = None
self._adopt_orphan_if_running()
@property
def recording(self) -> bool:
return self._proc is not None or self._adopted_pid is not None
@property
def started_at(self) -> float | None:
return self._started_at
def start_monitor(self, dest: Destination = "videos", output: str | None = None) -> None:
if self.recording:
logger.warning("[Screenrec] start_monitor: already recording")
return
path = self._make_output_path(dest)
# Prefer an explicit output name when the caller passes one, but fenster's
# IPC reports synthetic names ("Unknown-XXXX") that wl-screenrec can't
# resolve. Fall back to the focused output's geometry, which works
# across i3/sway/fenster.
if output and not output.startswith("Unknown-"):
self._spawn(["wl-screenrec", "-o", output, "-f", path], path, dest)
return
geom = self._focused_output_geometry()
if not geom:
logger.error("[Screenrec] no focused output found")
return
self._spawn(["wl-screenrec", "-g", geom, "-f", path], path, dest)
def start_region(self, dest: Destination = "videos") -> None:
if self.recording:
logger.warning("[Screenrec] start_region: already recording")
return
geom = self._slurp_region()
if not geom:
logger.info("[Screenrec] region selection cancelled")
return
path = self._make_output_path(dest)
self._spawn(["wl-screenrec", "-g", geom, "-f", path], path, dest)
def stop(self) -> None:
if self._proc is not None:
logger.info("[Screenrec] sending SIGINT to wl-screenrec")
try:
self._proc.send_signal(signal.SIGINT)
except ProcessLookupError:
self._on_exit(self._proc.pid, 0)
return
if self._adopted_pid is not None:
logger.info(f"[Screenrec] sending SIGINT to adopted pid {self._adopted_pid}")
try:
os.kill(self._adopted_pid, signal.SIGINT)
except ProcessLookupError:
pass
self._poll_adopted_until_gone()
def _spawn(self, argv: list[str], path: str, dest: Destination) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
logger.info(f"[Screenrec] spawning: {' '.join(argv)}")
try:
self._proc = subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except FileNotFoundError:
logger.error("[Screenrec] wl-screenrec not found on PATH")
return
self._output_path = path
self._destination = dest
self._started_at = time.monotonic()
self._watch_id = GLib.child_watch_add(
GLib.PRIORITY_DEFAULT, self._proc.pid, self._on_exit
)
self.recording_changed(True)
def _on_exit(self, pid: int, status: int) -> None:
logger.info(f"[Screenrec] wl-screenrec exited (pid={pid} status={status})")
path = self._output_path
dest = self._destination
self._reset_state()
if dest == "clipboard" and path and os.path.exists(path):
self._copy_to_clipboard(path)
self.recording_changed(False)
def _reset_state(self) -> None:
self._proc = None
self._adopted_pid = None
self._started_at = None
self._output_path = None
self._destination = None
if self._watch_id is not None:
try:
GLib.source_remove(self._watch_id)
except Exception:
pass
self._watch_id = None
def _copy_to_clipboard(self, path: str) -> None:
try:
with open(path, "rb") as f:
subprocess.Popen(
["wl-copy", "-t", "video/mp4"],
stdin=f,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
logger.info(f"[Screenrec] copied {path} to clipboard as video/mp4")
except FileNotFoundError:
logger.error("[Screenrec] wl-copy not found on PATH")
def _slurp_region(self) -> str | None:
try:
result = subprocess.run(
["slurp"], capture_output=True, text=True, check=False
)
except FileNotFoundError:
logger.error("[Screenrec] slurp not found on PATH")
return None
geom = result.stdout.strip()
return geom or None
def _focused_output(self) -> str | None:
reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
if not (reply.is_ok and isinstance(reply.reply, list)):
return None
for ws in reply.reply:
if ws.get("focused"):
return ws.get("output")
return None
def _focused_output_geometry(self) -> str | None:
"""Return the focused output's geometry as 'X,Y WxH' (slurp format).
Used as a portable fallback when the IPC's output names aren't real
wl_output names (e.g. fenster reports 'Unknown-XXXX').
"""
reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
if not (reply.is_ok and isinstance(reply.reply, list)):
return None
for ws in reply.reply:
if ws.get("focused"):
rect = ws.get("rect") or {}
x = rect.get("x")
y = rect.get("y")
w = rect.get("width")
h = rect.get("height")
if None in (x, y, w, h) or w <= 0 or h <= 0:
return None
return f"{x},{y} {w}x{h}"
return None
def _make_output_path(self, dest: Destination) -> str:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
if dest == "clipboard":
tmp = os.path.join(GLib.get_tmp_dir(), f"sims-screenrec-{stamp}.mp4")
return tmp
return os.path.join(self._output_dir, f"{stamp}.mp4")
def _adopt_orphan_if_running(self) -> None:
try:
result = subprocess.run(
["pgrep", "-x", "wl-screenrec"], capture_output=True, text=True
)
except FileNotFoundError:
return
pid_str = result.stdout.strip().split("\n")[0] if result.stdout.strip() else ""
if not pid_str:
return
try:
self._adopted_pid = int(pid_str)
except ValueError:
return
logger.info(f"[Screenrec] adopted orphan wl-screenrec pid={self._adopted_pid}")
self._started_at = time.monotonic()
self.recording_changed(True)
def _poll_adopted_until_gone(self) -> None:
def check() -> bool:
if self._adopted_pid is None:
return False
try:
os.kill(self._adopted_pid, 0)
except ProcessLookupError:
logger.info("[Screenrec] adopted wl-screenrec finished")
self._reset_state()
self.recording_changed(False)
return False
return True
GLib.timeout_add(250, check)

View File

@@ -0,0 +1,49 @@
"""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::active", self._on_event)
i3.connect("event::smart_corners::inactive", 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

@@ -1,8 +1,14 @@
#bar-inner { #sims-inner {
padding: 4px; padding: 4px;
border-bottom: solid 2px; border-bottom: solid 2px;
border-color: var(--border-color); border-color: var(--border-color);
background-color: var(--window-bg); 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 { #center-container {
@@ -56,10 +62,63 @@
padding: 2px; padding: 2px;
} }
#player-small {
background-color: var(--module-bg);
padding: 6px;
border-radius: 100px;
}
#compact-mpris-icon,
#compact-mpris-button {
background: transparent;
border: none;
padding: 0 4px;
margin: 0;
box-shadow: none;
min-height: 0;
min-width: 0;
}
#compact-mpris-button-icon {
color: var(--foreground);
}
#compact-mpris-button:hover #compact-mpris-button-icon {
color: var(--blue);
}
#compact-mpris-fallback {
color: var(--foreground);
opacity: 0.7;
}
#compact-mpris-label {
color: var(--foreground);
font-size: 13px;
}
#nixos-label { #nixos-label {
color: var(--blue); color: var(--blue);
} }
#screenrec-widget {
background: transparent;
border: none;
padding: 0 6px;
margin: 0;
box-shadow: none;
}
#screenrec-dot {
color: #ff4444;
font-size: 14px;
}
#screenrec-elapsed {
font-size: 12px;
font-family: monospace;
}
tooltip { tooltip {
border: solid 2px; border: solid 2px;
border-color: var(--border-color); border-color: var(--border-color);

View File

@@ -16,51 +16,13 @@
border-radius: 12px; border-radius: 12px;
} }
/* Calendar popup */
#calendar-popup {
background-color: var(--window-bg);
border: solid 2px var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: slide-down 200ms ease-out;
}
@keyframes slide-down {
from {
opacity: 0;
margin-top: -20px;
}
to {
opacity: 1;
margin-top: 10px;
}
}
#calendar-title {
color: var(--foreground);
font-weight: bold;
margin-bottom: 8px;
}
#events-box {
background-color: var(--window-bg);
border: none; /* Remove outline */
border-radius: 8px;
padding: 16px;
}
#no-events {
color: var(--light-grey);
padding: 4px;
}
/* Calendar event items */ /* Calendar event items */
.event-item { .event-item {
border-radius: 6px; border-radius: 8px;
padding: 8px 12px; padding: 8px 10px;
margin: 4px 0px; margin: 2px 0px;
background-color: var(--module-bg); background-color: var(--light-bg);
border: none; /* Remove outline */ border: none;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }

92
sims/styles/launcher.css Normal file
View File

@@ -0,0 +1,92 @@
/* Shared styles for the fuzzy-menu launcher (window finder, app launcher,
screenrec menu, and any future provider built on FuzzyMenu).
Element IDs come from sims/modules/launcher/base.py. */
#picker-box {
padding: 12px;
background-color: rgba(40, 40, 40, 0.95);
border-radius: 8px;
font-family: sans-serif;
font-size: 14px;
color: white;
min-width: 720px;
}
#viewport {
padding: 8px;
background-color: rgba(30, 30, 30, 0.9);
border-radius: 6px;
font-family: sans-serif;
font-size: 14px;
color: white;
}
#viewport > * {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.04);
border-left: 3px solid transparent;
transition: background-color 80ms ease, border-color 80ms ease;
}
#viewport > *.selected {
background-color: rgba(137, 180, 250, 0.28);
border-left-color: #89b4fa;
color: #ffffff;
font-weight: 600;
}
#viewport > *.selected label {
color: #ffffff;
}
/* Provider-specific tweaks (apps launcher) */
#app-icon {
margin-right: 4px;
}
#app-name {
font-weight: 500;
}
#app-generic {
font-size: 11px;
opacity: 0.6;
}
/* Provider-specific tweaks (notmuch search) */
#notmuch-text {
/* Let the date column hug the right edge */
margin-right: 8px;
}
#notmuch-subject {
font-weight: 500;
}
#notmuch-authors {
font-size: 11px;
opacity: 0.7;
}
#notmuch-date {
font-size: 11px;
opacity: 0.6;
}
#notmuch-saved-name {
font-weight: 500;
}
#notmuch-saved-query {
font-size: 11px;
opacity: 0.6;
font-family: monospace;
}
#notmuch-bare {
font-style: italic;
opacity: 0.7;
}

View File

@@ -3,9 +3,10 @@
@import url("./menu.css"); @import url("./menu.css");
@import url("./vinyl.css"); @import url("./vinyl.css");
@import url("./bar.css"); @import url("./bar.css");
@import url("./finder.css"); @import url("./launcher.css");
@import url("./calendar.css"); @import url("./calendar.css");
@import url("./notmuch.css"); @import url("./notmuch.css");
@import url("./notifications.css");
/* unset so we can style everything from the ground up. */ /* unset so we can style everything from the ground up. */

View File

@@ -0,0 +1,179 @@
#notification {
padding: 0.8rem;
border: solid 1px var(--border-color);
border-radius: 1rem;
background-color: var(--mid-bg);
}
#notification .summary {
font-size: 18px;
font-weight: bold;
}
#notification .body {
color: var(--mid-fg);
font-weight: normal;
}
#notification button {
padding: 0.4rem 0.8rem;
font-weight: 600;
border-radius: 0.6rem;
background-color: var(--light-bg);
}
#notification button:hover {
background-color: var(--dark-grey);
}
#notification.urgency-low {
border-color: var(--dark-grey);
}
#notification.urgency-low .summary {
color: var(--mid-fg);
}
#notification.urgency-critical {
border-color: var(--red);
border-width: 2px;
}
#notification.urgency-critical .summary {
color: var(--red);
}
/* Control center side rail */
#control-center {
background-color: transparent;
}
#control-center-body {
background-color: var(--background);
border-left: solid 2px var(--border-color);
border-bottom: solid 2px var(--border-color);
border-bottom-left-radius: 28px;
padding: 50px 12px 12px 12px;
}
#control-center-header {
padding: 4px 8px 12px 8px;
border-bottom: solid 1px var(--border-color);
}
#control-center-title {
font-size: 18px;
font-weight: bold;
color: var(--foreground);
}
#control-center-close {
padding: 4px 8px;
border-radius: 12px;
background-color: var(--module-bg);
}
#control-center-close:hover {
background-color: var(--light-bg);
}
#control-center-sections {
padding: 8px 0;
}
#control-center-settings,
#control-center-calendar,
#control-center-notifications {
padding: 8px;
background-color: var(--module-bg);
border-radius: 12px;
}
#control-center-section-title {
font-size: 12px;
font-weight: bold;
color: var(--light-grey);
padding: 4px 4px 8px 4px;
}
#control-center-settings-row {
padding: 4px 4px;
}
#control-center-settings-label {
font-size: 14px;
color: var(--foreground);
}
#control-center-events {
padding: 0 4px;
}
#control-center-no-events {
color: var(--light-grey);
padding: 6px 4px;
}
#control-center-notifications-header {
padding: 0 4px 6px 4px;
}
#control-center-notifications-clear {
padding: 2px 10px;
border-radius: 8px;
background-color: var(--light-bg);
font-size: 12px;
}
#control-center-notifications-clear:hover {
background-color: var(--dark-grey);
}
#control-center-notifications-list {
padding: 0 4px;
}
#control-center-notifications-empty {
color: var(--light-grey);
padding: 8px 4px;
}
#notification-history-entry {
padding: 10px;
border: solid 1px var(--border-color);
border-radius: 0.75rem;
background-color: var(--light-bg);
}
#notification-history-entry .summary {
font-weight: bold;
}
#notification-history-entry .body {
color: var(--mid-fg);
}
#notification-history-entry .timestamp {
color: var(--light-grey);
font-size: 12px;
padding-left: 8px;
}
#notification-history-entry.urgency-critical {
border-color: var(--red);
}
#notification-history-entry.urgency-critical .summary {
color: var(--red);
}
#notification-history-dismiss {
padding: 2px;
border-radius: 0.5rem;
}
#notification-history-dismiss:hover {
background-color: var(--dark-grey);
}

View File

@@ -23,6 +23,22 @@
background-color: var(--module-bg); background-color: var(--module-bg);
} }
#notmuch-widget.debt-warn {
background-color: var(--orange);
}
#notmuch-widget.debt-warn:hover {
background-color: var(--gold);
}
#notmuch-widget.debt-alarm {
background-color: var(--red);
}
#notmuch-widget.debt-alarm:hover {
background-color: var(--pink);
}
#unread-count { #unread-count {
color: var(--foreground); color: var(--foreground);
font-size: 14px; font-size: 14px;
@@ -30,6 +46,8 @@
min-width: 16px; min-width: 16px;
} }
#notmuch-widget.has-unread #unread-count { #notmuch-widget.has-unread #unread-count,
#notmuch-widget.debt-warn #unread-count,
#notmuch-widget.debt-alarm #unread-count {
color: var(--background); color: var(--background);
} }

View File

@@ -5,7 +5,7 @@
} }
#workspaces>button { #workspaces>button {
padding: 0px 8px; padding: 8px;
transition: padding 0.05s steps(8), background-color 0.15s ease; transition: padding 0.05s steps(8), background-color 0.15s ease;
background-color: var(--ws-inactive); background-color: var(--ws-inactive);
border-radius: 100px; border-radius: 100px;
@@ -28,7 +28,7 @@
} }
#workspaces>button.active { #workspaces>button.active {
padding: 0px 32px; padding: 8px 32px;
background-color: var(--ws-active); background-color: var(--ws-active);
} }
@@ -39,6 +39,10 @@
animation: urgent-blink 1s infinite; animation: urgent-blink 1s infinite;
} }
#workspaces>button.foreign {
opacity: 0.45;
}
@keyframes urgent-blink { @keyframes urgent-blink {
0% { opacity: 1.0; } 0% { opacity: 1.0; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }

28
sims/utils/markup.py Normal file
View File

@@ -0,0 +1,28 @@
import re
from html import escape as html_escape
import gi
gi.require_version("Pango", "1.0")
from gi.repository import GLib, Pango
# Pango cannot render <img ...> from the freedesktop notification spec; strip it.
_IMG_RE = re.compile(r"<img\b[^>]*/?>", re.IGNORECASE)
def render_body_markup(body: str) -> tuple[str, bool]:
"""Return ``(text, is_markup)`` for a notification body.
If the body parses as Pango markup, ``text`` is the cleaned-up markup
string and ``is_markup`` is True. Otherwise ``text`` is the XML-escaped
plain text and ``is_markup`` is False.
"""
if not body:
return "", False
candidate = _IMG_RE.sub("", body)
try:
Pango.parse_markup(candidate, -1, "\0")
except GLib.Error:
return html_escape(body, quote=False), False
return candidate, True

View File

@@ -4,12 +4,12 @@ Fenster widgets for workspace and window management via sway IPC.
from gi.repository import GLib 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.utils.helpers import bulk_connect
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.button import Button from fabric.widgets.button import Button
from fabric.widgets.label import Label from fabric.widgets.label import Label
from bar.services.fenster import get_i3_connection from sims.services.fenster import get_i3_connection
class FensterWorkspaceButton(Button): class FensterWorkspaceButton(Button):
@@ -41,7 +41,7 @@ class FensterWorkspaceButton(Button):
return self._workspace_num return self._workspace_num
def _on_clicked(self, *args): def _on_clicked(self, *args):
self._i3.send_command(f"workspace number {self._workspace_num}") self._i3.send_command(f"workspace {self._workspace_num}")
def _toggle_class(self, name: str, on: bool): def _toggle_class(self, name: str, on: bool):
if on: if on:
@@ -61,6 +61,9 @@ class FensterWorkspaceButton(Button):
def set_urgent(self, urgent: bool): def set_urgent(self, urgent: bool):
self._toggle_class("urgent", urgent) self._toggle_class("urgent", urgent)
def set_foreign(self, foreign: bool):
self._toggle_class("foreign", foreign)
class FensterWorkspaces(Box): class FensterWorkspaces(Box):
"""Container widget showing a fixed set of workspace bubbles (1..N).""" """Container widget showing a fixed set of workspace bubbles (1..N)."""
@@ -108,7 +111,8 @@ class FensterWorkspaces(Box):
) )
if self._i3.ready: if self._i3.ready:
self._schedule_refresh() # Initial state is already current — no need to defer.
self._refresh_workspaces()
else: else:
self._i3.connect("notify::ready", lambda *_: self._schedule_refresh()) self._i3.connect("notify::ready", lambda *_: self._schedule_refresh())
@@ -149,11 +153,13 @@ class FensterWorkspaces(Box):
button.set_visible_other(False) button.set_visible_other(False)
button.set_urgent(False) button.set_urgent(False)
button.set_empty(True) button.set_empty(True)
button.set_foreign(False)
continue continue
focused = bool(ws.get("focused")) focused = bool(ws.get("focused"))
visible = bool(ws.get("visible")) visible = bool(ws.get("visible"))
urgent = bool(ws.get("urgent")) urgent = bool(ws.get("urgent"))
ws_output = ws.get("output")
window_count = ws.get("window_count", 0) window_count = ws.get("window_count", 0)
button.set_active(focused) button.set_active(focused)
@@ -161,6 +167,12 @@ class FensterWorkspaces(Box):
button.set_visible_other(visible and not focused) button.set_visible_other(visible and not focused)
button.set_urgent(urgent) button.set_urgent(urgent)
button.set_empty(window_count == 0) button.set_empty(window_count == 0)
# Workspace currently shown on a different output than this bar's.
button.set_foreign(
self._output is not None
and ws_output is not None
and ws_output != self._output
)
self.show_all() self.show_all()
@@ -202,7 +214,7 @@ class FensterActiveWindow(Label):
if tree_reply.is_ok and isinstance(tree_reply.reply, dict): if tree_reply.is_ok and isinstance(tree_reply.reply, dict):
focused = self._find_focused(tree_reply.reply) focused = self._find_focused(tree_reply.reply)
if focused: if focused:
self._set_title(focused.get("name", "")) self._set_title(focused.get("name") or "")
return return
self.set_label("") self.set_label("")
@@ -217,7 +229,7 @@ class FensterActiveWindow(Label):
def _on_window_event(self, _, event: I3Event): def _on_window_event(self, _, event: I3Event):
container = event.data.get("container", {}) container = event.data.get("container", {})
self._set_title(container.get("name", "")) self._set_title(container.get("name") or "")
def _on_window_close(self, _, event: I3Event): def _on_window_close(self, _, event: I3Event):
self._initialize() self._initialize()

View File

@@ -0,0 +1,136 @@
from fabric.notifications import Notification
from fabric.utils import invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from gi.repository import GdkPixbuf
from sims.utils.markup import render_body_markup
NOTIFICATION_IMAGE_SIZE = 64
class NotificationWidget(Box):
def __init__(
self,
notification: Notification,
width: int = 360,
timeout_ms: int = 10_000,
**kwargs,
):
super().__init__(
size=(width, -1),
name="notification",
spacing=8,
orientation="v",
**kwargs,
)
self._notification = notification
urgency_class = {0: "urgency-low", 1: "urgency-normal", 2: "urgency-critical"}.get(
notification.urgency, "urgency-normal"
)
self.get_style_context().add_class(urgency_class)
body_container = Box(spacing=4, orientation="h")
if image_pixbuf := self._notification.image_pixbuf:
body_container.add(
Image(
pixbuf=image_pixbuf.scale_simple(
NOTIFICATION_IMAGE_SIZE,
NOTIFICATION_IMAGE_SIZE,
GdkPixbuf.InterpType.BILINEAR,
)
)
)
text_children = []
summary = self._notification.summary or ""
body = self._notification.body or ""
text_children.append(
Box(
orientation="h",
children=[
Label(label=summary, ellipsization="middle")
.build()
.add_style_class("summary")
.unwrap(),
],
h_expand=True,
v_expand=True,
).build(
lambda box, _: box.pack_end(
Button(
image=Image(icon_name="window-close-symbolic", icon_size=18),
v_align="center",
h_align="end",
on_clicked=lambda *_: self._notification.close(),
),
False,
False,
0,
)
)
)
if body:
body_text, body_is_markup = render_body_markup(body)
body_kwargs = {"markup": body_text} if body_is_markup else {"label": body_text}
text_children.append(
Label(
**body_kwargs,
line_wrap="word-char",
v_align="start",
h_align="start",
)
.build()
.add_style_class("body")
.unwrap()
)
body_container.add(
Box(
spacing=4,
orientation="v",
children=text_children,
h_expand=True,
v_expand=True,
)
)
self.add(body_container)
if actions := self._notification.actions:
self.add(
Box(
spacing=4,
orientation="h",
children=[
Button(
h_expand=True,
v_expand=True,
label=action.label,
on_clicked=lambda *_, action=action: action.invoke(),
)
for action in actions
],
)
)
self._notification.connect(
"closed",
lambda *_: (
parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore
self.destroy(),
),
)
invoke_repeater(
timeout_ms,
lambda: self._notification.close("expired"),
initial_call=False,
)

View File

@@ -0,0 +1,103 @@
import time
from typing import Callable
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from sims.services.notification_history import HistoryEntry
from sims.utils.markup import render_body_markup
def _time_ago(ts: float, now: float | None = None) -> str:
delta = int((now if now is not None else time.time()) - ts)
if delta < 60:
return "just now"
if delta < 3600:
return f"{delta // 60}m ago"
if delta < 86400:
return f"{delta // 3600}h ago"
return f"{delta // 86400}d ago"
class NotificationHistoryEntryWidget(Box):
THUMB_SIZE = 40
def __init__(
self,
entry: HistoryEntry,
on_dismiss: Callable[[int], None],
**kwargs,
):
super().__init__(
name="notification-history-entry",
spacing=8,
orientation="h",
**kwargs,
)
urgency_class = {0: "urgency-low", 1: "urgency-normal", 2: "urgency-critical"}.get(
entry.urgency, "urgency-normal"
)
self.get_style_context().add_class(urgency_class)
if entry.pixbuf is not None:
self.add(Image(pixbuf=entry.pixbuf, h_align="start", v_align="start"))
text_children: list = []
header = Box(orientation="h", h_expand=True)
header.pack_start(
Label(
label=entry.summary,
ellipsization="end",
h_align="start",
)
.build()
.add_style_class("summary")
.unwrap(),
True,
True,
0,
)
header.pack_end(
Label(label=_time_ago(entry.timestamp), h_align="end")
.build()
.add_style_class("timestamp")
.unwrap(),
False,
False,
0,
)
text_children.append(header)
if entry.body:
body_text, body_is_markup = render_body_markup(entry.body)
body_kwargs = {"markup": body_text} if body_is_markup else {"label": body_text}
text_children.append(
Label(
**body_kwargs,
line_wrap="word-char",
h_align="start",
v_align="start",
)
.build()
.add_style_class("body")
.unwrap()
)
text_box = Box(orientation="v", spacing=2, children=text_children, h_expand=True)
self.add(text_box)
self.pack_end(
Button(
name="notification-history-dismiss",
image=Image(icon_name="window-close-symbolic", icon_size=14),
v_align="start",
h_align="end",
on_clicked=lambda *_: on_dismiss(entry.id),
),
False,
False,
0,
)