Compare commits
2 Commits
5b7398de2c
...
app-launch
| Author | SHA1 | Date | |
|---|---|---|---|
| 6366f57d6e | |||
| 5c5fce2581 |
15
Makefile
15
Makefile
@@ -1,15 +0,0 @@
|
||||
run:
|
||||
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))
|
||||
11
README.md
11
README.md
@@ -1,14 +1,3 @@
|
||||
# Todo
|
||||
|
||||
# Ideas
|
||||
## Org-mode integration
|
||||
- https://github.com/jlumpe/pyorg
|
||||
- https://github.com/jlumpe/ox-json
|
||||
## Emails not seen
|
||||
with notmuch
|
||||
## notch power bar
|
||||
- show the power around the notch
|
||||
- show watts charging/discharging in bar
|
||||
- https://lo.cafe/notchnook
|
||||
## Screenshot menu
|
||||
## Media Playing
|
||||
|
||||
@@ -4,7 +4,7 @@ from platformdirs import user_config_dir
|
||||
import argparse
|
||||
|
||||
|
||||
APP_NAME = "sims"
|
||||
APP_NAME = "makku_bar"
|
||||
|
||||
XDG_CONFIG_HOME = user_config_dir(appname=APP_NAME)
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description="sims")
|
||||
parser = argparse.ArgumentParser(description="makku_bar")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
@@ -50,29 +50,3 @@ if app_config is None:
|
||||
raise Exception("Config file missing")
|
||||
|
||||
VINYL = app_config.get("vinyl", {"enable": False})
|
||||
BATTERY = app_config.get("battery", {"enable": False})
|
||||
WINDOW_TITLE = app_config.get("window_title", {"enable": True})
|
||||
STYLIX = app_config.get("stylix", {"enable": False})
|
||||
CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"})
|
||||
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)
|
||||
LOG_LEVEL = app_config.get("logLevel", "WARNING")
|
||||
DEV = app_config.get("dev", False)
|
||||
57
bar/main.py
Normal file
57
bar/main.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from loguru import logger
|
||||
|
||||
from fabric import Application
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.river.widgets import (
|
||||
get_river_connection,
|
||||
)
|
||||
from fabric.utils import (
|
||||
get_relative_path,
|
||||
)
|
||||
from .modules.bar import StatusBar
|
||||
from .modules.window_fuzzy import FuzzyWindowFinder
|
||||
from .modules.app_launcher import AppLauncher
|
||||
|
||||
|
||||
tray = SystemTray(name="system-tray", spacing=4)
|
||||
river = get_river_connection()
|
||||
|
||||
dummy = Window(visible=False)
|
||||
finder = FuzzyWindowFinder()
|
||||
launcher = AppLauncher()
|
||||
|
||||
bar_windows = []
|
||||
|
||||
app = Application("bar", dummy, finder, launcher)
|
||||
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
||||
|
||||
|
||||
def spawn_bars():
|
||||
logger.info("[Bar] Spawning bars after river ready")
|
||||
outputs = river.outputs
|
||||
|
||||
if not outputs:
|
||||
logger.warning("[Bar] No outputs found — skipping bar spawn")
|
||||
return
|
||||
|
||||
output_ids = sorted(outputs.keys())
|
||||
|
||||
for i, output_id in enumerate(output_ids):
|
||||
bar = StatusBar(display=output_id, tray=tray if i == 0 else None, monitor=i)
|
||||
bar_windows.append(bar)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if river.ready:
|
||||
spawn_bars()
|
||||
else:
|
||||
river.connect("notify::ready", lambda sender, pspec: spawn_bars())
|
||||
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
192
bar/modules/app_launcher.py
Normal file
192
bar/modules/app_launcher.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
example configuration shows how to make a simple
|
||||
desktop applications launcher, this example doesn't involve
|
||||
any styling (except a couple of basic style properties)
|
||||
|
||||
|
||||
the purpose of this configuration is to show to to use
|
||||
the given utils and mainly how using lazy executors might
|
||||
make the configuration way more faster than it's supposed to be
|
||||
"""
|
||||
|
||||
import operator
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
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.entry import Entry
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.utils import DesktopApp, get_desktop_applications, idle_add, remove_handler
|
||||
import subprocess
|
||||
from time import sleep
|
||||
import threading
|
||||
|
||||
|
||||
@dataclass()
|
||||
class CustomApp:
|
||||
name: str
|
||||
generic_name: str | None
|
||||
display_name: str | None
|
||||
description: str | None
|
||||
executable: str | None
|
||||
command_line: str | None
|
||||
hidden: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
display_name=None,
|
||||
executable=None,
|
||||
generic_name=None,
|
||||
description=None,
|
||||
command_line=None,
|
||||
hidden=False,
|
||||
):
|
||||
self.name = name
|
||||
self.generic_name = generic_name
|
||||
self.display_name = display_name
|
||||
self.description = description
|
||||
self.executable = executable
|
||||
self.command_line = command_line
|
||||
self.hidden = hidden
|
||||
|
||||
def launch(self):
|
||||
def background():
|
||||
subprocess.run([self.command_line])
|
||||
|
||||
threading.Thread(target=background, daemon=True).start()
|
||||
|
||||
def get_icon_pixbuf(
|
||||
self,
|
||||
size: int = 48,
|
||||
default_icon: str | None = "image-missing",
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class AppLauncher(Window):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
layer="top",
|
||||
anchor="center",
|
||||
exclusivity="none",
|
||||
keyboard_mode="on-demand",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
self._arranger_handler: int = 0
|
||||
self._all_apps = get_desktop_applications()
|
||||
self._custom_apps = [
|
||||
CustomApp("Screenshot Clipboard", command_line="grim2clip")
|
||||
]
|
||||
|
||||
self.viewport = Box(spacing=2, orientation="v")
|
||||
self.search_entry = Entry(
|
||||
placeholder="Search Applications...",
|
||||
h_expand=True,
|
||||
notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
|
||||
)
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
min_content_size=(280, 320),
|
||||
max_content_size=(280 * 2, 320),
|
||||
child=self.viewport,
|
||||
)
|
||||
|
||||
self.add(
|
||||
Box(
|
||||
spacing=2,
|
||||
orientation="v",
|
||||
style="margin: 2px",
|
||||
children=[
|
||||
# the header with the search entry
|
||||
Box(
|
||||
spacing=2,
|
||||
orientation="h",
|
||||
children=[
|
||||
self.search_entry,
|
||||
Button(
|
||||
image=Image(icon_name="window-close"),
|
||||
tooltip_text="Exit",
|
||||
on_clicked=lambda *_: self.application.quit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
# the actual slots holder
|
||||
self.scrolled_window,
|
||||
],
|
||||
)
|
||||
)
|
||||
self.show_all()
|
||||
|
||||
def arrange_viewport(self, query: str = ""):
|
||||
# reset everything so we can filter current viewport's slots...
|
||||
# remove the old handler so we can avoid race conditions
|
||||
remove_handler(self._arranger_handler) if self._arranger_handler else None
|
||||
|
||||
# remove all children from the viewport
|
||||
self.viewport.children = []
|
||||
|
||||
combined_apps = self._all_apps + self._custom_apps
|
||||
# make a new iterator containing the filtered apps
|
||||
filtered_apps_iter = iter(
|
||||
[
|
||||
app
|
||||
for app in combined_apps
|
||||
if query.casefold()
|
||||
in (
|
||||
(app.display_name or "")
|
||||
+ (" " + app.name + " ")
|
||||
+ (app.generic_name or "")
|
||||
).casefold()
|
||||
]
|
||||
)
|
||||
should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps)
|
||||
|
||||
# all aboard...
|
||||
# start the process of adding slots with a lazy executor
|
||||
# using this method makes the process of adding slots way more less
|
||||
# resource expensive without blocking the main thread and resulting in a lock
|
||||
self._arranger_handler = idle_add(
|
||||
lambda *args: self.add_next_application(*args)
|
||||
or (self.resize_viewport() if should_resize else False),
|
||||
filtered_apps_iter,
|
||||
pin=True,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def add_next_application(self, apps_iter: Iterator[DesktopApp]):
|
||||
if not (app := next(apps_iter, None)):
|
||||
return False
|
||||
|
||||
self.viewport.add(self.bake_application_slot(app))
|
||||
return True
|
||||
|
||||
def resize_viewport(self):
|
||||
self.scrolled_window.set_min_content_width(
|
||||
self.viewport.get_allocation().width # type: ignore
|
||||
)
|
||||
return False
|
||||
|
||||
def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button:
|
||||
return Button(
|
||||
child=Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
children=[
|
||||
Image(pixbuf=app.get_icon_pixbuf(), h_align="start", size=32),
|
||||
Label(
|
||||
label=app.display_name or "Unknown",
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip_text=app.description,
|
||||
on_clicked=lambda *_: (self.hide(), app.launch()),
|
||||
**kwargs,
|
||||
)
|
||||
133
bar/modules/bar.py
Normal file
133
bar/modules/bar.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import psutil
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from bar.modules.player import Player
|
||||
from bar.modules.vinyl import VinylButton
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from fabric.river.widgets import (
|
||||
RiverWorkspaces,
|
||||
RiverWorkspaceButton,
|
||||
RiverActiveWindow,
|
||||
get_river_connection,
|
||||
)
|
||||
from fabric.utils import (
|
||||
invoke_repeater,
|
||||
)
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
|
||||
from bar.config import VINYL
|
||||
|
||||
|
||||
class StatusBar(Window):
|
||||
def __init__(
|
||||
self,
|
||||
display: int,
|
||||
tray: SystemTray | None = None,
|
||||
monitor: int = 1,
|
||||
river_service=None,
|
||||
):
|
||||
super().__init__(
|
||||
name="bar",
|
||||
layer="top",
|
||||
anchor="left top right",
|
||||
margin="0px 0px -2px 0px",
|
||||
exclusivity="auto",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
monitor=monitor,
|
||||
)
|
||||
if river_service:
|
||||
self.river = river_service
|
||||
else:
|
||||
self.river = get_river_connection()
|
||||
|
||||
self.workspaces = RiverWorkspaces(
|
||||
display,
|
||||
name="workspaces",
|
||||
spacing=4,
|
||||
buttons_factory=lambda ws_id: RiverWorkspaceButton(id=ws_id, label=None),
|
||||
river_service=self.river,
|
||||
)
|
||||
self.date_time = DateTime(name="date-time", formatters="%d %b - %H:%M")
|
||||
self.system_tray = tray
|
||||
|
||||
self.active_window = RiverActiveWindow(
|
||||
name="active-window",
|
||||
max_length=50,
|
||||
style="color: #ffffff; font-size: 14px; font-weight: bold;",
|
||||
)
|
||||
|
||||
self.ram_progress_bar = CircularProgressBar(
|
||||
name="ram-progress-bar", pie=True, size=24
|
||||
)
|
||||
self.cpu_progress_bar = CircularProgressBar(
|
||||
name="cpu-progress-bar", pie=True, size=24
|
||||
)
|
||||
|
||||
self.progress_label = Label(
|
||||
"", style="margin: 0px 6px 0px 0px; font-size: 12px"
|
||||
)
|
||||
self.progress_bars_overlay = Overlay(
|
||||
child=self.ram_progress_bar,
|
||||
overlays=[self.cpu_progress_bar, self.progress_label],
|
||||
)
|
||||
self.player = Player()
|
||||
self.vinyl = None
|
||||
if VINYL["enable"]:
|
||||
self.vinyl = VinylButton()
|
||||
|
||||
self.status_container = Box(
|
||||
name="widgets-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=self.progress_bars_overlay,
|
||||
)
|
||||
|
||||
end_container_children = []
|
||||
|
||||
if self.vinyl:
|
||||
end_container_children.append(self.vinyl)
|
||||
|
||||
end_container_children.append(self.status_container)
|
||||
if self.system_tray:
|
||||
end_container_children.append(self.system_tray)
|
||||
|
||||
end_container_children.append(self.date_time)
|
||||
|
||||
self.children = CenterBox(
|
||||
name="bar-inner",
|
||||
start_children=Box(
|
||||
name="start-container",
|
||||
spacing=6,
|
||||
orientation="h",
|
||||
children=[
|
||||
Label(name="nixos-label", markup=""),
|
||||
self.workspaces,
|
||||
],
|
||||
),
|
||||
center_children=Box(
|
||||
name="center-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=[self.active_window],
|
||||
),
|
||||
end_children=Box(
|
||||
name="end-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=end_container_children,
|
||||
),
|
||||
)
|
||||
|
||||
invoke_repeater(1000, self.update_progress_bars)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def update_progress_bars(self):
|
||||
self.ram_progress_bar.value = psutil.virtual_memory().percent / 100
|
||||
self.cpu_progress_bar.value = psutil.cpu_percent() / 100
|
||||
return True
|
||||
274
bar/modules/cavalcade.py
Normal file
274
bar/modules/cavalcade.py
Normal file
@@ -0,0 +1,274 @@
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import re
|
||||
import ctypes
|
||||
import signal
|
||||
|
||||
from gi.repository import GLib, Gtk, Gdk
|
||||
from loguru import logger
|
||||
from math import pi
|
||||
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
|
||||
import configparser
|
||||
|
||||
|
||||
def get_bars(file_path):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(file_path)
|
||||
return int(config["general"]["bars"])
|
||||
|
||||
|
||||
CAVA_CONFIG = get_relative_path("../config/cavalcade/cava.ini")
|
||||
|
||||
bars = get_bars(CAVA_CONFIG)
|
||||
|
||||
|
||||
def set_death_signal():
|
||||
"""
|
||||
Set the death signal of the child process to SIGTERM so that if the parent
|
||||
process is killed, the child (cava) is automatically terminated.
|
||||
"""
|
||||
libc = ctypes.CDLL("libc.so.6")
|
||||
PR_SET_PDEATHSIG = 1
|
||||
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
|
||||
|
||||
|
||||
class Cava:
|
||||
"""
|
||||
CAVA wrapper.
|
||||
Launch cava process with certain settings and read output.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
RUNNING = 1
|
||||
RESTARTING = 2
|
||||
CLOSING = 3
|
||||
|
||||
def __init__(self, mainapp):
|
||||
self.bars = bars
|
||||
self.path = "/tmp/cava.fifo"
|
||||
|
||||
self.cava_config_file = CAVA_CONFIG
|
||||
self.data_handler = mainapp.draw.update
|
||||
self.command = ["cava", "-p", self.cava_config_file]
|
||||
self.state = self.NONE
|
||||
|
||||
self.env = dict(os.environ)
|
||||
self.env["LC_ALL"] = "en_US.UTF-8" # not sure if it's necessary
|
||||
|
||||
is_16bit = True
|
||||
self.byte_type, self.byte_size, self.byte_norm = (
|
||||
("H", 2, 65535) if is_16bit else ("B", 1, 255)
|
||||
)
|
||||
|
||||
if not os.path.exists(self.path):
|
||||
os.mkfifo(self.path)
|
||||
|
||||
self.fifo_fd = None
|
||||
self.fifo_dummy_fd = None
|
||||
self.io_watch_id = None
|
||||
|
||||
def _run_process(self):
|
||||
logger.debug("Launching cava process...")
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
self.command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=self.env,
|
||||
preexec_fn=set_death_signal, # Ensure cava gets killed when the parent dies.
|
||||
)
|
||||
logger.debug("cava successfully launched!")
|
||||
self.state = self.RUNNING
|
||||
except Exception:
|
||||
logger.exception("Fail to launch cava")
|
||||
|
||||
def _start_io_reader(self):
|
||||
logger.debug("Activating GLib IO watch for cava stream handler")
|
||||
# Open FIFO in non-blocking mode for reading
|
||||
self.fifo_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
# Open dummy write end to prevent getting an EOF on our FIFO
|
||||
self.fifo_dummy_fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
self.io_watch_id = GLib.io_add_watch(
|
||||
self.fifo_fd, GLib.IO_IN, self._io_callback
|
||||
)
|
||||
|
||||
def _io_callback(self, source, condition):
|
||||
chunk = self.byte_size * self.bars # number of bytes for given format
|
||||
try:
|
||||
data = os.read(self.fifo_fd, chunk)
|
||||
except OSError as e:
|
||||
# logger.error("Error reading FIFO: {}".format(e))
|
||||
return False
|
||||
|
||||
# When no data is read, do not remove the IO watch immediately.
|
||||
if len(data) < chunk:
|
||||
# Instead of closing the FIFO, we log a warning and continue.
|
||||
# logger.warning("Incomplete data packet received (expected {} bytes, got {}). Waiting for more data...".format(chunk, len(data)))
|
||||
# Returning True keeps the IO watch active. A real EOF will only occur when the writer closes.
|
||||
return True
|
||||
|
||||
fmt = self.byte_type * self.bars # format string for struct.unpack
|
||||
sample = [i / self.byte_norm for i in struct.unpack(fmt, data)]
|
||||
GLib.idle_add(self.data_handler, sample)
|
||||
return True
|
||||
|
||||
def _on_stop(self):
|
||||
logger.debug("Cava stream handler deactivated")
|
||||
if self.state == self.RESTARTING:
|
||||
self.start()
|
||||
elif self.state == self.RUNNING:
|
||||
self.state = self.NONE
|
||||
logger.error("Cava process was unexpectedly terminated.")
|
||||
# self.restart() # May cause infinity loop, need more check
|
||||
|
||||
def start(self):
|
||||
"""Launch cava"""
|
||||
self._start_io_reader()
|
||||
self._run_process()
|
||||
|
||||
def restart(self):
|
||||
"""Restart cava process"""
|
||||
if self.state == self.RUNNING:
|
||||
logger.debug("Restarting cava process (normal mode) ...")
|
||||
self.state = self.RESTARTING
|
||||
if self.process.poll() is None:
|
||||
self.process.kill()
|
||||
elif self.state == self.NONE:
|
||||
logger.warning("Restarting cava process (after crash) ...")
|
||||
self.start()
|
||||
|
||||
def close(self):
|
||||
"""Stop cava process"""
|
||||
self.state = self.CLOSING
|
||||
if self.process.poll() is None:
|
||||
self.process.kill()
|
||||
if self.io_watch_id:
|
||||
GLib.source_remove(self.io_watch_id)
|
||||
if self.fifo_fd:
|
||||
os.close(self.fifo_fd)
|
||||
if self.fifo_dummy_fd:
|
||||
os.close(self.fifo_dummy_fd)
|
||||
if os.path.exists(self.path):
|
||||
os.remove(self.path)
|
||||
|
||||
|
||||
class AttributeDict(dict):
|
||||
"""Dictionary with keys as attributes. Does nothing but easy reading"""
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return self.get(attr, 3)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self[attr] = value
|
||||
|
||||
|
||||
class Spectrum:
|
||||
"""Spectrum drawing"""
|
||||
|
||||
def __init__(self):
|
||||
self.silence_value = 0
|
||||
self.audio_sample = []
|
||||
self.color = None
|
||||
|
||||
self.area = Gtk.DrawingArea()
|
||||
self.area.connect("draw", self.redraw)
|
||||
self.area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
|
||||
self.sizes = AttributeDict()
|
||||
self.sizes.area = AttributeDict()
|
||||
self.sizes.bar = AttributeDict()
|
||||
|
||||
self.silence = 10
|
||||
self.max_height = 12
|
||||
|
||||
self.area.connect("configure-event", self.size_update)
|
||||
self.color_update()
|
||||
|
||||
def is_silence(self, value):
|
||||
"""Check if volume level critically low during last iterations"""
|
||||
self.silence_value = 0 if value > 0 else self.silence_value + 1
|
||||
return self.silence_value > self.silence
|
||||
|
||||
def update(self, data):
|
||||
"""Audio data processing"""
|
||||
self.color_update()
|
||||
self.audio_sample = data
|
||||
if not self.is_silence(self.audio_sample[0]):
|
||||
self.area.queue_draw()
|
||||
elif self.silence_value == (self.silence + 1):
|
||||
self.audio_sample = [0] * self.sizes.number
|
||||
self.area.queue_draw()
|
||||
|
||||
def redraw(self, widget, cr):
|
||||
"""Draw spectrum graph"""
|
||||
cr.set_source_rgba(*self.color)
|
||||
dx = 3
|
||||
|
||||
center_y = self.sizes.area.height / 2 # center vertical of the drawing area
|
||||
for i, value in enumerate(self.audio_sample):
|
||||
width = self.sizes.area.width / self.sizes.number - self.sizes.padding
|
||||
radius = width / 2
|
||||
height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2
|
||||
if height == self.sizes.zero / 2 + 1:
|
||||
height *= 0.5
|
||||
|
||||
height = min(height, self.max_height)
|
||||
|
||||
# Draw rectangle and arcs for rounded ends
|
||||
cr.rectangle(dx, center_y - height, width, height * 2)
|
||||
cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi)
|
||||
cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi)
|
||||
|
||||
cr.close_path()
|
||||
dx += width + self.sizes.padding
|
||||
cr.fill()
|
||||
|
||||
def size_update(self, *args):
|
||||
"""Update drawing geometry"""
|
||||
self.sizes.number = bars
|
||||
self.sizes.padding = 100 / bars
|
||||
self.sizes.zero = 0
|
||||
|
||||
self.sizes.area.width = self.area.get_allocated_width()
|
||||
self.sizes.area.height = self.area.get_allocated_height() - 2
|
||||
|
||||
tw = self.sizes.area.width - self.sizes.padding * (self.sizes.number - 1)
|
||||
self.sizes.bar.width = max(int(tw / self.sizes.number), 1)
|
||||
self.sizes.bar.height = self.sizes.area.height
|
||||
|
||||
def color_update(self):
|
||||
"""Set drawing color according to current settings by reading primary color from CSS"""
|
||||
color = "#a5c8ff" # default value
|
||||
try:
|
||||
with open(get_relative_path("../styles/colors.css"), "r") as f:
|
||||
content = f.read()
|
||||
m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
|
||||
if m:
|
||||
color = m.group(1)
|
||||
except Exception as e:
|
||||
logger.error("Failed to read primary color: {}".format(e))
|
||||
red = int(color[1:3], 16) / 255
|
||||
green = int(color[3:5], 16) / 255
|
||||
blue = int(color[5:7], 16) / 255
|
||||
self.color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
|
||||
|
||||
|
||||
class SpectrumRender:
|
||||
def __init__(self, mode=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.mode = mode
|
||||
|
||||
self.draw = Spectrum()
|
||||
self.cava = Cava(self)
|
||||
self.cava.start()
|
||||
|
||||
def get_spectrum_box(self):
|
||||
# Get the spectrum box
|
||||
box = Overlay(name="cavalcade", h_align="center", v_align="center")
|
||||
box.set_size_request(180, 40)
|
||||
box.add_overlay(self.draw.area)
|
||||
return box
|
||||
@@ -1,41 +1,20 @@
|
||||
import contextlib
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import tempfile
|
||||
from gi.repository import Gtk, GLib, Gio, Gdk, Pango
|
||||
from gi.repository import Gtk, GLib, Gio, Gdk
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.stack import Stack
|
||||
from ..widgets.circle_image import CircleImage
|
||||
import sims.modules.icons as icons
|
||||
from sims.services.mpris import MprisPlayerManager, MprisPlayer
|
||||
from fabric import Fabricator
|
||||
import bar.modules.icons as icons
|
||||
from bar.services.mpris import MprisPlayerManager, MprisPlayer
|
||||
|
||||
# from sims.modules.cavalcade import SpectrumRender
|
||||
|
||||
|
||||
def get_player_progress(fabricator, mpris_player):
|
||||
"""Get player progress for Fabricator"""
|
||||
if not mpris_player:
|
||||
return (0, 0, 0.0)
|
||||
|
||||
try:
|
||||
current = mpris_player.position
|
||||
except Exception:
|
||||
current = 0
|
||||
try:
|
||||
total = int(mpris_player.length or 0)
|
||||
except Exception:
|
||||
total = 0
|
||||
|
||||
progress = current / total if total > 0 else 0.0
|
||||
return (current, total, progress)
|
||||
# from bar.modules.cavalcade import SpectrumRender
|
||||
|
||||
|
||||
def get_player_icon_markup_by_name(player_name):
|
||||
@@ -524,52 +503,30 @@ class Player(Box):
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
|
||||
class PlayerSmall(CenterBox):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="player-small", orientation="h", v_align="center"
|
||||
name="player-small", orientation="h", h_align="fill", v_align="center"
|
||||
)
|
||||
self._display_options = ["title", "artist"]
|
||||
self._show_artist = False # toggle flag
|
||||
self._display_options = ["cavalcade", "title", "artist"]
|
||||
self._display_index = 0
|
||||
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._current_display = "cavalcade"
|
||||
|
||||
self.mpris_icon = Button(
|
||||
name="compact-mpris-icon",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
child=self.cover_stack,
|
||||
child=Label(name="compact-mpris-icon-label", markup=icons.disc),
|
||||
)
|
||||
# Remove scroll events; instead, add button press events.
|
||||
self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
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)
|
||||
|
||||
self.mpris_label = Label(
|
||||
@@ -579,36 +536,36 @@ class PlayerSmall(Box):
|
||||
max_chars_width=26,
|
||||
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(
|
||||
name="compact-mpris-button",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
child=self.play_image,
|
||||
child=Label(name="compact-mpris-button-label", markup=icons.play),
|
||||
)
|
||||
self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
self.mpris_button.connect(
|
||||
"button-press-event", self._on_play_pause_button_press
|
||||
)
|
||||
# Add hover effect
|
||||
add_hover_cursor(self.mpris_button)
|
||||
|
||||
# self.cavalcade = SpectrumRender()
|
||||
# self.cavalcade_box = self.cavalcade.get_spectrum_box()
|
||||
|
||||
self.center_stack = Stack(
|
||||
name="compact-mpris",
|
||||
transition_type="crossfade",
|
||||
transition_duration=100,
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
children=[self.mpris_label],
|
||||
children=[
|
||||
# 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(
|
||||
name="compact-mpris",
|
||||
orientation="h",
|
||||
@@ -617,7 +574,7 @@ class PlayerSmall(Box):
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
start_children=self.mpris_icon,
|
||||
center_children=self.center_stack,
|
||||
center_children=self.center_stack, # Changed to center_stack to handle stack switching
|
||||
end_children=self.mpris_button,
|
||||
)
|
||||
|
||||
@@ -625,307 +582,157 @@ class PlayerSmall(Box):
|
||||
|
||||
self.mpris_manager = MprisPlayerManager()
|
||||
self.mpris_player = None
|
||||
self._players = {}
|
||||
self._player_handlers = {}
|
||||
self._last_status = {}
|
||||
# Almacenar el índice del reproductor actual
|
||||
self.current_index = 0
|
||||
|
||||
for p in self.mpris_manager.players:
|
||||
self._track_player(p)
|
||||
self._select_initial_player()
|
||||
self._apply_mpris_properties()
|
||||
players = self.mpris_manager.players
|
||||
if players:
|
||||
mp = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = mp
|
||||
self._apply_mpris_properties()
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self._apply_mpris_properties()
|
||||
|
||||
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
|
||||
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
|
||||
|
||||
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)
|
||||
self.mpris_button.connect("clicked", self._on_play_pause_clicked)
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
if not self.mpris_player:
|
||||
self._set_label_text("")
|
||||
self.play_image.set_property("icon-name", self.PLAY_ICON)
|
||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
self._update_spin()
|
||||
self.mpris_label.set_text("Nothing Playing")
|
||||
self.mpris_button.get_child().set_markup(icons.stop)
|
||||
self.mpris_icon.get_child().set_markup(icons.disc)
|
||||
if self._current_display != "cavalcade":
|
||||
self.center_stack.set_visible_child(
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
if self._current_display == "title":
|
||||
text = mp.title.strip() if mp.title and mp.title.strip() else ""
|
||||
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)
|
||||
text = mp.title if mp.title and mp.title.strip() else "Nothing Playing"
|
||||
self.mpris_label.set_text(text)
|
||||
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):
|
||||
if event.type != Gdk.EventType.BUTTON_PRESS:
|
||||
return True
|
||||
from gi.repository import Gdk
|
||||
|
||||
if event.button == 2:
|
||||
if not self.mpris_player:
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
players = self.mpris_manager.players
|
||||
if not players:
|
||||
return True
|
||||
self._display_index = (self._display_index + 1) % len(self._display_options)
|
||||
self._current_display = self._display_options[self._display_index]
|
||||
|
||||
if event.button == 2: # Middle-click: cycle display
|
||||
self._display_index = (self._display_index + 1) % len(
|
||||
self._display_options
|
||||
)
|
||||
self._current_display = self._display_options[self._display_index]
|
||||
self._apply_mpris_properties() # Re-apply to update label/cavalcade
|
||||
return True
|
||||
|
||||
# Cambiar de reproductor según el botón presionado.
|
||||
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()
|
||||
return True
|
||||
|
||||
players = list(self._players.values())
|
||||
if not players:
|
||||
return True
|
||||
|
||||
idx = players.index(self.mpris_player) if self.mpris_player in players else -1
|
||||
if event.button == 1:
|
||||
idx = (idx + 1) % len(players)
|
||||
elif event.button == 3:
|
||||
idx = (idx - 1) % len(players)
|
||||
else:
|
||||
return True
|
||||
|
||||
self.mpris_player = players[idx]
|
||||
self._apply_mpris_properties()
|
||||
return True # Se consume el evento
|
||||
return True
|
||||
|
||||
def _on_play_pause_button_press(self, widget, event):
|
||||
if event.type != Gdk.EventType.BUTTON_PRESS or not self.mpris_player:
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
if event.button == 1: # Click izquierdo -> track anterior
|
||||
if self.mpris_player:
|
||||
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.update_play_pause_icon()
|
||||
return True
|
||||
if event.button == 1:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
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
|
||||
|
||||
def _restore_play_pause_icon(self):
|
||||
self.update_play_pause_icon()
|
||||
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):
|
||||
playing = (
|
||||
self.mpris_player
|
||||
and self.mpris_player.playback_status == "playing"
|
||||
)
|
||||
self.play_image.set_property(
|
||||
"icon-name", self.PAUSE_ICON if playing else self.PLAY_ICON
|
||||
)
|
||||
self._update_spin()
|
||||
if self.mpris_player and self.mpris_player.playback_status == "playing":
|
||||
self.mpris_button.get_child().set_markup(icons.pause)
|
||||
else:
|
||||
self.mpris_button.get_child().set_markup(icons.play)
|
||||
|
||||
def _track_player(self, playerctl_player):
|
||||
mp = MprisPlayer(playerctl_player)
|
||||
name = mp.player_name
|
||||
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_play_pause_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
|
||||
def _untrack_player(self, name):
|
||||
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()
|
||||
def _on_mpris_changed(self, *args):
|
||||
# Update properties when the player's state changes.
|
||||
self._apply_mpris_properties()
|
||||
|
||||
def on_player_appeared(self, manager, player):
|
||||
self._track_player(player)
|
||||
# When a new player appears, use it if no player is active.
|
||||
if not self.mpris_player:
|
||||
name = player.get_property("player-name")
|
||||
self.mpris_player = self._players.get(name)
|
||||
mp = MprisPlayer(player)
|
||||
self.mpris_player = mp
|
||||
self._apply_mpris_properties()
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
|
||||
def on_player_vanished(self, manager, player_name):
|
||||
was_active = (
|
||||
self.mpris_player and self.mpris_player.player_name == player_name
|
||||
)
|
||||
self._untrack_player(player_name)
|
||||
if was_active:
|
||||
replacement = next(
|
||||
(mp for mp in self._players.values() if mp.playback_status == "playing"),
|
||||
None,
|
||||
)
|
||||
if replacement is None and self._players:
|
||||
replacement = next(iter(self._players.values()))
|
||||
self.mpris_player = replacement
|
||||
self._apply_mpris_properties()
|
||||
players = self.mpris_manager.players
|
||||
if (
|
||||
players
|
||||
and self.mpris_player
|
||||
and self.mpris_player.player_name == player_name
|
||||
):
|
||||
if players: # Check if players is not empty after vanishing
|
||||
self.current_index = self.current_index % len(players)
|
||||
new_player = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = new_player
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self.mpris_player = None # No players left
|
||||
elif not players:
|
||||
self.mpris_player = None
|
||||
self._apply_mpris_properties()
|
||||
92
bar/modules/vinyl.py
Normal file
92
bar/modules/vinyl.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.core.service import Property
|
||||
import subprocess
|
||||
|
||||
|
||||
class VinylButton(Box):
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
@active.setter
|
||||
def active(self, value: bool):
|
||||
self._active = value
|
||||
# Update appearance based on state
|
||||
self._update_appearance()
|
||||
|
||||
# Execute shell command based on new state
|
||||
if self._active:
|
||||
self._execute_active_command()
|
||||
else:
|
||||
self._execute_inactive_command()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
active_command="""pw-link alsa_input.pci-0000_12_00.6.analog-stereo:capture_FL alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX0
|
||||
pw-link alsa_input.pci-0000_12_00.6.analog-stereo:capture_FR alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX1""",
|
||||
inactive_command="""pw-link -d alsa_input.pci-0000_12_00.6.analog-stereo:capture_FL alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX0
|
||||
pw-link -d alsa_input.pci-0000_12_00.6.analog-stereo:capture_FR alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX1 """,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Initialize properties
|
||||
self._active = False
|
||||
self._active_command = active_command
|
||||
self._inactive_command = inactive_command
|
||||
|
||||
# Set up the icon
|
||||
self.icon = Label(
|
||||
label="", # CD icon
|
||||
name="vinyl-icon",
|
||||
style="",
|
||||
)
|
||||
|
||||
# Set up event box to handle clicks
|
||||
self.event_box = EventBox(
|
||||
events="button-press",
|
||||
child=Overlay(
|
||||
child=self.icon,
|
||||
),
|
||||
name="vinyl-button",
|
||||
)
|
||||
|
||||
# Connect click event
|
||||
self.event_box.connect("button-press-event", self._on_clicked)
|
||||
|
||||
# Add to parent box
|
||||
self.add(self.event_box)
|
||||
|
||||
# Initialize appearance
|
||||
self._update_appearance()
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update CSS class based on active state"""
|
||||
if self._active:
|
||||
self.add_style_class("active")
|
||||
else:
|
||||
self.remove_style_class("active")
|
||||
|
||||
def _on_clicked(self, _, event):
|
||||
"""Handle button click event"""
|
||||
if event.button == 1: # Left click
|
||||
# Toggle active state
|
||||
self.active = not self.active
|
||||
return True
|
||||
|
||||
def _execute_active_command(self):
|
||||
"""Execute shell command when button is activated"""
|
||||
try:
|
||||
subprocess.Popen(self._active_command, shell=True)
|
||||
except Exception as e:
|
||||
print(f"Error executing active command: {e}")
|
||||
|
||||
def _execute_inactive_command(self):
|
||||
"""Execute shell command when button is deactivated"""
|
||||
try:
|
||||
subprocess.Popen(self._inactive_command, shell=True)
|
||||
except Exception as e:
|
||||
print(f"Error executing inactive command: {e}")
|
||||
75
bar/modules/window_fuzzy.py
Normal file
75
bar/modules/window_fuzzy.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import operator
|
||||
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 fabric.utils import idle_add
|
||||
from gi.repository import Gdk
|
||||
|
||||
|
||||
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._all_windows = ["Test", "Uwu", "Tidal"]
|
||||
|
||||
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 notify_text(self, entry, *_):
|
||||
text = entry.get_text()
|
||||
self.arrange_viewport(text) # Update list on typing
|
||||
print(text)
|
||||
|
||||
def on_search_entry_key_press(self, widget, event):
|
||||
# if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
|
||||
# self.move_selection_2d(event.keyval)
|
||||
# return True
|
||||
print(event.keyval)
|
||||
if event.keyval in [Gdk.KEY_Escape, 103]:
|
||||
self.hide()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_search_entry_activate(self, text):
|
||||
print(f"activate {text}")
|
||||
|
||||
def arrange_viewport(self, query: str = ""):
|
||||
self.viewport.children = [] # Clear previous entries
|
||||
|
||||
filtered = [w for w in self._all_windows if query.lower() in w.lower()]
|
||||
|
||||
for window in filtered:
|
||||
self.viewport.add(
|
||||
Box(name="slot-box", orientation="h", children=[Label(label=window)])
|
||||
)
|
||||
@@ -265,11 +265,7 @@ class MprisPlayerManager(Service):
|
||||
|
||||
def on_name_appeard(self, manager, player_name: Playerctl.PlayerName):
|
||||
logger.info(f"[MprisPlayer] {player_name.name} appeared")
|
||||
try:
|
||||
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
|
||||
new_player = Playerctl.Player.new_from_name(player_name)
|
||||
manager.manage_player(new_player)
|
||||
self.emit("player-appeared", new_player) # type: ignore
|
||||
|
||||
@@ -279,10 +275,7 @@ class MprisPlayerManager(Service):
|
||||
|
||||
def add_players(self):
|
||||
for player in self._manager.get_property("player-names"): # type: ignore
|
||||
try:
|
||||
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}")
|
||||
self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore
|
||||
|
||||
@Property(object, "readable")
|
||||
def players(self):
|
||||
51
bar/styles/bar.css
Normal file
51
bar/styles/bar.css
Normal file
@@ -0,0 +1,51 @@
|
||||
#bar-inner {
|
||||
padding: 4px;
|
||||
border-bottom: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
#center-container {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.active-window {
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#cpu-progress-bar,
|
||||
#ram-progress-bar,
|
||||
#volume-progress-bar {
|
||||
color: transparent;
|
||||
background-color: transparent
|
||||
}
|
||||
|
||||
#cpu-progress-bar {
|
||||
border: solid 0px alpha(var(--violet), 0.8);
|
||||
}
|
||||
|
||||
#ram-progress-bar,
|
||||
#volume-progress-bar {
|
||||
border: solid 0px var(--blue);
|
||||
}
|
||||
|
||||
#widgets-container {
|
||||
background-color: var(--module-bg);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#nixos-label {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
tooltip {
|
||||
border: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
}
|
||||
|
||||
tooltip>* {
|
||||
padding: 2px 4px
|
||||
}
|
||||
@@ -20,9 +20,8 @@
|
||||
--window-bg: alpha(var(--background), 0.9);
|
||||
--module-bg: alpha(var(--mid-bg), 0.8);
|
||||
--border-color: var(--light-bg);
|
||||
--ws-active: var(--blue);
|
||||
--ws-visible: var(--violet);
|
||||
--ws-inactive: var(--light-grey);
|
||||
--ws-active: var(--pink);
|
||||
--ws-inactive: var(--blue);
|
||||
--ws-empty: var(--dark-grey);
|
||||
--ws-hover: var(--turquoise);
|
||||
--ws-urgent: var(--red);
|
||||
29
bar/styles/finder.css
Normal file
29
bar/styles/finder.css
Normal file
@@ -0,0 +1,29 @@
|
||||
#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 */
|
||||
}
|
||||
@@ -3,10 +3,7 @@
|
||||
@import url("./menu.css");
|
||||
@import url("./vinyl.css");
|
||||
@import url("./bar.css");
|
||||
@import url("./launcher.css");
|
||||
@import url("./calendar.css");
|
||||
@import url("./notmuch.css");
|
||||
@import url("./notifications.css");
|
||||
@import url("./finder.css");
|
||||
|
||||
|
||||
/* unset so we can style everything from the ground up. */
|
||||
41
bar/styles/vinyl.css
Normal file
41
bar/styles/vinyl.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Vinyl button styling */
|
||||
#vinyl-button {
|
||||
padding: 0px 8px;
|
||||
transition: padding 0.05s steps(8);
|
||||
background-color: rgba(180, 180, 180, 0.2);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Active state styling */
|
||||
.active #vinyl-button {
|
||||
background-color: rgba(108, 158, 175, 0.7);
|
||||
padding: 0px 32px;
|
||||
}
|
||||
|
||||
/* Icon styling */
|
||||
#vinyl-icon {
|
||||
color: #555555;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
/* Label styling */
|
||||
#vinyl-label {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* Active state changes for icon and label */
|
||||
.active #vinyl-icon,
|
||||
.active #vinyl-label {
|
||||
color: var(--pink);
|
||||
padding: 0px 32px;
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
#vinyl-button:hover {
|
||||
background-color: rgba(180, 180, 180, 0.4);
|
||||
}
|
||||
|
||||
.active #vinyl-button:hover {
|
||||
background-color: rgba(108, 158, 175, 0.9);
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
}
|
||||
|
||||
#workspaces>button {
|
||||
padding: 8px;
|
||||
transition: padding 0.05s steps(8), background-color 0.15s ease;
|
||||
background-color: var(--ws-inactive);
|
||||
padding: 0px 8px;
|
||||
transition: padding 0.05s steps(8);
|
||||
background-color: var(--foreground);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
@@ -15,36 +15,28 @@
|
||||
font-size: 0px;
|
||||
}
|
||||
|
||||
#workspaces>button:hover {
|
||||
#workspaces button.hover {
|
||||
background-color: var(--ws-hover);
|
||||
}
|
||||
|
||||
#workspaces>button.empty {
|
||||
background-color: var(--ws-empty);
|
||||
}
|
||||
|
||||
#workspaces>button.visible {
|
||||
background-color: var(--ws-visible);
|
||||
}
|
||||
|
||||
#workspaces>button.active {
|
||||
padding: 8px 32px;
|
||||
background-color: var(--ws-active);
|
||||
}
|
||||
|
||||
#workspaces>button.urgent {
|
||||
#workspaces button.urgent {
|
||||
background-color: var(--ws-urgent);
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
animation: urgent-blink 1s infinite;
|
||||
}
|
||||
|
||||
#workspaces>button.foreign {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@keyframes urgent-blink {
|
||||
0% { opacity: 1.0; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1.0; }
|
||||
}
|
||||
|
||||
#workspaces>button.empty {
|
||||
background-color: var(--ws-empty);
|
||||
}
|
||||
|
||||
#workspaces>button.active {
|
||||
padding: 0px 32px;
|
||||
background-color: var(--ws-active);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
height: 42
|
||||
dev: true
|
||||
window_title:
|
||||
enable: true
|
||||
vinyl:
|
||||
enable: true
|
||||
battery:
|
||||
enable: true
|
||||
calendar:
|
||||
enable: true
|
||||
khal_path: "khal" # or full path like "/home/user/.nix-profile/bin/khal"
|
||||
notmuch:
|
||||
enable: true
|
||||
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"
|
||||
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:
|
||||
enable: true
|
||||
colors:
|
||||
base00: "1e1e2e" # background
|
||||
base01: "313244" # lighter background
|
||||
base02: "45475a" # selection background
|
||||
base03: "585b70" # comments
|
||||
base04: "bac2de" # dark foreground
|
||||
base05: "cdd6f4" # foreground
|
||||
base06: "f5e0dc" # light foreground
|
||||
base07: "b4befe" # light background
|
||||
base08: "f38ba8" # red
|
||||
base09: "fab387" # orange
|
||||
base0A: "f9e2af" # yellow
|
||||
base0B: "a6e3a1" # green
|
||||
base0C: "94e2d5" # cyan
|
||||
base0D: "89b4fa" # blue
|
||||
base0E: "cba6f7" # purple
|
||||
base0F: "f2cdcd" # brown
|
||||
fonts:
|
||||
sansSerif: "Inter"
|
||||
serif: "Times New Roman"
|
||||
monospace: "JetBrains Mono"
|
||||
sizes:
|
||||
desktop: 16
|
||||
applications: 14
|
||||
terminal: 16
|
||||
popups: 14
|
||||
@@ -1,7 +1,2 @@
|
||||
bar_height: 42
|
||||
window_title:
|
||||
enable: false
|
||||
vinyl:
|
||||
enable: true
|
||||
battery:
|
||||
enable: true
|
||||
enabled: true
|
||||
|
||||
10
flake.lock
generated
10
flake.lock
generated
@@ -6,15 +6,15 @@
|
||||
"utils": "utils"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770146720,
|
||||
"narHash": "sha256-YVlwsUz4SLj8qYAb21ernT3lDB/piU1V6hTW/UjikWA=",
|
||||
"owner": "Fabric-Development",
|
||||
"lastModified": 1747045720,
|
||||
"narHash": "sha256-2Z0F4hnluJZunwRfx80EQXpjGLhunV2wrseT42nzh7M=",
|
||||
"owner": "Makesesama",
|
||||
"repo": "fabric",
|
||||
"rev": "fd2aabbd7e1859aa7c11c626a6c36a937aca736a",
|
||||
"rev": "dae50c763e8bf2b4e5807b49b9e62425e0725cfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Fabric-Development",
|
||||
"owner": "Makesesama",
|
||||
"repo": "fabric",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
217
flake.nix
217
flake.nix
@@ -1,11 +1,11 @@
|
||||
{
|
||||
description = "sims status bar (companion to fenster WM).";
|
||||
description = "Fabric Bar Example";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/24.11";
|
||||
unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
fabric.url = "github:Fabric-Development/fabric";
|
||||
fabric.url = "github:Makesesama/fabric";
|
||||
home-manager.url = "github:nix-community/home-manager";
|
||||
home-manager.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
@@ -32,19 +32,20 @@
|
||||
{
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
devShells.default = pkgs.callPackage ./nix/shell.nix { inherit pkgs; };
|
||||
packages = rec {
|
||||
packages = {
|
||||
default = pkgs.callPackage ./nix/derivation.nix { inherit (pkgs) lib python3Packages; };
|
||||
sims = default;
|
||||
makku = pkgs.writeShellScriptBin "makku" ''
|
||||
dbus-send --session --print-reply --dest=org.Fabric.fabric.bar /org/Fabric/fabric org.Fabric.fabric.Evaluate string:"finder.show()" > /dev/null 2>&1
|
||||
'';
|
||||
};
|
||||
apps.default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/sims";
|
||||
program = "${self.packages.${system}.default}/bin/bar";
|
||||
};
|
||||
}
|
||||
)
|
||||
// {
|
||||
homeManagerModules = {
|
||||
sims =
|
||||
homeManagerModules.makku-bar =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
@@ -52,18 +53,18 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.sims;
|
||||
cfg = config.services.makku-bar;
|
||||
|
||||
settingsFormat = pkgs.formats.yaml { };
|
||||
in
|
||||
{
|
||||
options.services.sims = {
|
||||
enable = lib.mkEnableOption "sims status bar";
|
||||
options.services.makku-bar = {
|
||||
enable = lib.mkEnableOption "makku-bar status bar";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.default;
|
||||
description = "The sims package to use.";
|
||||
description = "The makku-bar package to use.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
@@ -75,211 +76,27 @@
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
battery = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
height = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 40;
|
||||
description = "Height of the status bar in pixels";
|
||||
};
|
||||
logLevel = lib.mkOption {
|
||||
type = lib.types.enum [ "TRACE" "DEBUG" "INFO" "SUCCESS" "WARNING" "ERROR" "CRITICAL" ];
|
||||
default = "WARNING";
|
||||
description = "Log level for the status bar (loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)";
|
||||
};
|
||||
window_title = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to show the window title in the center of the bar";
|
||||
};
|
||||
};
|
||||
stylix = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
default = { enable = false; };
|
||||
description = "Stylix configuration passed from the stylix module";
|
||||
};
|
||||
calendar = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the calendar widget";
|
||||
};
|
||||
khal_path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "khal";
|
||||
description = "Path to the khal binary";
|
||||
};
|
||||
};
|
||||
notmuch = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the notmuch email widget";
|
||||
};
|
||||
notmuch_path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "notmuch";
|
||||
description = "Path to the notmuch binary";
|
||||
};
|
||||
emacsclient_command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "emacsclient";
|
||||
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";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
default = {
|
||||
vinyl.enable = false;
|
||||
battery.enable = false;
|
||||
height = 40;
|
||||
logLevel = "WARNING";
|
||||
window_title.enable = true;
|
||||
stylix.enable = false;
|
||||
calendar = {
|
||||
enable = true;
|
||||
khal_path = "khal";
|
||||
};
|
||||
notmuch = {
|
||||
enable = true;
|
||||
notmuch_path = "notmuch";
|
||||
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.sims.enable {
|
||||
systemd.user.services.sims =
|
||||
config = lib.mkIf config.services.makku-bar.enable {
|
||||
systemd.user.services.makku-bar =
|
||||
let
|
||||
configFile = settingsFormat.generate "config.yaml" cfg.settings;
|
||||
in
|
||||
{
|
||||
Unit = {
|
||||
Description = "sims status bar";
|
||||
Description = "Makku Status Bar";
|
||||
After = [ "graphical-session.target" ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
|
||||
ExecStart = "${config.services.makku-bar.package}/bin/bar --config ${configFile}";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
|
||||
@@ -289,7 +106,5 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
stylix-sims = import ./nix/stylix/hm.nix;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,14 +12,11 @@
|
||||
wrapGAppsHook3,
|
||||
playerctl,
|
||||
webp-pixbuf-loader,
|
||||
notmuch,
|
||||
khal,
|
||||
dbus,
|
||||
...
|
||||
}:
|
||||
|
||||
python3Packages.buildPythonApplication {
|
||||
pname = "sims";
|
||||
pname = "fabric-nix-example";
|
||||
version = "0.0.1";
|
||||
pyproject = true;
|
||||
|
||||
@@ -41,8 +38,6 @@ python3Packages.buildPythonApplication {
|
||||
gdk-pixbuf
|
||||
playerctl
|
||||
webp-pixbuf-loader
|
||||
notmuch
|
||||
khal
|
||||
];
|
||||
|
||||
dependencies = with python3Packages; [
|
||||
@@ -58,38 +53,24 @@ python3Packages.buildPythonApplication {
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/${python3Packages.python.sitePackages}
|
||||
cp -r sims $out/${python3Packages.python.sitePackages}/
|
||||
cp -r bar $out/${python3Packages.python.sitePackages}/
|
||||
|
||||
# If you have any scripts to install
|
||||
mkdir -p $out/bin
|
||||
cp scripts/launcher.py $out/bin/sims
|
||||
chmod +x $out/bin/sims
|
||||
cp scripts/cli_launcher.py $out/bin/sims-cli
|
||||
chmod +x $out/bin/sims-cli
|
||||
|
||||
cp scripts/launcher.py $out/bin/bar
|
||||
chmod +x $out/bin/bar
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
preFixup = ''
|
||||
makeWrapperArgs+=("''${gappsWrapperArgs[@]}")
|
||||
makeWrapperArgs+=(--prefix PATH : ${
|
||||
lib.makeBinPath [
|
||||
khal
|
||||
notmuch
|
||||
dbus
|
||||
]
|
||||
})
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit khal notmuch;
|
||||
};
|
||||
|
||||
meta = {
|
||||
changelog = "";
|
||||
description = ''
|
||||
sims status bar (companion to fenster WM).
|
||||
Fabrix Bar Example
|
||||
'';
|
||||
homepage = "https://github.com/wholikeel/fabric";
|
||||
license = lib.licenses.agpl3Only;
|
||||
|
||||
@@ -22,7 +22,6 @@ pkgs.mkShell {
|
||||
gobject-introspection
|
||||
libdbusmenu-gtk3
|
||||
gdk-pixbuf
|
||||
librsvg
|
||||
gnome-bluetooth
|
||||
cinnamon-desktop
|
||||
wayland-scanner
|
||||
@@ -47,8 +46,4 @@ pkgs.mkShell {
|
||||
]
|
||||
))
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export GDK_PIXBUF_MODULE_FILE=${pkgs.librsvg}/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
cfg = config.stylix.targets.sims;
|
||||
in
|
||||
{
|
||||
options.stylix.targets.sims.enable =
|
||||
config.lib.stylix.mkEnableTarget "sims" true;
|
||||
|
||||
config = lib.mkIf (config.stylix.enable && cfg.enable) {
|
||||
services.sims.settings.stylix = {
|
||||
enable = true;
|
||||
colors = {
|
||||
base00 = config.lib.stylix.colors.base00; # background
|
||||
base01 = config.lib.stylix.colors.base01; # lighter background
|
||||
base02 = config.lib.stylix.colors.base02; # selection background
|
||||
base03 = config.lib.stylix.colors.base03; # comments
|
||||
base04 = config.lib.stylix.colors.base04; # dark foreground
|
||||
base05 = config.lib.stylix.colors.base05; # foreground
|
||||
base06 = config.lib.stylix.colors.base06; # light foreground
|
||||
base07 = config.lib.stylix.colors.base07; # light background
|
||||
base08 = config.lib.stylix.colors.base08; # red
|
||||
base09 = config.lib.stylix.colors.base09; # orange
|
||||
base0A = config.lib.stylix.colors.base0A; # yellow
|
||||
base0B = config.lib.stylix.colors.base0B; # green
|
||||
base0C = config.lib.stylix.colors.base0C; # cyan
|
||||
base0D = config.lib.stylix.colors.base0D; # blue
|
||||
base0E = config.lib.stylix.colors.base0E; # purple
|
||||
base0F = config.lib.stylix.colors.base0F; # brown
|
||||
};
|
||||
fonts = {
|
||||
serif = config.stylix.fonts.serif.name;
|
||||
sansSerif = config.stylix.fonts.sansSerif.name;
|
||||
monospace = config.stylix.fonts.monospace.name;
|
||||
sizes = {
|
||||
desktop = config.stylix.fonts.sizes.desktop;
|
||||
applications = config.stylix.fonts.sizes.applications;
|
||||
terminal = config.stylix.fonts.sizes.terminal;
|
||||
popups = config.stylix.fonts.sizes.popups;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{ lib }:
|
||||
{
|
||||
name = "Makku Bar";
|
||||
homepage = "https://github.com/Makesesama/makku-bar";
|
||||
maintainers = [ ];
|
||||
}
|
||||
@@ -3,14 +3,14 @@ requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sims"
|
||||
name = "fabric-nix-example"
|
||||
version = "0.0.1"
|
||||
requires-python = ">= 3.11"
|
||||
authors = [
|
||||
]
|
||||
maintainers = [
|
||||
]
|
||||
description = "sims status bar (companion to fenster WM)."
|
||||
description = "Fabric using Nix example."
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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())
|
||||
@@ -15,7 +15,7 @@ if site_packages_dir not in sys.path:
|
||||
sys.path.insert(0, site_packages_dir)
|
||||
|
||||
|
||||
from sims.main import *
|
||||
from bar.main import *
|
||||
|
||||
sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__))
|
||||
sys.exit(main())
|
||||
|
||||
171
sims/cli.py
171
sims/cli.py
@@ -1,171 +0,0 @@
|
||||
"""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()
|
||||
244
sims/main.py
244
sims/main.py
@@ -1,244 +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 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()
|
||||
@@ -1,201 +0,0 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from sims.modules.player import Player, PlayerSmall
|
||||
from sims.modules.battery import Battery
|
||||
from sims.modules.control_center import ControlCenter
|
||||
from sims.modules.notmuch import NotmuchWidget
|
||||
from sims.modules.screenrec import ScreenrecWidget
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
|
||||
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 sims.services.system_stats import SystemStatsService
|
||||
|
||||
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
|
||||
|
||||
|
||||
class StatusBar(Window):
|
||||
def __init__(
|
||||
self,
|
||||
display: str,
|
||||
tray: SystemTray | None = None,
|
||||
monitor: int = 1,
|
||||
screenrec_service: ScreenrecService | None = None,
|
||||
control_center: ControlCenter | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
name="sims",
|
||||
layer="top",
|
||||
anchor="left top right",
|
||||
margin="0px 0px -2px 0px",
|
||||
exclusivity="auto",
|
||||
visible=False,
|
||||
all_visible=False,
|
||||
monitor=monitor,
|
||||
)
|
||||
self.output = display
|
||||
self._corners_rounded = False
|
||||
self._right_flat = False
|
||||
|
||||
self.workspaces = FensterWorkspaces(
|
||||
output=display,
|
||||
name="workspaces",
|
||||
spacing=4,
|
||||
)
|
||||
|
||||
datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M")
|
||||
self.date_time = Button(
|
||||
name="date-time-button",
|
||||
child=datetime_widget,
|
||||
on_clicked=self._on_date_time_clicked,
|
||||
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
|
||||
)
|
||||
self.control_center = control_center
|
||||
|
||||
self.system_tray = tray
|
||||
|
||||
self.active_window = FensterActiveWindow(
|
||||
name="active-window",
|
||||
max_length=50,
|
||||
style="color: #ffffff; font-size: 14px; font-weight: bold;",
|
||||
)
|
||||
|
||||
self.ram_progress_bar = CircularProgressBar(
|
||||
name="ram-progress-bar", pie=True, size=24
|
||||
)
|
||||
self.cpu_progress_bar = CircularProgressBar(
|
||||
name="cpu-progress-bar", pie=True, size=24
|
||||
)
|
||||
|
||||
self.progress_label = Label(
|
||||
"", style="margin: 0px 6px 0px 0px; font-size: 12px"
|
||||
)
|
||||
self.progress_bars_overlay = Overlay(
|
||||
child=self.ram_progress_bar,
|
||||
overlays=[self.cpu_progress_bar, self.progress_label],
|
||||
)
|
||||
self.player = Player()
|
||||
self.player_small = PlayerSmall()
|
||||
|
||||
self.battery = None
|
||||
if BATTERY["enable"]:
|
||||
self.battery = Battery()
|
||||
|
||||
self.notmuch = None
|
||||
if NOTMUCH["enable"]:
|
||||
self.notmuch = NotmuchWidget()
|
||||
|
||||
self.screenrec = None
|
||||
if screenrec_service is not None:
|
||||
self.screenrec = ScreenrecWidget(screenrec_service)
|
||||
|
||||
self.status_container = Box(
|
||||
name="widgets-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=self.progress_bars_overlay,
|
||||
)
|
||||
|
||||
end_container_children = []
|
||||
|
||||
end_container_children.append(self.status_container)
|
||||
if self.system_tray:
|
||||
end_container_children.append(self.system_tray)
|
||||
|
||||
if self.battery:
|
||||
end_container_children.append(self.battery)
|
||||
|
||||
if self.notmuch:
|
||||
end_container_children.append(self.notmuch)
|
||||
|
||||
if self.screenrec:
|
||||
end_container_children.append(self.screenrec)
|
||||
|
||||
end_container_children.append(self.date_time)
|
||||
|
||||
center_children = []
|
||||
if WINDOW_TITLE["enable"]:
|
||||
center_children.append(self.active_window)
|
||||
|
||||
self.inner = CenterBox(
|
||||
name="sims-inner",
|
||||
start_children=Box(
|
||||
name="start-container",
|
||||
spacing=6,
|
||||
orientation="h",
|
||||
children=[
|
||||
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
|
||||
self.workspaces,
|
||||
self.player_small,
|
||||
],
|
||||
),
|
||||
center_children=Box(
|
||||
name="center-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=center_children,
|
||||
),
|
||||
end_children=Box(
|
||||
name="end-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=end_container_children,
|
||||
),
|
||||
)
|
||||
self.children = self.inner
|
||||
|
||||
self.system_stats_service = SystemStatsService(update_interval=3000)
|
||||
self.system_stats_service.connect("stats-changed", self.update_progress_bars)
|
||||
|
||||
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()
|
||||
|
||||
@property
|
||||
def corners_rounded(self) -> bool:
|
||||
return self._corners_rounded
|
||||
|
||||
def set_corners_rounded(self, rounded: bool) -> None:
|
||||
if rounded == self._corners_rounded:
|
||||
return
|
||||
if rounded:
|
||||
self.inner.add_style_class("rounded-bottom")
|
||||
else:
|
||||
self.inner.remove_style_class("rounded-bottom")
|
||||
self._corners_rounded = rounded
|
||||
|
||||
def _on_smart_corners_changed(self, _service, output: str, active: bool):
|
||||
if output != self.output:
|
||||
return
|
||||
self.set_corners_rounded(not active)
|
||||
|
||||
def 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):
|
||||
self.cpu_progress_bar.value = cpu_percent
|
||||
self.ram_progress_bar.value = memory_percent
|
||||
|
||||
def _on_date_time_clicked(self, _button=None):
|
||||
if self.control_center is not None:
|
||||
self.control_center.toggle()
|
||||
@@ -1,53 +0,0 @@
|
||||
from gi.repository import GLib
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.image import Image
|
||||
from sims.services.battery import BatteryService
|
||||
|
||||
|
||||
class Battery(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="battery-widget", orientation="h", spacing=4, **kwargs)
|
||||
|
||||
self.bat_icon = Image(
|
||||
name="bat-icon", icon_name="battery-full-symbolic", icon_size=16
|
||||
)
|
||||
|
||||
self.bat_label = Label(name="bat-label", label="100%")
|
||||
|
||||
# Create battery service with signal-based updates
|
||||
self.battery_service = BatteryService(update_interval=10000) # Check every 10 seconds
|
||||
self.battery_service.connect("battery-changed", self.update_battery)
|
||||
|
||||
self.children = [self.bat_icon, self.bat_label]
|
||||
self.show_all()
|
||||
|
||||
# Initialize with current battery status
|
||||
initial_percent = self.battery_service.percent
|
||||
initial_charging = self.battery_service.charging
|
||||
GLib.idle_add(self.update_battery, None, initial_percent, initial_charging)
|
||||
|
||||
def _icon_lookup(self, bat, charging):
|
||||
# Round to nearest 10 for level-based icons
|
||||
level = max(10, min(100, round(bat / 10) * 10))
|
||||
|
||||
if charging:
|
||||
# Adwaita ships battery-level-100-charged-symbolic, not -charging.
|
||||
suffix = "charged" if level == 100 else "charging"
|
||||
return f"battery-level-{level}-{suffix}-symbolic"
|
||||
else:
|
||||
return f"battery-level-{level}-symbolic"
|
||||
|
||||
def update_battery(self, service, percent, charging):
|
||||
"""Update battery display when battery status changes"""
|
||||
icon_name = self._icon_lookup(percent, charging)
|
||||
self.bat_icon.set_property("icon-name", icon_name)
|
||||
|
||||
self.bat_label.set_text(f"{int(percent)}%")
|
||||
|
||||
if percent < 20 and not charging:
|
||||
self.bat_label.add_style_class("battery-low")
|
||||
self.bat_icon.add_style_class("battery-low")
|
||||
else:
|
||||
self.bat_label.remove_style_class("battery-low")
|
||||
self.bat_icon.remove_style_class("battery-low")
|
||||
@@ -1,200 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
from datetime import date
|
||||
|
||||
# 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', '')
|
||||
from loguru import logger
|
||||
from sims.config import CALENDAR
|
||||
|
||||
# Try to import khal as a Python library
|
||||
try:
|
||||
from khal.cli.main import main_khal
|
||||
from khal.settings import get_config
|
||||
from khal.khalendar import CalendarCollection
|
||||
KHAL_AVAILABLE = True
|
||||
logger.info("[Calendar] Using khal as Python library")
|
||||
except ImportError:
|
||||
KHAL_AVAILABLE = False
|
||||
logger.info("[Calendar] khal Python library not available, falling back to subprocess")
|
||||
|
||||
|
||||
class CalendarService:
|
||||
def __init__(self, update_interval=300000): # 5 minutes default
|
||||
self.events = []
|
||||
self.callbacks = []
|
||||
self._update_interval = update_interval
|
||||
self._timer_id = None
|
||||
|
||||
# Initial load
|
||||
self.update_events()
|
||||
# Start periodic updates
|
||||
self.start_monitoring()
|
||||
|
||||
def connect(self, signal_name, callback):
|
||||
"""Simple callback system to replace signals"""
|
||||
if signal_name == "events-changed":
|
||||
self.callbacks.append(callback)
|
||||
|
||||
def emit_events_changed(self, events):
|
||||
"""Emit events changed to all callbacks"""
|
||||
for callback in self.callbacks:
|
||||
callback(self, events)
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start periodic event updates"""
|
||||
if self._timer_id is None:
|
||||
from fabric.utils import invoke_repeater
|
||||
|
||||
self._timer_id = invoke_repeater(
|
||||
self._update_interval, self._periodic_update
|
||||
)
|
||||
logger.info(
|
||||
f"[Calendar] Started periodic updates every {self._update_interval/1000/60:.1f} minutes"
|
||||
)
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop periodic event updates"""
|
||||
if self._timer_id is not None:
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
logger.info("[Calendar] Stopped periodic updates")
|
||||
|
||||
def _periodic_update(self):
|
||||
"""Periodic update callback"""
|
||||
logger.info("[Calendar] Performing periodic events update")
|
||||
self.update_events()
|
||||
return True # Keep the timer running
|
||||
|
||||
def get_cached_events(self):
|
||||
"""Get cached events without triggering update"""
|
||||
return self.events
|
||||
|
||||
def update_events_python_api(self):
|
||||
"""Fetch today's events using khal Python API"""
|
||||
try:
|
||||
# Get khal configuration
|
||||
config = get_config()
|
||||
|
||||
# Create calendar collection
|
||||
collection = CalendarCollection.from_calendars(
|
||||
calendars=config['calendars'],
|
||||
dbpath=config['sqlite']['path'],
|
||||
locale=config['locale'],
|
||||
color=config['default']['print_new'],
|
||||
unicode_symbols=config['default']['unicode_symbols'],
|
||||
default_calendar=config['default']['default_calendar'],
|
||||
readonly=True
|
||||
)
|
||||
|
||||
# Get today's events
|
||||
today = date.today()
|
||||
events = collection.get_events_on(today)
|
||||
|
||||
# Format events to match our expected structure
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_event = {
|
||||
'title': str(event.summary),
|
||||
'start': event.start.strftime('%m-%d %H:%M') if hasattr(event.start, 'strftime') else '',
|
||||
'end': event.end.strftime('%m-%d %H:%M') if hasattr(event.end, 'strftime') else '',
|
||||
'location': str(event.location) if event.location else ''
|
||||
}
|
||||
formatted_events.append(formatted_event)
|
||||
|
||||
# Sort by start time
|
||||
formatted_events.sort(key=lambda e: e.get('start', ''))
|
||||
|
||||
self.events = formatted_events
|
||||
logger.info(f"[Calendar] Found {len(self.events)} events using Python API")
|
||||
self.emit_events_changed(self.events)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Calendar] Error using khal Python API: {e}")
|
||||
# Fall back to subprocess method
|
||||
self.update_events_subprocess()
|
||||
|
||||
def update_events_subprocess(self):
|
||||
"""Fetch today's events using khal subprocess (fallback)"""
|
||||
# Get khal path from config
|
||||
khal_path = CALENDAR.get("khal_path", "khal")
|
||||
|
||||
# Check if khal is available
|
||||
if not shutil.which(khal_path):
|
||||
logger.warning(f"[Calendar] khal not found at '{khal_path}'. Please install khal or configure the correct path.")
|
||||
self.events = []
|
||||
self.emit_events_changed(self.events)
|
||||
return
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
khal_path,
|
||||
"list",
|
||||
"--json",
|
||||
"title",
|
||||
"--json",
|
||||
"start",
|
||||
"--json",
|
||||
"end",
|
||||
"--json",
|
||||
"location",
|
||||
"today",
|
||||
]
|
||||
logger.info(f"[Calendar] Running command: {' '.join(cmd)}")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
logger.info(f"[Calendar] Command stdout: {result.stdout[:200]}...")
|
||||
logger.info(f"[Calendar] Command stderr: {result.stderr[:200]}...")
|
||||
|
||||
if result.stdout.strip():
|
||||
lines = result.stdout.strip().split("\n")
|
||||
all_events = []
|
||||
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
try:
|
||||
events = json.loads(line)
|
||||
all_events.extend(events)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
self.events = all_events
|
||||
logger.info(f"[Calendar] Found {len(self.events)} events using subprocess")
|
||||
self.emit_events_changed(self.events)
|
||||
else:
|
||||
self.events = []
|
||||
self.emit_events_changed(self.events)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"[Calendar] Failed to fetch events: {e}")
|
||||
self.events = []
|
||||
self.emit_events_changed(self.events)
|
||||
except Exception as e:
|
||||
logger.error(f"[Calendar] Error processing events: {e}")
|
||||
self.events = []
|
||||
self.emit_events_changed(self.events)
|
||||
|
||||
def update_events(self):
|
||||
"""Fetch today's events from khal"""
|
||||
# Check if calendar is enabled
|
||||
if not CALENDAR.get("enable", True):
|
||||
logger.info("[Calendar] Calendar is disabled in config")
|
||||
self.events = []
|
||||
self.emit_events_changed(self.events)
|
||||
return
|
||||
|
||||
# Try Python API first, fall back to subprocess
|
||||
if KHAL_AVAILABLE:
|
||||
logger.info("[Calendar] Using khal Python API")
|
||||
self.update_events_python_api()
|
||||
else:
|
||||
logger.info("[Calendar] Using khal subprocess")
|
||||
self.update_events_subprocess()
|
||||
@@ -1,352 +0,0 @@
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
from .base import FuzzyMenu, LauncherProvider, StaticAction, StaticActionProvider
|
||||
from .windows import WindowProvider
|
||||
|
||||
__all__ = [
|
||||
"FuzzyMenu",
|
||||
"LauncherProvider",
|
||||
"StaticAction",
|
||||
"StaticActionProvider",
|
||||
"WindowProvider",
|
||||
]
|
||||
@@ -1,87 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,205 +0,0 @@
|
||||
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()
|
||||
@@ -1,75 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,284 +0,0 @@
|
||||
"""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)
|
||||
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)
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
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")
|
||||
@@ -1,54 +0,0 @@
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -1,208 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
# 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', '')
|
||||
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 loguru import logger
|
||||
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:
|
||||
def __init__(self, update_interval=60000): # 1 minute default
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.callbacks = []
|
||||
self._update_interval = update_interval
|
||||
self._timer_id = None
|
||||
|
||||
# Initial load
|
||||
self.update_counts()
|
||||
# Start periodic updates
|
||||
self.start_monitoring()
|
||||
|
||||
def connect(self, signal_name, callback):
|
||||
"""Simple callback system to replace signals"""
|
||||
if signal_name == "counts-changed":
|
||||
self.callbacks.append(callback)
|
||||
|
||||
def emit_counts_changed(self):
|
||||
"""Emit counts changed to all callbacks"""
|
||||
for callback in self.callbacks:
|
||||
callback(self, self.unread_count, self.debt_count)
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start periodic unread count updates"""
|
||||
if self._timer_id is None:
|
||||
from fabric.utils import invoke_repeater
|
||||
|
||||
self._timer_id = invoke_repeater(
|
||||
self._update_interval, self._periodic_update
|
||||
)
|
||||
logger.info(
|
||||
f"[Notmuch] Started periodic updates every {self._update_interval/1000} seconds"
|
||||
)
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop periodic unread count updates"""
|
||||
if self._timer_id is not None:
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
logger.info("[Notmuch] Stopped periodic updates")
|
||||
|
||||
def _periodic_update(self):
|
||||
"""Periodic update callback"""
|
||||
logger.info("[Notmuch] Performing periodic count update")
|
||||
self.update_counts()
|
||||
return True # Keep the timer running
|
||||
|
||||
def get_cached_count(self):
|
||||
"""Get cached unread count without triggering update"""
|
||||
return self.unread_count
|
||||
|
||||
def get_cached_debt_count(self):
|
||||
"""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
|
||||
if not NOTMUCH.get("enable", True):
|
||||
logger.info("[Notmuch] Notmuch is disabled in config")
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
return
|
||||
|
||||
# Get notmuch path from config
|
||||
notmuch_path = NOTMUCH.get("notmuch_path", "notmuch")
|
||||
|
||||
# Check if notmuch is available
|
||||
if not shutil.which(notmuch_path):
|
||||
logger.warning(f"[Notmuch] notmuch not found at '{notmuch_path}'. Please install notmuch or configure the correct path.")
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
return
|
||||
|
||||
debt_query = NOTMUCH.get("debt_query", DEFAULT_DEBT_QUERY)
|
||||
|
||||
try:
|
||||
self.unread_count = self._run_count(notmuch_path, "tag:unread")
|
||||
self.debt_count = self._run_count(notmuch_path, debt_query)
|
||||
logger.info(
|
||||
f"[Notmuch] {self.unread_count} unread, {self.debt_count} aging (debt query: {debt_query!r})"
|
||||
)
|
||||
self.emit_counts_changed()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"[Notmuch] Failed to fetch counts: {e}")
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
except ValueError as e:
|
||||
logger.error(f"[Notmuch] Error parsing count: {e}")
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
except Exception as e:
|
||||
logger.error(f"[Notmuch] Error getting counts: {e}")
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
|
||||
|
||||
class NotmuchWidget(Button):
|
||||
def __init__(self, **kwargs):
|
||||
# Create the widget content
|
||||
self.icon = Image(icon_name="mail-unread-symbolic", icon_size=16)
|
||||
self.label = Label("0", name="unread-count")
|
||||
|
||||
# Container for icon and label
|
||||
container = Box(
|
||||
orientation="h",
|
||||
spacing=4,
|
||||
children=[self.icon, self.label]
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name="notmuch-widget",
|
||||
child=container,
|
||||
on_clicked=self.open_email_client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize the service
|
||||
self.service = NotmuchService()
|
||||
self.service.connect("counts-changed", self.update_display)
|
||||
|
||||
logger.info("[Notmuch] Notmuch widget initialized")
|
||||
|
||||
# Initial update
|
||||
self.update_display(
|
||||
self.service, self.service.unread_count, self.service.debt_count
|
||||
)
|
||||
|
||||
def open_email_client(self, button=None):
|
||||
"""Open notmuch in emacsclient"""
|
||||
emacsclient_command = NOTMUCH.get("emacsclient_command", "emacsclient")
|
||||
|
||||
try:
|
||||
# Open emacsclient with notmuch function
|
||||
cmd = [emacsclient_command, "-c", "-e", "(notmuch)"]
|
||||
logger.info(f"[Notmuch] Running emacsclient command: {' '.join(cmd)}")
|
||||
subprocess.Popen(cmd, start_new_session=True)
|
||||
logger.info(f"[Notmuch] Successfully started emacsclient process")
|
||||
except Exception as e:
|
||||
logger.error(f"[Notmuch] Failed to open notmuch in emacsclient '{emacsclient_command}': {e}")
|
||||
|
||||
def update_display(self, service, unread, debt):
|
||||
"""Update the widget display with unread + debt counts"""
|
||||
warn_at = NOTMUCH.get("debt_warn_at", DEFAULT_DEBT_WARN_AT)
|
||||
alarm_at = NOTMUCH.get("debt_alarm_at", DEFAULT_DEBT_ALARM_AT)
|
||||
|
||||
classes = ["notmuch-widget"]
|
||||
if unread > 0:
|
||||
self.label.set_text(str(unread))
|
||||
self.label.set_visible(True)
|
||||
self.icon.set_property("icon-name", "mail-unread-symbolic")
|
||||
classes.append("has-unread")
|
||||
else:
|
||||
self.label.set_text("")
|
||||
self.label.set_visible(False)
|
||||
self.icon.set_property("icon-name", "mail-read-symbolic")
|
||||
classes.append("no-unread")
|
||||
|
||||
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}"
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -1,475 +0,0 @@
|
||||
from sims.config import STYLIX
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
def generate_stylix_css():
|
||||
"""Generate CSS using Stylix colors if enabled"""
|
||||
if not STYLIX.get("enable", False):
|
||||
return None
|
||||
|
||||
colors = STYLIX.get("colors", {})
|
||||
fonts = STYLIX.get("fonts", {})
|
||||
|
||||
# Default colors if Stylix is not properly configured
|
||||
default_colors = {
|
||||
"base00": "1e1e2e", # background
|
||||
"base01": "313244", # lighter background
|
||||
"base02": "45475a", # selection background
|
||||
"base03": "585b70", # comments
|
||||
"base04": "bac2de", # dark foreground
|
||||
"base05": "cdd6f4", # foreground
|
||||
"base06": "f5e0dc", # light foreground
|
||||
"base07": "b4befe", # light background
|
||||
"base08": "f38ba8", # red
|
||||
"base09": "fab387", # orange
|
||||
"base0A": "f9e2af", # yellow
|
||||
"base0B": "a6e3a1", # green
|
||||
"base0C": "94e2d5", # cyan
|
||||
"base0D": "89b4fa", # blue
|
||||
"base0E": "cba6f7", # purple
|
||||
"base0F": "f2cdcd", # brown
|
||||
}
|
||||
|
||||
# Use Stylix colors or fallback to defaults
|
||||
for key in default_colors:
|
||||
if key not in colors:
|
||||
colors[key] = default_colors[key]
|
||||
|
||||
# Default font
|
||||
font_family = fonts.get("sansSerif", "sans-serif")
|
||||
font_sizes = fonts.get("sizes", {})
|
||||
# Use desktop font size for the bar, fallback to applications, then default
|
||||
font_size = font_sizes.get("desktop", font_sizes.get("applications", 14))
|
||||
|
||||
# Calculate relative font sizes
|
||||
small_font = max(int(font_size * 0.85), 10) # Minimum 10px
|
||||
large_font = int(font_size * 1.1)
|
||||
|
||||
# Debug logging
|
||||
from loguru import logger
|
||||
logger.info(f"[Stylix] Using font sizes - Base: {font_size}px, Small: {small_font}px, Large: {large_font}px")
|
||||
|
||||
# Generate GTK CSS with Stylix colors
|
||||
css_content = f"""/* Stylix-generated theme */
|
||||
|
||||
/* Apply Stylix font */
|
||||
* {{
|
||||
font-family: "{font_family}", sans-serif;
|
||||
font-size: {font_size}px;
|
||||
}}
|
||||
|
||||
/* Bar styling */
|
||||
#sims-inner {{
|
||||
padding: 4px;
|
||||
border-bottom: solid 2px;
|
||||
border-color: #{colors["base02"]};
|
||||
background-color: #{colors["base00"]};
|
||||
border-radius: 0;
|
||||
transition: border-radius 200ms ease;
|
||||
}}
|
||||
|
||||
#sims-inner.rounded-bottom {{
|
||||
border-radius: 0 0 28px 28px;
|
||||
}}
|
||||
|
||||
#center-container {{
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
.active-window {{
|
||||
color: #{colors["base05"]};
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
/* Battery */
|
||||
#battery-widget {{
|
||||
background-color: #{colors["base01"]};
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
|
||||
#bat-icon {{
|
||||
color: #{colors["base0D"]};
|
||||
margin-right: 2px;
|
||||
}}
|
||||
|
||||
#bat-label {{
|
||||
color: #{colors["base05"]};
|
||||
font-size: {font_size}px;
|
||||
}}
|
||||
|
||||
#bat-label.battery-low {{
|
||||
color: #{colors["base08"]};
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
/* Progress bars */
|
||||
#cpu-progress-bar,
|
||||
#ram-progress-bar,
|
||||
#volume-progress-bar {{
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
#cpu-progress-bar {{
|
||||
border: solid 0px alpha(#{colors["base0E"]}, 0.8);
|
||||
}}
|
||||
|
||||
#ram-progress-bar,
|
||||
#volume-progress-bar {{
|
||||
border: solid 0px #{colors["base0D"]};
|
||||
}}
|
||||
|
||||
/* Widgets container */
|
||||
#widgets-container {{
|
||||
background-color: #{colors["base01"]};
|
||||
padding: 2px;
|
||||
border-radius: 16px;
|
||||
}}
|
||||
|
||||
/* NixOS label */
|
||||
#nixos-label {{
|
||||
color: #{colors["base0D"]};
|
||||
}}
|
||||
|
||||
/* Date time */
|
||||
#date-time {{
|
||||
color: #{colors["base05"]};
|
||||
background-color: #{colors["base01"]};
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
|
||||
#date-time-button {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}}
|
||||
|
||||
/* Generic popup styling */
|
||||
.popup-window,
|
||||
#calendar-popup,
|
||||
#quick-menu {{
|
||||
background-color: #{colors["base00"]};
|
||||
border: solid 2px #{colors["base02"]};
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
animation: slide-down 200ms ease-out;
|
||||
/* Add subtle inner glow for better depth perception */
|
||||
outline: 1px solid rgba(255, 255, 255, 0.05);
|
||||
outline-offset: -1px;
|
||||
}}
|
||||
|
||||
@keyframes slide-down {{
|
||||
from {{
|
||||
opacity: 0;
|
||||
margin-top: -20px;
|
||||
}}
|
||||
to {{
|
||||
opacity: 1;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
}}
|
||||
|
||||
#calendar-title {{
|
||||
color: #{colors["base05"]};
|
||||
font-weight: bold;
|
||||
font-size: {large_font}px;
|
||||
margin-bottom: 8px;
|
||||
}}
|
||||
|
||||
#events-box {{
|
||||
background-color: #{colors["base00"]};
|
||||
border: solid 1px #{colors["base02"]};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}}
|
||||
|
||||
#no-events {{
|
||||
color: #{colors["base03"]};
|
||||
}}
|
||||
|
||||
/* Calendar event items */
|
||||
.event-item {{
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0px;
|
||||
transition: background-color 0.15s ease;
|
||||
}}
|
||||
|
||||
#event-content {{
|
||||
margin-left: 8px;
|
||||
}}
|
||||
|
||||
.event-item.upcoming {{
|
||||
background-color: #{colors["base01"]};
|
||||
}}
|
||||
|
||||
.event-item.past {{
|
||||
background-color: #{colors["base01"]};
|
||||
opacity: 0.6;
|
||||
}}
|
||||
|
||||
.event-title {{
|
||||
font-weight: bold;
|
||||
font-size: {font_size}px;
|
||||
}}
|
||||
|
||||
.event-title.upcoming {{
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
.event-title.past {{
|
||||
color: #{colors["base04"]};
|
||||
}}
|
||||
|
||||
.event-time {{
|
||||
font-size: {small_font}px;
|
||||
}}
|
||||
|
||||
.event-time.upcoming {{
|
||||
color: #{colors["base04"]};
|
||||
}}
|
||||
|
||||
.event-time.past {{
|
||||
color: #{colors["base03"]};
|
||||
}}
|
||||
|
||||
.event-location {{
|
||||
font-size: {small_font}px;
|
||||
}}
|
||||
|
||||
.event-location.upcoming {{
|
||||
color: #{colors["base03"]};
|
||||
}}
|
||||
|
||||
.event-location.past {{
|
||||
color: #{colors["base03"]};
|
||||
opacity: 0.8;
|
||||
}}
|
||||
|
||||
/* Tooltips */
|
||||
tooltip {{
|
||||
border: solid 2px;
|
||||
border-color: #{colors["base02"]};
|
||||
background-color: #{colors["base00"]};
|
||||
color: #{colors["base05"]};
|
||||
border-radius: 16px;
|
||||
}}
|
||||
|
||||
tooltip>* {{
|
||||
padding: 2px 4px;
|
||||
}}
|
||||
|
||||
/* Workspaces */
|
||||
#workspaces {{
|
||||
background-color: #{colors["base01"]};
|
||||
padding: 6px 6px;
|
||||
border-radius: 16px;
|
||||
}}
|
||||
|
||||
#workspaces>button {{
|
||||
background-color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
#workspaces>button.empty:not(.active):not(.visible) {{
|
||||
background-color: #{colors["base03"]};
|
||||
}}
|
||||
|
||||
#workspaces>button.visible:not(.active) {{
|
||||
background-color: #{colors["base0E"]};
|
||||
}}
|
||||
|
||||
#workspaces>button.active {{
|
||||
background-color: #{colors["base0D"]};
|
||||
}}
|
||||
|
||||
#workspaces>button.urgent {{
|
||||
background-color: #{colors["base08"]};
|
||||
}}
|
||||
|
||||
/* Workspace shown on a different monitor than this bar — dim it. */
|
||||
#workspaces>button.foreign {{
|
||||
opacity: 0.45;
|
||||
}}
|
||||
|
||||
#workspaces>button>label {{
|
||||
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-container {{
|
||||
background-color: #{colors["base00"]};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
#quick-menu-button {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
#quick-menu-button:hover {{
|
||||
background-color: #{colors["base01"]};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
.quick-menu-item {{
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.15s ease;
|
||||
}}
|
||||
|
||||
.quick-menu-item:hover {{
|
||||
background-color: #{colors["base01"]};
|
||||
}}
|
||||
|
||||
.section-title {{
|
||||
color: #{colors["base04"]};
|
||||
font-weight: bold;
|
||||
font-size: {small_font}px;
|
||||
}}
|
||||
|
||||
/* Vinyl button styling */
|
||||
#vinyl-button {{
|
||||
background-color: transparent;
|
||||
color: #{colors["base05"]};
|
||||
border: none;
|
||||
padding: 4px;
|
||||
margin: 0px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
#vinyl-button.active {{
|
||||
background-color: #{colors["base0B"]};
|
||||
color: #{colors["base00"]};
|
||||
}}
|
||||
|
||||
#vinyl-icon {{
|
||||
color: inherit;
|
||||
}}
|
||||
|
||||
/* Toggle switch styling for quick menu */
|
||||
.toggle-active {{
|
||||
background-color: #{colors["base0B"]};
|
||||
}}
|
||||
|
||||
.toggle-inactive {{
|
||||
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
|
||||
temp_fd, temp_path = tempfile.mkstemp(suffix=".css", prefix="stylix_")
|
||||
try:
|
||||
with os.fdopen(temp_fd, "w") as f:
|
||||
f.write(css_content)
|
||||
return temp_path
|
||||
except Exception:
|
||||
os.close(temp_fd)
|
||||
return None
|
||||
|
||||
|
||||
def get_stylix_css_path():
|
||||
"""Get the path to the Stylix CSS file"""
|
||||
return generate_stylix_css()
|
||||
@@ -1,85 +0,0 @@
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.core.service import Property
|
||||
import subprocess
|
||||
|
||||
|
||||
class VinylButton(Button):
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
@active.setter
|
||||
def active(self, value: bool):
|
||||
self._active = value
|
||||
# Update appearance based on state
|
||||
self._update_appearance()
|
||||
|
||||
# Execute shell command based on new state
|
||||
if self._active:
|
||||
self._execute_active_command()
|
||||
else:
|
||||
self._execute_inactive_command()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
active_command=[
|
||||
"pw-link alsa_input.pci-0000_12_00.6.analog-stereo:capture_FL alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX0",
|
||||
"pw-link alsa_input.pci-0000_12_00.6.analog-stereo:capture_FR alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX1",
|
||||
],
|
||||
inactive_command=[
|
||||
"pw-link -d alsa_input.pci-0000_12_00.6.analog-stereo:capture_FL alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX0",
|
||||
"pw-link -d alsa_input.pci-0000_12_00.6.analog-stereo:capture_FR alsa_output.usb-BEHRINGER_UMC1820_A71E9E3E-00.multichannel-output:playback_AUX1 ",
|
||||
],
|
||||
**kwargs,
|
||||
):
|
||||
# Initialize properties
|
||||
self._active = False
|
||||
self._active_command = active_command
|
||||
self._inactive_command = inactive_command
|
||||
|
||||
# Set up the icon using GTK icon
|
||||
self.icon = Image(
|
||||
icon_name="folder-music-symbolic",
|
||||
icon_size=16,
|
||||
name="vinyl-icon",
|
||||
)
|
||||
|
||||
# Initialize the Button with the icon as child
|
||||
super().__init__(
|
||||
name="vinyl-button",
|
||||
child=self.icon,
|
||||
on_clicked=self._on_clicked,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize appearance
|
||||
self._update_appearance()
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update CSS class based on active state"""
|
||||
if self._active:
|
||||
self.add_style_class("active")
|
||||
else:
|
||||
self.remove_style_class("active")
|
||||
|
||||
def _on_clicked(self, button=None):
|
||||
"""Handle button click event"""
|
||||
# Toggle active state
|
||||
self.active = not self.active
|
||||
|
||||
def _execute_active_command(self):
|
||||
"""Execute shell command when button is activated"""
|
||||
try:
|
||||
for cmd in self._active_command:
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
except Exception as e:
|
||||
print(f"Error executing active command: {e}")
|
||||
|
||||
def _execute_inactive_command(self):
|
||||
"""Execute shell command when button is deactivated"""
|
||||
try:
|
||||
for cmd in self._inactive_command:
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
except Exception as e:
|
||||
print(f"Error executing inactive command: {e}")
|
||||
@@ -1,9 +0,0 @@
|
||||
from sims.modules.launcher import FuzzyMenu, WindowProvider
|
||||
|
||||
|
||||
def FuzzyWindowFinder(monitor: int = 0) -> FuzzyMenu:
|
||||
return FuzzyMenu(
|
||||
provider=WindowProvider(),
|
||||
monitor=monitor,
|
||||
placeholder="Search Windows...",
|
||||
)
|
||||
@@ -1,72 +0,0 @@
|
||||
import psutil
|
||||
from fabric.core.service import Service, Signal
|
||||
from fabric.utils import invoke_repeater
|
||||
|
||||
|
||||
class BatteryService(Service):
|
||||
@Signal
|
||||
def battery_changed(self, percent: float, charging: bool) -> None:
|
||||
"""Signal emitted when battery status changes"""
|
||||
pass
|
||||
|
||||
def __init__(self, update_interval=10000, **kwargs): # Check every 10 seconds
|
||||
super().__init__(**kwargs)
|
||||
self._percent = 0.0
|
||||
self._charging = False
|
||||
self._update_interval = update_interval
|
||||
self._timer_id = None
|
||||
|
||||
# Start periodic updates
|
||||
self.start_monitoring()
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start monitoring battery status"""
|
||||
if self._timer_id is None:
|
||||
# Get initial values
|
||||
self._update_battery()
|
||||
# Set up periodic updates
|
||||
self._timer_id = invoke_repeater(self._update_interval, self._update_battery)
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop monitoring battery status"""
|
||||
if self._timer_id is not None:
|
||||
from gi.repository import GLib
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
|
||||
def _update_battery(self):
|
||||
"""Update battery status and emit signal if changed"""
|
||||
try:
|
||||
# Use the same pattern as the example
|
||||
bat_sen = psutil.sensors_battery()
|
||||
if not bat_sen:
|
||||
# No battery sensor available (desktop systems)
|
||||
new_percent = 100.0 # Assume plugged in
|
||||
new_charging = True
|
||||
else:
|
||||
new_percent = bat_sen.percent
|
||||
new_charging = bat_sen.power_plugged
|
||||
|
||||
# Only emit signal if values changed
|
||||
percent_changed = abs(new_percent - self._percent) > 0.5
|
||||
charging_changed = new_charging != self._charging
|
||||
|
||||
if percent_changed or charging_changed:
|
||||
self._percent = new_percent
|
||||
self._charging = new_charging
|
||||
self.battery_changed(new_percent, new_charging)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating battery status: {e}")
|
||||
|
||||
return True # Keep the timer running
|
||||
|
||||
@property
|
||||
def percent(self):
|
||||
"""Get current battery percentage"""
|
||||
return self._percent
|
||||
|
||||
@property
|
||||
def charging(self):
|
||||
"""Get current charging status"""
|
||||
return self._charging
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,245 +0,0 @@
|
||||
"""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()),
|
||||
)
|
||||
@@ -1,106 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,230 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""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
|
||||
@@ -1,66 +0,0 @@
|
||||
import psutil
|
||||
from fabric.core.service import Service, Signal
|
||||
from fabric.utils import invoke_repeater
|
||||
|
||||
|
||||
class SystemStatsService(Service):
|
||||
@Signal
|
||||
def stats_changed(self, cpu_percent: float, memory_percent: float) -> None:
|
||||
"""Signal emitted when system stats change"""
|
||||
pass
|
||||
|
||||
def __init__(self, update_interval=3000, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._cpu_percent = 0.0
|
||||
self._memory_percent = 0.0
|
||||
self._update_interval = update_interval
|
||||
self._timer_id = None
|
||||
|
||||
# Start periodic updates
|
||||
self.start_monitoring()
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start monitoring system stats"""
|
||||
if self._timer_id is None:
|
||||
# Get initial values
|
||||
self._update_stats()
|
||||
# Set up periodic updates
|
||||
self._timer_id = invoke_repeater(self._update_interval, self._update_stats)
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop monitoring system stats"""
|
||||
if self._timer_id is not None:
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
|
||||
def _update_stats(self):
|
||||
"""Update system stats and emit signal if changed"""
|
||||
try:
|
||||
new_cpu = psutil.cpu_percent()
|
||||
new_memory = psutil.virtual_memory().percent
|
||||
|
||||
# Only emit signal if values changed significantly (reduce noise)
|
||||
cpu_changed = abs(new_cpu - self._cpu_percent) > 1.0
|
||||
memory_changed = abs(new_memory - self._memory_percent) > 1.0
|
||||
|
||||
if cpu_changed or memory_changed:
|
||||
self._cpu_percent = new_cpu
|
||||
self._memory_percent = new_memory
|
||||
self.stats_changed(new_cpu / 100, new_memory / 100)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating system stats: {e}")
|
||||
|
||||
return True # Keep the timer running
|
||||
|
||||
@property
|
||||
def cpu_percent(self):
|
||||
"""Get current CPU percentage"""
|
||||
return self._cpu_percent / 100
|
||||
|
||||
@property
|
||||
def memory_percent(self):
|
||||
"""Get current memory percentage"""
|
||||
return self._memory_percent / 100
|
||||
@@ -1,130 +0,0 @@
|
||||
#sims-inner {
|
||||
padding: 4px;
|
||||
border-bottom: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
border-radius: 0;
|
||||
transition: border-radius 200ms ease;
|
||||
}
|
||||
|
||||
#sims-inner.rounded-bottom {
|
||||
border-radius: 0 0 28px 28px;
|
||||
}
|
||||
|
||||
#center-container {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.active-window {
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#battery-widget {
|
||||
background-color: var(--module-bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#bat-icon {
|
||||
color: var(--blue);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
#bat-label {
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#bat-label.battery-low {
|
||||
color: var(--red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#cpu-progress-bar,
|
||||
#ram-progress-bar,
|
||||
#volume-progress-bar {
|
||||
color: transparent;
|
||||
background-color: transparent
|
||||
}
|
||||
|
||||
#cpu-progress-bar {
|
||||
border: solid 0px alpha(var(--violet), 0.8);
|
||||
}
|
||||
|
||||
#ram-progress-bar,
|
||||
#volume-progress-bar {
|
||||
border: solid 0px var(--blue);
|
||||
}
|
||||
|
||||
#widgets-container {
|
||||
background-color: var(--module-bg);
|
||||
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 {
|
||||
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 {
|
||||
border: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
}
|
||||
|
||||
tooltip>* {
|
||||
padding: 2px 4px
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* Calendar widget styling */
|
||||
|
||||
/* Date time button */
|
||||
#date-time-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#date-time {
|
||||
color: var(--foreground);
|
||||
background-color: var(--module-bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Calendar event items */
|
||||
.event-item {
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin: 2px 0px;
|
||||
background-color: var(--light-bg);
|
||||
border: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
#event-content {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-weight: bold;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: var(--dark-fg);
|
||||
}
|
||||
|
||||
.event-location {
|
||||
color: var(--light-grey);
|
||||
}
|
||||
|
||||
/* Current time indicator */
|
||||
.current-time-indicator {
|
||||
margin: 8px 0px;
|
||||
padding: 4px 0px;
|
||||
}
|
||||
|
||||
#current-time-label {
|
||||
color: var(--blue);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#current-time-line {
|
||||
color: var(--blue);
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/* 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;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/* Notmuch email widget styling */
|
||||
|
||||
#notmuch-widget {
|
||||
background-color: var(--module-bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#notmuch-widget:hover {
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#notmuch-widget.has-unread {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
#notmuch-widget.has-unread:hover {
|
||||
background-color: var(--turquoise);
|
||||
}
|
||||
|
||||
#notmuch-widget.no-unread {
|
||||
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 {
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
#notmuch-widget.has-unread #unread-count,
|
||||
#notmuch-widget.debt-warn #unread-count,
|
||||
#notmuch-widget.debt-alarm #unread-count {
|
||||
color: var(--background);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/* Vinyl button styling */
|
||||
#vinyl-button {
|
||||
background-color: var(--module-bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#vinyl-button:hover {
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
/* Active state styling */
|
||||
#vinyl-button.active {
|
||||
background-color: var(--pink);
|
||||
}
|
||||
|
||||
#vinyl-button.active:hover {
|
||||
background-color: var(--turquoise);
|
||||
}
|
||||
|
||||
/* Icon styling */
|
||||
#vinyl-icon {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#vinyl-button.active #vinyl-icon {
|
||||
color: var(--background);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,240 +0,0 @@
|
||||
"""
|
||||
Fenster widgets for workspace and window management via sway IPC.
|
||||
"""
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from sims.services.i3 import I3, I3Event, I3MessageType
|
||||
from fabric.utils.helpers import bulk_connect
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from sims.services.fenster import get_i3_connection
|
||||
|
||||
|
||||
class FensterWorkspaceButton(Button):
|
||||
"""Button representing a single workspace"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_num: int,
|
||||
i3: I3 | None = None,
|
||||
label: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._workspace_num = workspace_num
|
||||
self._i3 = i3 or get_i3_connection()
|
||||
|
||||
display_label = label if label is not None else str(workspace_num)
|
||||
|
||||
super().__init__(
|
||||
name=f"workspace-button-{workspace_num}",
|
||||
child=Label(label=display_label),
|
||||
on_clicked=self._on_clicked,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.add_style_class("workspace-button")
|
||||
|
||||
@property
|
||||
def workspace_num(self) -> int:
|
||||
return self._workspace_num
|
||||
|
||||
def _on_clicked(self, *args):
|
||||
self._i3.send_command(f"workspace {self._workspace_num}")
|
||||
|
||||
def _toggle_class(self, name: str, on: bool):
|
||||
if on:
|
||||
self.add_style_class(name)
|
||||
else:
|
||||
self.remove_style_class(name)
|
||||
|
||||
def set_active(self, active: bool):
|
||||
self._toggle_class("active", active)
|
||||
|
||||
def set_visible_other(self, visible: bool):
|
||||
self._toggle_class("visible", visible)
|
||||
|
||||
def set_empty(self, empty: bool):
|
||||
self._toggle_class("empty", empty)
|
||||
|
||||
def set_urgent(self, urgent: bool):
|
||||
self._toggle_class("urgent", urgent)
|
||||
|
||||
def set_foreign(self, foreign: bool):
|
||||
self._toggle_class("foreign", foreign)
|
||||
|
||||
|
||||
class FensterWorkspaces(Box):
|
||||
"""Container widget showing a fixed set of workspace bubbles (1..N)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output: str | None = None,
|
||||
i3: I3 | None = None,
|
||||
buttons_factory=None,
|
||||
workspace_count: int = 9,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
name=kwargs.pop("name", "workspaces"),
|
||||
spacing=kwargs.pop("spacing", 4),
|
||||
orientation="h",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._output = output
|
||||
self._workspace_count = workspace_count
|
||||
self._i3 = i3 or get_i3_connection()
|
||||
self._buttons_factory = buttons_factory or self._default_button_factory
|
||||
self._buttons: dict[int, FensterWorkspaceButton] = {}
|
||||
self._refresh_pending = False
|
||||
|
||||
# Pre-create one button per workspace slot so position N always means workspace N.
|
||||
for n in range(1, workspace_count + 1):
|
||||
button = self._buttons_factory(n)
|
||||
self._buttons[n] = button
|
||||
self.add(button)
|
||||
|
||||
bulk_connect(
|
||||
self._i3,
|
||||
{
|
||||
"event::workspace::focus": self._on_event,
|
||||
"event::workspace::init": self._on_event,
|
||||
"event::workspace::empty": self._on_event,
|
||||
"event::workspace::urgent": self._on_event,
|
||||
"event::workspace::move": self._on_event,
|
||||
"event::window::focus": self._on_event,
|
||||
"event::window::new": self._on_event,
|
||||
"event::window::close": self._on_event,
|
||||
},
|
||||
)
|
||||
|
||||
if self._i3.ready:
|
||||
# Initial state is already current — no need to defer.
|
||||
self._refresh_workspaces()
|
||||
else:
|
||||
self._i3.connect("notify::ready", lambda *_: self._schedule_refresh())
|
||||
|
||||
def _default_button_factory(self, workspace_num: int) -> FensterWorkspaceButton:
|
||||
return FensterWorkspaceButton(workspace_num=workspace_num, i3=self._i3)
|
||||
|
||||
def _on_event(self, _, event: I3Event):
|
||||
self._schedule_refresh()
|
||||
|
||||
def _schedule_refresh(self):
|
||||
# Defer to the next idle tick — fenster's internal state is not always
|
||||
# updated synchronously when an event fires, so querying GET_WORKSPACES
|
||||
# immediately can return the pre-event view.
|
||||
if self._refresh_pending:
|
||||
return
|
||||
self._refresh_pending = True
|
||||
GLib.idle_add(self._refresh_idle)
|
||||
|
||||
def _refresh_idle(self):
|
||||
self._refresh_pending = False
|
||||
self._refresh_workspaces()
|
||||
return False
|
||||
|
||||
def _refresh_workspaces(self):
|
||||
reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
|
||||
if reply.is_ok and isinstance(reply.reply, list):
|
||||
self._update_workspaces(reply.reply)
|
||||
|
||||
def _update_workspaces(self, workspaces: list):
|
||||
ws_by_num = {
|
||||
ws["num"]: ws for ws in workspaces if ws.get("num") is not None
|
||||
}
|
||||
|
||||
for n, button in self._buttons.items():
|
||||
ws = ws_by_num.get(n)
|
||||
if ws is None:
|
||||
button.set_active(False)
|
||||
button.set_visible_other(False)
|
||||
button.set_urgent(False)
|
||||
button.set_empty(True)
|
||||
button.set_foreign(False)
|
||||
continue
|
||||
|
||||
focused = bool(ws.get("focused"))
|
||||
visible = bool(ws.get("visible"))
|
||||
urgent = bool(ws.get("urgent"))
|
||||
ws_output = ws.get("output")
|
||||
window_count = ws.get("window_count", 0)
|
||||
|
||||
button.set_active(focused)
|
||||
# Visible on its output but not the focused one → shown on another monitor.
|
||||
button.set_visible_other(visible and not focused)
|
||||
button.set_urgent(urgent)
|
||||
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()
|
||||
|
||||
|
||||
class FensterActiveWindow(Label):
|
||||
"""Label showing the title of the focused window"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
i3: I3 | None = None,
|
||||
max_length: int = 50,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
name=kwargs.pop("name", "active-window"),
|
||||
label="",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._i3 = i3 or get_i3_connection()
|
||||
self._max_length = max_length
|
||||
|
||||
bulk_connect(
|
||||
self._i3,
|
||||
{
|
||||
"event::window::focus": self._on_window_event,
|
||||
"event::window::title": self._on_window_event,
|
||||
"event::window::close": self._on_window_close,
|
||||
},
|
||||
)
|
||||
|
||||
if self._i3.ready:
|
||||
self._initialize()
|
||||
else:
|
||||
self._i3.connect("notify::ready", lambda *_: self._initialize())
|
||||
|
||||
def _initialize(self):
|
||||
tree_reply = I3.send_command("", I3MessageType.GET_TREE)
|
||||
if tree_reply.is_ok and isinstance(tree_reply.reply, dict):
|
||||
focused = self._find_focused(tree_reply.reply)
|
||||
if focused:
|
||||
self._set_title(focused.get("name") or "")
|
||||
return
|
||||
self.set_label("")
|
||||
|
||||
def _find_focused(self, node: dict) -> dict | None:
|
||||
if node.get("focused") and node.get("type") == "con":
|
||||
return node
|
||||
for child in node.get("nodes", []) + node.get("floating_nodes", []):
|
||||
result = self._find_focused(child)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
def _on_window_event(self, _, event: I3Event):
|
||||
container = event.data.get("container", {})
|
||||
self._set_title(container.get("name") or "")
|
||||
|
||||
def _on_window_close(self, _, event: I3Event):
|
||||
self._initialize()
|
||||
|
||||
def _set_title(self, title: str):
|
||||
if len(title) > self._max_length:
|
||||
title = title[: self._max_length - 3] + "..."
|
||||
self.set_label(title)
|
||||
@@ -1,136 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user