Compare commits
23 Commits
03db9c4e4a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df2bef7685 | |||
| 5d08a48b6c | |||
| 82b0cf7aaa | |||
| e4744bab81 | |||
| 872dbfc792 | |||
| 64781af68f | |||
| 0ebfbdb3a9 | |||
| bf3920ad35 | |||
| 72c76c9fda | |||
| 743e1ed0c5 | |||
| f28dd0b6a2 | |||
| 0b8190ae8b | |||
| 9495dfba62 | |||
| 0cf1c5aeb7 | |||
| f8b352d624 | |||
| 53713ee0f5 | |||
| 736e1a47c9 | |||
| 0966c1ce70 | |||
| 2541edd0f4 | |||
| ce030a8734 | |||
| 133dc74fb9 | |||
| 0cec84bec5 | |||
| 2f6fc3b59c |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
.direnv
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Todo
|
||||
- https://github.com/jlumpe/pyorg
|
||||
- https://github.com/jlumpe/ox-json
|
||||
170
bar/bar.css
170
bar/bar.css
@@ -1,170 +0,0 @@
|
||||
/* Fabric bar.css
|
||||
* https://github.com/Fabric-Development/fabric/blob/rewrite/examples/bar/bar.css
|
||||
* Modified with Camellia Theme: https://github.com/camellia-theme/camellia
|
||||
*/
|
||||
/* we can use webcss variables, fabric compiles that to gtk css.
|
||||
global variables can be stored in :vars */
|
||||
:vars {
|
||||
/* Base colors from Camellia theme */
|
||||
--background: #17181C; /* BG */
|
||||
--mid-bg: #1E1F24; /* Mid BG */
|
||||
--light-bg: #26272B; /* Light BG */
|
||||
--dark-grey: #333438; /* Dark Grey */
|
||||
--light-grey: #8F9093; /* Light Grey */
|
||||
--dark-fg: #B0B1B4; /* Dark FG */
|
||||
--mid-fg: #CBCCCE; /* Mid FG */
|
||||
--foreground: #E4E5E7; /* FG */
|
||||
|
||||
/* Accent colors from Camellia theme */
|
||||
--pink: #FA3867; /* Pink */
|
||||
--orange: #F3872F; /* Orange */
|
||||
--gold: #FEBD16; /* Gold */
|
||||
--lime: #3FD43B; /* Lime */
|
||||
--turquoise: #47E7CE; /* Turquoise */
|
||||
--blue: #53ADE1; /* Blue */
|
||||
--violet: #AD60FF; /* Violet */
|
||||
--red: #FC3F2C; /* Red */
|
||||
|
||||
/* Functional variables */
|
||||
--window-bg: alpha(var(--background), 0.9);
|
||||
--module-bg: alpha(var(--mid-bg), 0.8);
|
||||
--border-color: var(--mid-fg);
|
||||
--ws-active: var(--pink);
|
||||
--ws-inactive: var(--blue);
|
||||
--ws-empty: var(--dark-grey);
|
||||
--ws-hover: var(--turquoise);
|
||||
--ws-urgent: var(--red);
|
||||
}
|
||||
|
||||
/* unset so we can style everything from the ground up. */
|
||||
* {
|
||||
all: unset;
|
||||
color: var(--foreground);
|
||||
font-size: 16px;
|
||||
font-family: "Jost*", sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
#bar-inner {
|
||||
padding: 4px;
|
||||
border-bottom: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
#workspaces {
|
||||
padding: 6px;
|
||||
min-width: 0px;
|
||||
background-color: var(--module-bg);
|
||||
}
|
||||
|
||||
#workspaces>button {
|
||||
padding: 0px 8px;
|
||||
transition: padding 0.05s steps(8);
|
||||
background-color: var(--ws-inactive);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
#workspaces>button>label {
|
||||
font-size: 0px;
|
||||
}
|
||||
|
||||
#workspaces>button:hover {
|
||||
background-color: var(--ws-hover);
|
||||
}
|
||||
|
||||
#workspaces>button.urgent {
|
||||
background-color: var(--ws-urgent);
|
||||
}
|
||||
|
||||
#workspaces>button.empty {
|
||||
background-color: var(--ws-empty);
|
||||
}
|
||||
|
||||
#workspaces>button.active {
|
||||
padding: 0px 32px;
|
||||
background-color: var(--ws-active);
|
||||
}
|
||||
|
||||
#center-container {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.active-window {
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#date-time,
|
||||
menu>menuitem>label,
|
||||
#date-time>label,
|
||||
/* system tray */
|
||||
#system-tray {
|
||||
padding: 2px 4px;
|
||||
background-color: var(--module-bg);
|
||||
}
|
||||
|
||||
/* menu and menu items (written for the system tray) */
|
||||
menu {
|
||||
border: solid 2px;
|
||||
border-radius: 10px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
}
|
||||
|
||||
menu>menuitem {
|
||||
border-radius: 0px;
|
||||
background-color: var(--module-bg);
|
||||
padding: 6px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
menu>menuitem:first-child {
|
||||
margin-top: 1px;
|
||||
border-radius: 8px 8px 0px 0px;
|
||||
}
|
||||
|
||||
menu>menuitem:last-child {
|
||||
margin-bottom: 1px;
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
}
|
||||
|
||||
menu>menuitem:hover {
|
||||
background-color: var(--pink);
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
tooltip {
|
||||
border: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
}
|
||||
|
||||
tooltip>* {
|
||||
padding: 2px 4px
|
||||
}
|
||||
52
bar/config.py
Normal file
52
bar/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import yaml
|
||||
import os
|
||||
from platformdirs import user_config_dir
|
||||
import argparse
|
||||
|
||||
|
||||
APP_NAME = "makku_bar"
|
||||
|
||||
XDG_CONFIG_HOME = user_config_dir(appname=APP_NAME)
|
||||
XDG_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, "config.yaml")
|
||||
|
||||
|
||||
def load_config(config_path=XDG_CONFIG_FILE):
|
||||
"""Loads configuration from a YAML file."""
|
||||
if config_path is None:
|
||||
print("No configuration file path provided or found.")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Configuration file not found at {config_path}")
|
||||
return None
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing YAML file '{config_path}': {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred loading config file '{config_path}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_args():
|
||||
parser = argparse.ArgumentParser(description="makku_bar")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
help="Path to a custom configuration file.",
|
||||
type=str,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args.config
|
||||
|
||||
|
||||
app_config = load_config() if not load_args() else load_config(load_args())
|
||||
|
||||
if app_config is None:
|
||||
raise Exception("Config file missing")
|
||||
|
||||
VINYL = app_config.get("vinyl", {"enable": False})
|
||||
55
bar/main.py
Normal file
55
bar/main.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
|
||||
|
||||
tray = SystemTray(name="system-tray", spacing=4)
|
||||
river = get_river_connection()
|
||||
|
||||
dummy = Window(visible=False)
|
||||
finder = FuzzyWindowFinder()
|
||||
|
||||
bar_windows = []
|
||||
|
||||
app = Application("bar", dummy, finder)
|
||||
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()
|
||||
@@ -1,79 +1,34 @@
|
||||
# fabric bar.py example
|
||||
# https://github.com/Fabric-Development/fabric/blob/rewrite/examples/bar/bar.py
|
||||
import psutil
|
||||
from fabric import Application
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from bar.modules.player import Player
|
||||
from bar.modules.vinyl import VinylButton
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from .river.widgets import RiverWorkspaces, RiverWorkspaceButton, RiverActiveWindow
|
||||
from fabric.utils import (
|
||||
FormattedString,
|
||||
bulk_replace,
|
||||
invoke_repeater,
|
||||
get_relative_path,
|
||||
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
|
||||
|
||||
AUDIO_WIDGET = True
|
||||
|
||||
if AUDIO_WIDGET is True:
|
||||
try:
|
||||
from fabric.audio.service import Audio
|
||||
except Exception as e:
|
||||
print(e)
|
||||
AUDIO_WIDGET = False
|
||||
|
||||
|
||||
class VolumeWidget(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.audio = Audio()
|
||||
|
||||
self.progress_bar = CircularProgressBar(
|
||||
name="volume-progress-bar", pie=True, size=24
|
||||
)
|
||||
|
||||
self.event_box = EventBox(
|
||||
events="scroll",
|
||||
child=Overlay(
|
||||
child=self.progress_bar,
|
||||
overlays=Label(
|
||||
label="",
|
||||
style="margin: 0px 6px 0px 0px; font-size: 12px", # to center the icon glyph
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.audio.connect("notify::speaker", self.on_speaker_changed)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
match event.direction:
|
||||
case 0:
|
||||
self.audio.speaker.volume += 8
|
||||
case 1:
|
||||
self.audio.speaker.volume -= 8
|
||||
return
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
self.progress_bar.value = self.audio.speaker.volume / 100
|
||||
self.audio.speaker.bind(
|
||||
"volume", "value", self.progress_bar, lambda _, v: v / 100
|
||||
)
|
||||
return
|
||||
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",
|
||||
@@ -83,15 +38,23 @@ class StatusBar(Window):
|
||||
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(
|
||||
44,
|
||||
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")
|
||||
self.system_tray = SystemTray(name="system-tray", spacing=4)
|
||||
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,
|
||||
@@ -104,13 +67,18 @@ class StatusBar(Window):
|
||||
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,
|
||||
Label("", style="margin: 0px 6px 0px 0px; font-size: 12px"),
|
||||
],
|
||||
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",
|
||||
@@ -118,15 +86,28 @@ class StatusBar(Window):
|
||||
orientation="h",
|
||||
children=self.progress_bars_overlay,
|
||||
)
|
||||
self.status_container.add(VolumeWidget()) if AUDIO_WIDGET is True else None
|
||||
|
||||
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=4,
|
||||
spacing=6,
|
||||
orientation="h",
|
||||
children=self.workspaces,
|
||||
children=[
|
||||
Label(name="nixos-label", markup=""),
|
||||
self.workspaces,
|
||||
],
|
||||
),
|
||||
center_children=Box(
|
||||
name="center-container",
|
||||
@@ -138,11 +119,7 @@ class StatusBar(Window):
|
||||
name="end-container",
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=[
|
||||
self.status_container,
|
||||
self.system_tray,
|
||||
self.date_time,
|
||||
],
|
||||
children=end_container_children,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -154,15 +131,3 @@ class StatusBar(Window):
|
||||
self.ram_progress_bar.value = psutil.virtual_memory().percent / 100
|
||||
self.cpu_progress_bar.value = psutil.cpu_percent() / 100
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
bar = StatusBar()
|
||||
app = Application("bar", bar)
|
||||
app.set_stylesheet_from_file(get_relative_path("bar.css"))
|
||||
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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
|
||||
176
bar/modules/icons.py
Normal file
176
bar/modules/icons.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# Parameters
|
||||
font_family: str = "tabler-icons"
|
||||
font_weight: str = "normal"
|
||||
|
||||
span: str = f"<span font-family='{font_family}' font-weight='{font_weight}'>"
|
||||
|
||||
# Panels
|
||||
apps: str = ""
|
||||
dashboard: str = ""
|
||||
chat: str = ""
|
||||
wallpapers: str = ""
|
||||
windows: str = ""
|
||||
|
||||
# Bar
|
||||
colorpicker: str = ""
|
||||
media: str = ""
|
||||
|
||||
# Toolbox
|
||||
|
||||
toolbox: str = ""
|
||||
ssfull: str = ""
|
||||
ssregion: str = ""
|
||||
sswindow: str = ""
|
||||
screenshots: str = ""
|
||||
screenrecord: str = ""
|
||||
recordings: str = ""
|
||||
ocr: str = "ﳃ"
|
||||
gamemode: str = ""
|
||||
gamemode_off: str = ""
|
||||
close: str = ""
|
||||
|
||||
# Circles
|
||||
temp: str = ""
|
||||
disk: str = ""
|
||||
battery: str = ""
|
||||
memory: str = "流"
|
||||
cpu: str = ""
|
||||
gpu: str = ""
|
||||
|
||||
# AIchat
|
||||
reload: str = ""
|
||||
detach: str = ""
|
||||
|
||||
# Wallpapers
|
||||
add: str = ""
|
||||
sort: str = ""
|
||||
circle: str = ""
|
||||
|
||||
# Chevrons
|
||||
chevron_up: str = ""
|
||||
chevron_down: str = ""
|
||||
chevron_left: str = ""
|
||||
chevron_right: str = ""
|
||||
|
||||
# Power
|
||||
lock: str = ""
|
||||
suspend: str = ""
|
||||
logout: str = ""
|
||||
reboot: str = ""
|
||||
shutdown: str = ""
|
||||
|
||||
# Power Manager
|
||||
power_saving: str = ""
|
||||
power_balanced: str = "勺"
|
||||
power_performance: str = ""
|
||||
charging: str = ""
|
||||
discharging: str = ""
|
||||
alert: str = ""
|
||||
bat_charging: str = ""
|
||||
bat_discharging: str = ""
|
||||
bat_low: str = "="
|
||||
bat_full: str = ""
|
||||
|
||||
|
||||
# Applets
|
||||
wifi_0: str = ""
|
||||
wifi_1: str = ""
|
||||
wifi_2: str = ""
|
||||
wifi_3: str = ""
|
||||
world: str = ""
|
||||
world_off: str = ""
|
||||
bluetooth: str = ""
|
||||
night: str = ""
|
||||
coffee: str = ""
|
||||
notifications: str = ""
|
||||
|
||||
wifi_off: str = ""
|
||||
bluetooth_off: str = ""
|
||||
night_off: str = ""
|
||||
notifications_off: str = ""
|
||||
|
||||
notifications_clear: str = ""
|
||||
download: str = ""
|
||||
upload: str = ""
|
||||
|
||||
# Bluetooth
|
||||
bluetooth_connected: str = ""
|
||||
bluetooth_disconnected: str = ""
|
||||
|
||||
# Player
|
||||
pause: str = ""
|
||||
play: str = ""
|
||||
stop: str = ""
|
||||
skip_back: str = ""
|
||||
skip_forward: str = ""
|
||||
prev: str = ""
|
||||
next: str = ""
|
||||
shuffle: str = ""
|
||||
repeat: str = ""
|
||||
music: str = ""
|
||||
rewind_backward_5: str = "謹"
|
||||
rewind_forward_5: str = "難"
|
||||
|
||||
# Volume
|
||||
vol_off: str = ""
|
||||
vol_mute: str = ""
|
||||
vol_medium: str = ""
|
||||
vol_high: str = ""
|
||||
|
||||
mic: str = ""
|
||||
mic_mute: str = ""
|
||||
|
||||
# Overview
|
||||
circle_plus: str = ""
|
||||
|
||||
# Pins
|
||||
paperclip: str = ""
|
||||
|
||||
# Clipboard Manager
|
||||
clipboard: str = ""
|
||||
clip_text: str = ""
|
||||
|
||||
# Confirm
|
||||
accept: str = ""
|
||||
cancel: str = ""
|
||||
trash: str = ""
|
||||
|
||||
# Config
|
||||
config: str = ""
|
||||
|
||||
# Icons
|
||||
firefox: str = ""
|
||||
chromium: str = ""
|
||||
spotify: str = "ﺆ"
|
||||
disc: str = "𐀾"
|
||||
disc_off: str = ""
|
||||
|
||||
# Brightness
|
||||
brightness_low: str = ""
|
||||
brightness_medium: str = ""
|
||||
brightness_high: str = ""
|
||||
|
||||
# Misc
|
||||
dot: str = ""
|
||||
palette: str = ""
|
||||
cloud_off: str = ""
|
||||
loader: str = ""
|
||||
radar: str = ""
|
||||
emoji: str = ""
|
||||
keyboard: str = ""
|
||||
terminal: str = ""
|
||||
timer_off: str = ""
|
||||
timer_on: str = ""
|
||||
spy: str = ""
|
||||
|
||||
exceptions: list[str] = ["font_family", "font_weight", "span"]
|
||||
|
||||
|
||||
def apply_span() -> None:
|
||||
global_dict = globals()
|
||||
for key in global_dict:
|
||||
if key not in exceptions and not key.startswith("__"):
|
||||
global_dict[key] = f"{span}{global_dict[key]}</span>"
|
||||
|
||||
|
||||
apply_span()
|
||||
738
bar/modules/player.py
Normal file
738
bar/modules/player.py
Normal file
@@ -0,0 +1,738 @@
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import tempfile
|
||||
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.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.stack import Stack
|
||||
from ..widgets.circle_image import CircleImage
|
||||
import bar.modules.icons as icons
|
||||
from bar.services.mpris import MprisPlayerManager, MprisPlayer
|
||||
|
||||
# from bar.modules.cavalcade import SpectrumRender
|
||||
|
||||
|
||||
def get_player_icon_markup_by_name(player_name):
|
||||
if player_name:
|
||||
pn = player_name.lower()
|
||||
if pn == "firefox":
|
||||
return icons.firefox
|
||||
elif pn == "spotify":
|
||||
return icons.spotify
|
||||
elif pn in ("chromium", "brave"):
|
||||
return icons.chromium
|
||||
return icons.disc
|
||||
|
||||
|
||||
def add_hover_cursor(widget):
|
||||
widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
|
||||
widget.connect(
|
||||
"enter-notify-event",
|
||||
lambda w, event: w.get_window().set_cursor(
|
||||
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "pointer")
|
||||
),
|
||||
)
|
||||
widget.connect(
|
||||
"leave-notify-event", lambda w, event: w.get_window().set_cursor(None)
|
||||
)
|
||||
|
||||
|
||||
class PlayerBox(Box):
|
||||
def __init__(self, mpris_player=None):
|
||||
super().__init__(
|
||||
orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=True
|
||||
)
|
||||
self.mpris_player = mpris_player
|
||||
self._progress_timer_id = None # Initialize timer ID
|
||||
|
||||
self.cover = CircleImage(
|
||||
name="player-cover",
|
||||
image_file=os.path.expanduser("~/Pictures/wallpaper/background.jpg"),
|
||||
size=162,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.cover_placerholder = CircleImage(
|
||||
name="player-cover",
|
||||
size=198,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.title = Label(
|
||||
name="player-title",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
ellipsization="end",
|
||||
max_chars_width=1,
|
||||
)
|
||||
self.album = Label(
|
||||
name="player-album",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
ellipsization="end",
|
||||
max_chars_width=1,
|
||||
)
|
||||
self.artist = Label(
|
||||
name="player-artist",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
ellipsization="end",
|
||||
max_chars_width=1,
|
||||
)
|
||||
self.progressbar = CircularProgressBar(
|
||||
name="player-progress",
|
||||
size=198,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
start_angle=180,
|
||||
end_angle=360,
|
||||
)
|
||||
self.time = Label(name="player-time", label="--:-- / --:--")
|
||||
self.overlay = Overlay(
|
||||
child=self.cover_placerholder,
|
||||
overlays=[self.progressbar, self.cover],
|
||||
)
|
||||
self.overlay_container = CenterBox(
|
||||
name="player-overlay", center_children=[self.overlay]
|
||||
)
|
||||
self.title.set_label("Nothing Playing")
|
||||
self.album.set_label("Enjoy the silence")
|
||||
self.artist.set_label("¯\\_(ツ)_/¯")
|
||||
self.progressbar.set_value(0.0)
|
||||
self.prev = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.prev),
|
||||
)
|
||||
self.backward = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.skip_back),
|
||||
)
|
||||
self.play_pause = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.play),
|
||||
)
|
||||
self.forward = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.skip_forward),
|
||||
)
|
||||
self.next = Button(
|
||||
name="player-btn",
|
||||
child=Label(name="player-btn-label", markup=icons.next),
|
||||
)
|
||||
# Add hover effect to buttons
|
||||
add_hover_cursor(self.prev)
|
||||
add_hover_cursor(self.backward)
|
||||
add_hover_cursor(self.play_pause)
|
||||
add_hover_cursor(self.forward)
|
||||
add_hover_cursor(self.next)
|
||||
self.btn_box = CenterBox(
|
||||
name="player-btn-box",
|
||||
orientation="h",
|
||||
center_children=[
|
||||
Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
children=[
|
||||
self.prev,
|
||||
self.backward,
|
||||
self.play_pause,
|
||||
self.forward,
|
||||
self.next,
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
self.player_box = Box(
|
||||
name="player-box",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
children=[
|
||||
self.overlay_container,
|
||||
self.title,
|
||||
self.album,
|
||||
self.artist,
|
||||
self.btn_box,
|
||||
self.time,
|
||||
],
|
||||
)
|
||||
self.add(self.player_box)
|
||||
if mpris_player:
|
||||
self._apply_mpris_properties() # This will handle starting the timer if needed
|
||||
self.prev.connect("clicked", self._on_prev_clicked)
|
||||
self.play_pause.connect("clicked", self._on_play_pause_clicked)
|
||||
self.backward.connect("clicked", self._on_backward_clicked)
|
||||
self.forward.connect("clicked", self._on_forward_clicked)
|
||||
self.next.connect("clicked", self._on_next_clicked)
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self.play_pause.get_child().set_markup(icons.stop)
|
||||
# Ensure buttons are disabled visually if no player
|
||||
self.backward.add_style_class("disabled")
|
||||
self.forward.add_style_class("disabled")
|
||||
self.prev.add_style_class("disabled")
|
||||
self.next.add_style_class("disabled")
|
||||
self.progressbar.set_value(0.0)
|
||||
self.time.set_text("--:-- / --:--")
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
mp = self.mpris_player
|
||||
self.title.set_visible(bool(mp.title and mp.title.strip()))
|
||||
if mp.title and mp.title.strip():
|
||||
self.title.set_text(mp.title)
|
||||
self.album.set_visible(bool(mp.album and mp.album.strip()))
|
||||
if mp.album and mp.album.strip():
|
||||
self.album.set_text(mp.album)
|
||||
self.artist.set_visible(bool(mp.artist and mp.artist.strip()))
|
||||
if mp.artist and mp.artist.strip():
|
||||
self.artist.set_text(mp.artist)
|
||||
if mp.arturl:
|
||||
parsed = urllib.parse.urlparse(mp.arturl)
|
||||
if parsed.scheme == "file":
|
||||
local_arturl = urllib.parse.unquote(parsed.path)
|
||||
self._set_cover_image(local_arturl)
|
||||
elif parsed.scheme in ("http", "https"):
|
||||
GLib.Thread.new(
|
||||
"download-artwork", self._download_and_set_artwork, mp.arturl
|
||||
)
|
||||
else:
|
||||
self._set_cover_image(mp.arturl)
|
||||
else:
|
||||
fallback = os.path.expanduser("~/Pictures/wallpaper/background.jpg")
|
||||
self._set_cover_image(fallback)
|
||||
file_obj = Gio.File.new_for_path(fallback)
|
||||
monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None)
|
||||
monitor.connect("changed", self.on_wallpaper_changed)
|
||||
self._wallpaper_monitor = monitor
|
||||
self.update_play_pause_icon()
|
||||
# Keep progress bar and time visible always
|
||||
self.progressbar.set_visible(True)
|
||||
self.time.set_visible(True)
|
||||
|
||||
player_name = (
|
||||
mp.player_name.lower()
|
||||
if hasattr(mp, "player_name") and mp.player_name
|
||||
else ""
|
||||
)
|
||||
can_seek = hasattr(mp, "can_seek") and mp.can_seek
|
||||
|
||||
if player_name == "firefox" or not can_seek:
|
||||
# Disable seeking buttons and reset progress/time display
|
||||
self.backward.add_style_class("disabled")
|
||||
self.forward.add_style_class("disabled")
|
||||
self.progressbar.set_value(0.0)
|
||||
self.time.set_text("--:-- / --:--")
|
||||
# Stop the timer if it's running
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
else:
|
||||
# Enable seeking buttons
|
||||
self.backward.remove_style_class("disabled")
|
||||
self.forward.remove_style_class("disabled")
|
||||
# Start the timer if it's not already running
|
||||
if not self._progress_timer_id:
|
||||
self._progress_timer_id = GLib.timeout_add(1000, self._update_progress)
|
||||
# Initial progress update if possible
|
||||
self._update_progress() # Call once for immediate update
|
||||
|
||||
# Enable/disable prev/next based on capabilities
|
||||
if hasattr(mp, "can_go_previous") and mp.can_go_previous:
|
||||
self.prev.remove_style_class("disabled")
|
||||
else:
|
||||
self.prev.add_style_class("disabled")
|
||||
|
||||
if hasattr(mp, "can_go_next") and mp.can_go_next:
|
||||
self.next.remove_style_class("disabled")
|
||||
else:
|
||||
self.next.add_style_class("disabled")
|
||||
|
||||
def _set_cover_image(self, image_path):
|
||||
if image_path and os.path.isfile(image_path):
|
||||
self.cover.set_image_from_file(image_path)
|
||||
else:
|
||||
fallback = os.path.expanduser("~/Pictures/wallpaper/background.jpg")
|
||||
self.cover.set_image_from_file(fallback)
|
||||
file_obj = Gio.File.new_for_path(fallback)
|
||||
monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None)
|
||||
monitor.connect("changed", self.on_wallpaper_changed)
|
||||
self._wallpaper_monitor = monitor
|
||||
|
||||
def _download_and_set_artwork(self, arturl):
|
||||
"""
|
||||
Download the artwork from the given URL asynchronously and update the cover image
|
||||
using GLib.idle_add to ensure UI updates occur on the main thread.
|
||||
"""
|
||||
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()
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
temp_file.write(data)
|
||||
temp_file.close()
|
||||
local_arturl = temp_file.name
|
||||
except Exception:
|
||||
local_arturl = os.path.expanduser("~/.current.wall")
|
||||
GLib.idle_add(self._set_cover_image, local_arturl)
|
||||
return None
|
||||
|
||||
def update_play_pause_icon(self):
|
||||
if self.mpris_player.playback_status == "playing":
|
||||
self.play_pause.get_child().set_markup(icons.pause)
|
||||
else:
|
||||
self.play_pause.get_child().set_markup(icons.play)
|
||||
|
||||
def on_wallpaper_changed(self, monitor, file, other_file, event):
|
||||
self.cover.set_image_from_file(os.path.expanduser("~/.current.wall"))
|
||||
|
||||
# --- Control methods, defined only once each ---
|
||||
def _on_prev_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.previous()
|
||||
|
||||
def _on_play_pause_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
|
||||
def _on_backward_clicked(self, button):
|
||||
# Only seek if player exists, can seek, and button is not disabled
|
||||
if (
|
||||
self.mpris_player
|
||||
and self.mpris_player.can_seek
|
||||
and "disabled" not in self.backward.get_style_context().list_classes()
|
||||
):
|
||||
new_pos = max(0, self.mpris_player.position - 5000000) # 5 seconds backward
|
||||
self.mpris_player.position = new_pos
|
||||
|
||||
def _on_forward_clicked(self, button):
|
||||
# Only seek if player exists, can seek, and button is not disabled
|
||||
if (
|
||||
self.mpris_player
|
||||
and self.mpris_player.can_seek
|
||||
and "disabled" not in self.forward.get_style_context().list_classes()
|
||||
):
|
||||
new_pos = self.mpris_player.position + 5000000 # 5 seconds forward
|
||||
self.mpris_player.position = new_pos
|
||||
|
||||
def _on_next_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.next()
|
||||
|
||||
def _update_progress(self):
|
||||
# Timer is now only active if can_seek is true, so no need for the initial check
|
||||
if not self.mpris_player: # Still need to check if player exists
|
||||
# Should not happen if timer logic is correct, but good safeguard
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
return False # Stop timer
|
||||
|
||||
try:
|
||||
current = self.mpris_player.position
|
||||
except Exception:
|
||||
current = 0
|
||||
try:
|
||||
total = int(self.mpris_player.length or 0)
|
||||
except Exception:
|
||||
total = 0
|
||||
|
||||
# Prevent division by zero or invalid updates
|
||||
if total <= 0:
|
||||
progress = 0.0
|
||||
self.time.set_text("--:-- / --:--")
|
||||
# Don't stop the timer here, length might become available later
|
||||
else:
|
||||
progress = current / total
|
||||
self.time.set_text(
|
||||
f"{self._format_time(current)} / {self._format_time(total)}"
|
||||
)
|
||||
|
||||
self.progressbar.set_value(progress)
|
||||
return True # Continue the timer
|
||||
|
||||
def _format_time(self, us):
|
||||
seconds = int(us / 1000000)
|
||||
minutes = seconds // 60
|
||||
seconds = seconds % 60
|
||||
return f"{minutes}:{seconds:02}"
|
||||
|
||||
def _update_metadata(self):
|
||||
if not self.mpris_player:
|
||||
return False
|
||||
self._apply_mpris_properties()
|
||||
return True
|
||||
|
||||
def _on_mpris_changed(self, *args):
|
||||
# Debounce metadata updates to avoid excessive work on the main thread.
|
||||
if not hasattr(self, "_update_pending") or not self._update_pending:
|
||||
self._update_pending = True
|
||||
# Use idle_add for potentially faster UI response than timeout_add(100)
|
||||
GLib.idle_add(self._apply_mpris_properties_debounced)
|
||||
|
||||
def _apply_mpris_properties_debounced(self):
|
||||
# Ensure player still exists before applying properties
|
||||
if self.mpris_player:
|
||||
self._apply_mpris_properties()
|
||||
else:
|
||||
# Player vanished, ensure timer is stopped if it was running
|
||||
if self._progress_timer_id:
|
||||
GLib.source_remove(self._progress_timer_id)
|
||||
self._progress_timer_id = None
|
||||
self._update_pending = False
|
||||
return False
|
||||
|
||||
|
||||
class Player(Box):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="player",
|
||||
orientation="v",
|
||||
h_align="fill",
|
||||
spacing=0,
|
||||
h_expand=False,
|
||||
v_expand=True,
|
||||
)
|
||||
self.player_stack = Stack(
|
||||
name="player-stack",
|
||||
transition_type="slide-left-right",
|
||||
transition_duration=500,
|
||||
v_align="center",
|
||||
v_expand=True,
|
||||
)
|
||||
self.switcher = Gtk.StackSwitcher(
|
||||
name="player-switcher",
|
||||
spacing=8,
|
||||
)
|
||||
self.switcher.set_stack(self.player_stack)
|
||||
self.switcher.set_halign(Gtk.Align.CENTER)
|
||||
self.mpris_manager = MprisPlayerManager()
|
||||
players = self.mpris_manager.players
|
||||
if players:
|
||||
for p in players:
|
||||
mp = MprisPlayer(p)
|
||||
pb = PlayerBox(mpris_player=mp)
|
||||
self.player_stack.add_titled(pb, mp.player_name, mp.player_name)
|
||||
else:
|
||||
pb = PlayerBox(mpris_player=None)
|
||||
self.player_stack.add_titled(pb, "nothing", "Nothing Playing")
|
||||
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
|
||||
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
|
||||
self.switcher.set_visible(True)
|
||||
self.add(self.player_stack)
|
||||
self.add(self.switcher)
|
||||
GLib.idle_add(self._replace_switcher_labels)
|
||||
|
||||
def on_player_appeared(self, manager, player):
|
||||
children = self.player_stack.get_children()
|
||||
if len(children) == 1 and not getattr(children[0], "mpris_player", None):
|
||||
self.player_stack.remove(children[0])
|
||||
mp = MprisPlayer(player)
|
||||
pb = PlayerBox(mpris_player=mp)
|
||||
self.player_stack.add_titled(pb, mp.player_name, mp.player_name)
|
||||
# Timer is now started conditionally within PlayerBox.__init__
|
||||
self.switcher.set_visible(True)
|
||||
GLib.idle_add(lambda: self._update_switcher_for_player(mp.player_name))
|
||||
GLib.idle_add(self._replace_switcher_labels)
|
||||
|
||||
def on_player_vanished(self, manager, player_name):
|
||||
for child in self.player_stack.get_children():
|
||||
if (
|
||||
hasattr(child, "mpris_player")
|
||||
and child.mpris_player
|
||||
and child.mpris_player.player_name == player_name
|
||||
):
|
||||
self.player_stack.remove(child)
|
||||
break
|
||||
if not any(
|
||||
getattr(child, "mpris_player", None)
|
||||
for child in self.player_stack.get_children()
|
||||
):
|
||||
pb = PlayerBox(mpris_player=None)
|
||||
self.player_stack.add_titled(pb, "nothing", "Nothing Playing")
|
||||
self.switcher.set_visible(True)
|
||||
GLib.idle_add(self._replace_switcher_labels)
|
||||
|
||||
def _replace_switcher_labels(self):
|
||||
buttons = self.switcher.get_children()
|
||||
for btn in buttons:
|
||||
if isinstance(btn, Gtk.ToggleButton):
|
||||
default_label = None
|
||||
for child in btn.get_children():
|
||||
if isinstance(child, Gtk.Label):
|
||||
default_label = child
|
||||
break
|
||||
if default_label:
|
||||
label_player_name = getattr(
|
||||
default_label, "player_name", default_label.get_text().lower()
|
||||
)
|
||||
icon_markup = get_player_icon_markup_by_name(label_player_name)
|
||||
btn.remove(default_label)
|
||||
new_label = Label(name="player-label", markup=icon_markup)
|
||||
new_label.player_name = label_player_name
|
||||
btn.add(new_label)
|
||||
new_label.show_all()
|
||||
return False
|
||||
|
||||
def _update_switcher_for_player(self, player_name):
|
||||
for btn in self.switcher.get_children():
|
||||
if isinstance(btn, Gtk.ToggleButton):
|
||||
default_label = None
|
||||
for child in btn.get_children():
|
||||
if isinstance(child, Gtk.Label):
|
||||
default_label = child
|
||||
break
|
||||
if default_label:
|
||||
label_player_name = getattr(
|
||||
default_label, "player_name", default_label.get_text().lower()
|
||||
)
|
||||
if label_player_name == player_name.lower():
|
||||
icon_markup = get_player_icon_markup_by_name(player_name)
|
||||
btn.remove(default_label)
|
||||
new_label = Label(name="player-label", markup=icon_markup)
|
||||
new_label.player_name = player_name.lower()
|
||||
btn.add(new_label)
|
||||
new_label.show_all()
|
||||
return False
|
||||
|
||||
|
||||
class PlayerSmall(CenterBox):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="player-small", orientation="h", h_align="fill", v_align="center"
|
||||
)
|
||||
self._show_artist = False # toggle flag
|
||||
self._display_options = ["cavalcade", "title", "artist"]
|
||||
self._display_index = 0
|
||||
self._current_display = "cavalcade"
|
||||
|
||||
self.mpris_icon = Button(
|
||||
name="compact-mpris-icon",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
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(
|
||||
name="compact-mpris-label",
|
||||
label="Nothing Playing",
|
||||
ellipsization="end",
|
||||
max_chars_width=26,
|
||||
h_align="center",
|
||||
)
|
||||
self.mpris_button = Button(
|
||||
name="compact-mpris-button",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
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.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",
|
||||
h_expand=True,
|
||||
h_align="fill",
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
start_children=self.mpris_icon,
|
||||
center_children=self.center_stack, # Changed to center_stack to handle stack switching
|
||||
end_children=self.mpris_button,
|
||||
)
|
||||
|
||||
self.add(self.mpris_small)
|
||||
|
||||
self.mpris_manager = MprisPlayerManager()
|
||||
self.mpris_player = None
|
||||
# Almacenar el índice del reproductor actual
|
||||
self.current_index = 0
|
||||
|
||||
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)
|
||||
self.mpris_button.connect("clicked", self._on_play_pause_clicked)
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
if not self.mpris_player:
|
||||
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
|
||||
|
||||
# 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 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):
|
||||
from gi.repository import Gdk
|
||||
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
players = self.mpris_manager.players
|
||||
if not players:
|
||||
return True
|
||||
|
||||
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 # Se consume el evento
|
||||
return True
|
||||
|
||||
def _on_play_pause_button_press(self, widget, event):
|
||||
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
|
||||
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):
|
||||
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 _on_play_pause_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
|
||||
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):
|
||||
# When a new player appears, use it if no player is active.
|
||||
if not self.mpris_player:
|
||||
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):
|
||||
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()
|
||||
112
bar/modules/power.py
Normal file
112
bar/modules/power.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.utils.helpers import exec_shell_command_async
|
||||
import modules.icons as icons
|
||||
|
||||
|
||||
class PowerMenu(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="power-menu",
|
||||
orientation="h",
|
||||
spacing=4,
|
||||
v_align="center",
|
||||
h_align="center",
|
||||
visible=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.notch = kwargs["notch"]
|
||||
|
||||
self.btn_lock = Button(
|
||||
name="power-menu-button",
|
||||
child=Label(name="button-label", markup=icons.lock),
|
||||
on_clicked=self.lock,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_suspend = Button(
|
||||
name="power-menu-button",
|
||||
child=Label(name="button-label", markup=icons.suspend),
|
||||
on_clicked=self.suspend,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_logout = Button(
|
||||
name="power-menu-button",
|
||||
child=Label(name="button-label", markup=icons.logout),
|
||||
on_clicked=self.logout,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_reboot = Button(
|
||||
name="power-menu-button",
|
||||
child=Label(name="button-label", markup=icons.reboot),
|
||||
on_clicked=self.reboot,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.btn_shutdown = Button(
|
||||
name="power-menu-button",
|
||||
child=Label(name="button-label", markup=icons.shutdown),
|
||||
on_clicked=self.poweroff,
|
||||
h_expand=False,
|
||||
v_expand=False,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.buttons = [
|
||||
self.btn_lock,
|
||||
self.btn_suspend,
|
||||
self.btn_logout,
|
||||
self.btn_reboot,
|
||||
self.btn_shutdown,
|
||||
]
|
||||
|
||||
for button in self.buttons:
|
||||
self.add(button)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def close_menu(self):
|
||||
self.notch.close_notch()
|
||||
|
||||
# Métodos de acción
|
||||
def lock(self, *args):
|
||||
print("Locking screen...")
|
||||
exec_shell_command_async("loginctl lock-session")
|
||||
self.close_menu()
|
||||
|
||||
def suspend(self, *args):
|
||||
print("Suspending system...")
|
||||
exec_shell_command_async("systemctl suspend")
|
||||
self.close_menu()
|
||||
|
||||
def logout(self, *args):
|
||||
print("Logging out...")
|
||||
exec_shell_command_async("hyprctl dispatch exit")
|
||||
self.close_menu()
|
||||
|
||||
def reboot(self, *args):
|
||||
print("Rebooting system...")
|
||||
exec_shell_command_async("systemctl reboot")
|
||||
self.close_menu()
|
||||
|
||||
def poweroff(self, *args):
|
||||
print("Powering off...")
|
||||
exec_shell_command_async("systemctl poweroff")
|
||||
self.close_menu()
|
||||
98
bar/modules/vinyl.py
Normal file
98
bar/modules/vinyl.py
Normal file
@@ -0,0 +1,98 @@
|
||||
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:
|
||||
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}")
|
||||
48
bar/modules/volume.py
Normal file
48
bar/modules/volume.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.audio.service import Audio
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
|
||||
class VolumeWidget(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.audio = Audio()
|
||||
|
||||
self.progress_bar = CircularProgressBar(
|
||||
name="volume-progress-bar", pie=True, size=24
|
||||
)
|
||||
|
||||
self.event_box = EventBox(
|
||||
events="scroll",
|
||||
child=Overlay(
|
||||
child=self.progress_bar,
|
||||
overlays=Label(
|
||||
label="",
|
||||
style="margin: 0px 6px 0px 0px; font-size: 12px", # to center the icon glyph
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.audio.connect("notify::speaker", self.on_speaker_changed)
|
||||
self.event_box.connect("scroll-event", self.on_scroll)
|
||||
self.add(self.event_box)
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
match event.direction:
|
||||
case 0:
|
||||
self.audio.speaker.volume += 8
|
||||
case 1:
|
||||
self.audio.speaker.volume -= 8
|
||||
return
|
||||
|
||||
def on_speaker_changed(self, *_):
|
||||
if not self.audio.speaker:
|
||||
return
|
||||
self.progress_bar.value = self.audio.speaker.volume / 100
|
||||
self.audio.speaker.bind(
|
||||
"volume", "value", self.progress_bar, lambda _, v: v / 100
|
||||
)
|
||||
return
|
||||
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)])
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .service import River
|
||||
|
||||
__all__ = ["River"]
|
||||
@@ -1,18 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from .zriver_command_callback_v1 import ZriverCommandCallbackV1 # noqa: F401
|
||||
from .zriver_control_v1 import ZriverControlV1 # noqa: F401
|
||||
@@ -1,90 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pywayland.protocol_core import (
|
||||
Argument,
|
||||
ArgumentType,
|
||||
Global,
|
||||
Interface,
|
||||
Proxy,
|
||||
Resource,
|
||||
)
|
||||
|
||||
|
||||
class ZriverCommandCallbackV1(Interface):
|
||||
"""Callback object
|
||||
|
||||
This object is created by the run_command request. Exactly one of the
|
||||
success or failure events will be sent. This object will be destroyed by
|
||||
the compositor after one of the events is sent.
|
||||
"""
|
||||
|
||||
name = "zriver_command_callback_v1"
|
||||
version = 1
|
||||
|
||||
|
||||
class ZriverCommandCallbackV1Proxy(Proxy[ZriverCommandCallbackV1]):
|
||||
interface = ZriverCommandCallbackV1
|
||||
|
||||
|
||||
class ZriverCommandCallbackV1Resource(Resource):
|
||||
interface = ZriverCommandCallbackV1
|
||||
|
||||
@ZriverCommandCallbackV1.event(
|
||||
Argument(ArgumentType.String),
|
||||
)
|
||||
def success(self, output: str) -> None:
|
||||
"""Command successful
|
||||
|
||||
Sent when the command has been successfully received and executed by
|
||||
the compositor. Some commands may produce output, in which case the
|
||||
output argument will be a non-empty string.
|
||||
|
||||
:param output:
|
||||
the output of the command
|
||||
:type output:
|
||||
`ArgumentType.String`
|
||||
"""
|
||||
self._post_event(0, output)
|
||||
|
||||
@ZriverCommandCallbackV1.event(
|
||||
Argument(ArgumentType.String),
|
||||
)
|
||||
def failure(self, failure_message: str) -> None:
|
||||
"""Command failed
|
||||
|
||||
Sent when the command could not be carried out. This could be due to
|
||||
sending a non-existent command, no command, not enough arguments, too
|
||||
many arguments, invalid arguments, etc.
|
||||
|
||||
:param failure_message:
|
||||
a message explaining why failure occurred
|
||||
:type failure_message:
|
||||
`ArgumentType.String`
|
||||
"""
|
||||
self._post_event(1, failure_message)
|
||||
|
||||
|
||||
class ZriverCommandCallbackV1Global(Global):
|
||||
interface = ZriverCommandCallbackV1
|
||||
|
||||
|
||||
ZriverCommandCallbackV1._gen_c()
|
||||
ZriverCommandCallbackV1.proxy_class = ZriverCommandCallbackV1Proxy
|
||||
ZriverCommandCallbackV1.resource_class = ZriverCommandCallbackV1Resource
|
||||
ZriverCommandCallbackV1.global_class = ZriverCommandCallbackV1Global
|
||||
@@ -1,111 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pywayland.protocol_core import (
|
||||
Argument,
|
||||
ArgumentType,
|
||||
Global,
|
||||
Interface,
|
||||
Proxy,
|
||||
Resource,
|
||||
)
|
||||
|
||||
from ..wayland import WlSeat
|
||||
from .zriver_command_callback_v1 import ZriverCommandCallbackV1
|
||||
|
||||
|
||||
class ZriverControlV1(Interface):
|
||||
"""Run compositor commands
|
||||
|
||||
This interface allows clients to run compositor commands and receive a
|
||||
success/failure response with output or a failure message respectively.
|
||||
|
||||
Each command is built up in a series of add_argument requests and executed
|
||||
with a run_command request. The first argument is the command to be run.
|
||||
|
||||
A complete list of commands should be made available in the man page of the
|
||||
compositor.
|
||||
"""
|
||||
|
||||
name = "zriver_control_v1"
|
||||
version = 1
|
||||
|
||||
|
||||
class ZriverControlV1Proxy(Proxy[ZriverControlV1]):
|
||||
interface = ZriverControlV1
|
||||
|
||||
@ZriverControlV1.request()
|
||||
def destroy(self) -> None:
|
||||
"""Destroy the river_control object
|
||||
|
||||
This request indicates that the client will not use the river_control
|
||||
object any more. Objects that have been created through this instance
|
||||
are not affected.
|
||||
"""
|
||||
self._marshal(0)
|
||||
self._destroy()
|
||||
|
||||
@ZriverControlV1.request(
|
||||
Argument(ArgumentType.String),
|
||||
)
|
||||
def add_argument(self, argument: str) -> None:
|
||||
"""Add an argument to the current command
|
||||
|
||||
Arguments are stored by the server in the order they were sent until
|
||||
the run_command request is made.
|
||||
|
||||
:param argument:
|
||||
the argument to add
|
||||
:type argument:
|
||||
`ArgumentType.String`
|
||||
"""
|
||||
self._marshal(1, argument)
|
||||
|
||||
@ZriverControlV1.request(
|
||||
Argument(ArgumentType.Object, interface=WlSeat),
|
||||
Argument(ArgumentType.NewId, interface=ZriverCommandCallbackV1),
|
||||
)
|
||||
def run_command(self, seat: WlSeat) -> Proxy[ZriverCommandCallbackV1]:
|
||||
"""Run the current command
|
||||
|
||||
Execute the command built up using the add_argument request for the
|
||||
given seat.
|
||||
|
||||
:param seat:
|
||||
:type seat:
|
||||
:class:`~pywayland.protocol.wayland.WlSeat`
|
||||
:returns:
|
||||
:class:`~pywayland.protocol.river_control_unstable_v1.ZriverCommandCallbackV1`
|
||||
-- callback object
|
||||
"""
|
||||
callback = self._marshal_constructor(2, ZriverCommandCallbackV1, seat)
|
||||
return callback
|
||||
|
||||
|
||||
class ZriverControlV1Resource(Resource):
|
||||
interface = ZriverControlV1
|
||||
|
||||
|
||||
class ZriverControlV1Global(Global):
|
||||
interface = ZriverControlV1
|
||||
|
||||
|
||||
ZriverControlV1._gen_c()
|
||||
ZriverControlV1.proxy_class = ZriverControlV1Proxy
|
||||
ZriverControlV1.resource_class = ZriverControlV1Resource
|
||||
ZriverControlV1.global_class = ZriverControlV1Global
|
||||
@@ -1,19 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from .zriver_output_status_v1 import ZriverOutputStatusV1 # noqa: F401
|
||||
from .zriver_seat_status_v1 import ZriverSeatStatusV1 # noqa: F401
|
||||
from .zriver_status_manager_v1 import ZriverStatusManagerV1 # noqa: F401
|
||||
@@ -1,140 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pywayland.protocol_core import (
|
||||
Argument,
|
||||
ArgumentType,
|
||||
Global,
|
||||
Interface,
|
||||
Proxy,
|
||||
Resource,
|
||||
)
|
||||
|
||||
|
||||
class ZriverOutputStatusV1(Interface):
|
||||
"""Track output tags and focus
|
||||
|
||||
This interface allows clients to receive information about the current
|
||||
windowing state of an output.
|
||||
"""
|
||||
|
||||
name = "zriver_output_status_v1"
|
||||
version = 4
|
||||
|
||||
|
||||
class ZriverOutputStatusV1Proxy(Proxy[ZriverOutputStatusV1]):
|
||||
interface = ZriverOutputStatusV1
|
||||
|
||||
@ZriverOutputStatusV1.request()
|
||||
def destroy(self) -> None:
|
||||
"""Destroy the river_output_status object
|
||||
|
||||
This request indicates that the client will not use the
|
||||
river_output_status object any more.
|
||||
"""
|
||||
self._marshal(0)
|
||||
self._destroy()
|
||||
|
||||
|
||||
class ZriverOutputStatusV1Resource(Resource):
|
||||
interface = ZriverOutputStatusV1
|
||||
|
||||
@ZriverOutputStatusV1.event(
|
||||
Argument(ArgumentType.Uint),
|
||||
)
|
||||
def focused_tags(self, tags: int) -> None:
|
||||
"""Focused tags of the output
|
||||
|
||||
Sent once binding the interface and again whenever the tag focus of the
|
||||
output changes.
|
||||
|
||||
:param tags:
|
||||
32-bit bitfield
|
||||
:type tags:
|
||||
`ArgumentType.Uint`
|
||||
"""
|
||||
self._post_event(0, tags)
|
||||
|
||||
@ZriverOutputStatusV1.event(
|
||||
Argument(ArgumentType.Array),
|
||||
)
|
||||
def view_tags(self, tags: list) -> None:
|
||||
"""Tag state of an output's views
|
||||
|
||||
Sent once on binding the interface and again whenever the tag state of
|
||||
the output changes.
|
||||
|
||||
:param tags:
|
||||
array of 32-bit bitfields
|
||||
:type tags:
|
||||
`ArgumentType.Array`
|
||||
"""
|
||||
self._post_event(1, tags)
|
||||
|
||||
@ZriverOutputStatusV1.event(
|
||||
Argument(ArgumentType.Uint),
|
||||
version=2,
|
||||
)
|
||||
def urgent_tags(self, tags: int) -> None:
|
||||
"""Tags of the output with an urgent view
|
||||
|
||||
Sent once on binding the interface and again whenever the set of tags
|
||||
with at least one urgent view changes.
|
||||
|
||||
:param tags:
|
||||
32-bit bitfield
|
||||
:type tags:
|
||||
`ArgumentType.Uint`
|
||||
"""
|
||||
self._post_event(2, tags)
|
||||
|
||||
@ZriverOutputStatusV1.event(
|
||||
Argument(ArgumentType.String),
|
||||
version=4,
|
||||
)
|
||||
def layout_name(self, name: str) -> None:
|
||||
"""Name of the layout
|
||||
|
||||
Sent once on binding the interface should a layout name exist and again
|
||||
whenever the name changes.
|
||||
|
||||
:param name:
|
||||
layout name
|
||||
:type name:
|
||||
`ArgumentType.String`
|
||||
"""
|
||||
self._post_event(3, name)
|
||||
|
||||
@ZriverOutputStatusV1.event(version=4)
|
||||
def layout_name_clear(self) -> None:
|
||||
"""Name of the layout
|
||||
|
||||
Sent when the current layout name has been removed without a new one
|
||||
being set, for example when the active layout generator disconnects.
|
||||
"""
|
||||
self._post_event(4)
|
||||
|
||||
|
||||
class ZriverOutputStatusV1Global(Global):
|
||||
interface = ZriverOutputStatusV1
|
||||
|
||||
|
||||
ZriverOutputStatusV1._gen_c()
|
||||
ZriverOutputStatusV1.proxy_class = ZriverOutputStatusV1Proxy
|
||||
ZriverOutputStatusV1.resource_class = ZriverOutputStatusV1Resource
|
||||
ZriverOutputStatusV1.global_class = ZriverOutputStatusV1Global
|
||||
@@ -1,131 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pywayland.protocol_core import (
|
||||
Argument,
|
||||
ArgumentType,
|
||||
Global,
|
||||
Interface,
|
||||
Proxy,
|
||||
Resource,
|
||||
)
|
||||
|
||||
from pywayland.protocol.wayland import WlOutput
|
||||
|
||||
|
||||
class ZriverSeatStatusV1(Interface):
|
||||
"""Track seat focus
|
||||
|
||||
This interface allows clients to receive information about the current
|
||||
focus of a seat. Note that (un)focused_output events will only be sent if
|
||||
the client has bound the relevant
|
||||
:class:`~pywayland.protocol.wayland.WlOutput` globals.
|
||||
"""
|
||||
|
||||
name = "zriver_seat_status_v1"
|
||||
version = 3
|
||||
|
||||
|
||||
class ZriverSeatStatusV1Proxy(Proxy[ZriverSeatStatusV1]):
|
||||
interface = ZriverSeatStatusV1
|
||||
|
||||
@ZriverSeatStatusV1.request()
|
||||
def destroy(self) -> None:
|
||||
"""Destroy the river_seat_status object
|
||||
|
||||
This request indicates that the client will not use the
|
||||
river_seat_status object any more.
|
||||
"""
|
||||
self._marshal(0)
|
||||
self._destroy()
|
||||
|
||||
|
||||
class ZriverSeatStatusV1Resource(Resource):
|
||||
interface = ZriverSeatStatusV1
|
||||
|
||||
@ZriverSeatStatusV1.event(
|
||||
Argument(ArgumentType.Object, interface=WlOutput),
|
||||
)
|
||||
def focused_output(self, output: WlOutput) -> None:
|
||||
"""The seat focused an output
|
||||
|
||||
Sent on binding the interface and again whenever an output gains focus.
|
||||
|
||||
:param output:
|
||||
:type output:
|
||||
:class:`~pywayland.protocol.wayland.WlOutput`
|
||||
"""
|
||||
self._post_event(0, output)
|
||||
|
||||
@ZriverSeatStatusV1.event(
|
||||
Argument(ArgumentType.Object, interface=WlOutput),
|
||||
)
|
||||
def unfocused_output(self, output: WlOutput) -> None:
|
||||
"""The seat unfocused an output
|
||||
|
||||
Sent whenever an output loses focus.
|
||||
|
||||
:param output:
|
||||
:type output:
|
||||
:class:`~pywayland.protocol.wayland.WlOutput`
|
||||
"""
|
||||
self._post_event(1, output)
|
||||
|
||||
@ZriverSeatStatusV1.event(
|
||||
Argument(ArgumentType.String),
|
||||
)
|
||||
def focused_view(self, title: str) -> None:
|
||||
"""Information on the focused view
|
||||
|
||||
Sent once on binding the interface and again whenever the focused view
|
||||
or a property thereof changes. The title may be an empty string if no
|
||||
view is focused or the focused view did not set a title.
|
||||
|
||||
:param title:
|
||||
title of the focused view
|
||||
:type title:
|
||||
`ArgumentType.String`
|
||||
"""
|
||||
self._post_event(2, title)
|
||||
|
||||
@ZriverSeatStatusV1.event(
|
||||
Argument(ArgumentType.String),
|
||||
version=3,
|
||||
)
|
||||
def mode(self, name: str) -> None:
|
||||
"""The active mode changed
|
||||
|
||||
Sent once on binding the interface and again whenever a new mode is
|
||||
entered (e.g. with riverctl enter-mode foobar).
|
||||
|
||||
:param name:
|
||||
name of the mode
|
||||
:type name:
|
||||
`ArgumentType.String`
|
||||
"""
|
||||
self._post_event(3, name)
|
||||
|
||||
|
||||
class ZriverSeatStatusV1Global(Global):
|
||||
interface = ZriverSeatStatusV1
|
||||
|
||||
|
||||
ZriverSeatStatusV1._gen_c()
|
||||
ZriverSeatStatusV1.proxy_class = ZriverSeatStatusV1Proxy
|
||||
ZriverSeatStatusV1.resource_class = ZriverSeatStatusV1Resource
|
||||
ZriverSeatStatusV1.global_class = ZriverSeatStatusV1Global
|
||||
@@ -1,109 +0,0 @@
|
||||
# This file has been autogenerated by the pywayland scanner
|
||||
|
||||
# Copyright 2020 The River Developers
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pywayland.protocol_core import (
|
||||
Argument,
|
||||
ArgumentType,
|
||||
Global,
|
||||
Interface,
|
||||
Proxy,
|
||||
Resource,
|
||||
)
|
||||
|
||||
from pywayland.protocol.wayland import WlOutput
|
||||
from pywayland.protocol.wayland import WlSeat
|
||||
from .zriver_output_status_v1 import ZriverOutputStatusV1
|
||||
from .zriver_seat_status_v1 import ZriverSeatStatusV1
|
||||
|
||||
|
||||
class ZriverStatusManagerV1(Interface):
|
||||
"""Manage river status objects
|
||||
|
||||
A global factory for objects that receive status information specific to
|
||||
river. It could be used to implement, for example, a status bar.
|
||||
"""
|
||||
|
||||
name = "zriver_status_manager_v1"
|
||||
version = 4
|
||||
|
||||
|
||||
class ZriverStatusManagerV1Proxy(Proxy[ZriverStatusManagerV1]):
|
||||
interface = ZriverStatusManagerV1
|
||||
|
||||
@ZriverStatusManagerV1.request()
|
||||
def destroy(self) -> None:
|
||||
"""Destroy the river_status_manager object
|
||||
|
||||
This request indicates that the client will not use the
|
||||
river_status_manager object any more. Objects that have been created
|
||||
through this instance are not affected.
|
||||
"""
|
||||
self._marshal(0)
|
||||
self._destroy()
|
||||
|
||||
@ZriverStatusManagerV1.request(
|
||||
Argument(ArgumentType.NewId, interface=ZriverOutputStatusV1),
|
||||
Argument(ArgumentType.Object, interface=WlOutput),
|
||||
)
|
||||
def get_river_output_status(self, output: WlOutput) -> Proxy[ZriverOutputStatusV1]:
|
||||
"""Create an output status object
|
||||
|
||||
This creates a new river_output_status object for the given
|
||||
:class:`~pywayland.protocol.wayland.WlOutput`.
|
||||
|
||||
:param output:
|
||||
:type output:
|
||||
:class:`~pywayland.protocol.wayland.WlOutput`
|
||||
:returns:
|
||||
:class:`~pywayland.protocol.river_status_unstable_v1.ZriverOutputStatusV1`
|
||||
"""
|
||||
id = self._marshal_constructor(1, ZriverOutputStatusV1, output)
|
||||
return id
|
||||
|
||||
@ZriverStatusManagerV1.request(
|
||||
Argument(ArgumentType.NewId, interface=ZriverSeatStatusV1),
|
||||
Argument(ArgumentType.Object, interface=WlSeat),
|
||||
)
|
||||
def get_river_seat_status(self, seat: WlSeat) -> Proxy[ZriverSeatStatusV1]:
|
||||
"""Create a seat status object
|
||||
|
||||
This creates a new river_seat_status object for the given
|
||||
:class:`~pywayland.protocol.wayland.WlSeat`.
|
||||
|
||||
:param seat:
|
||||
:type seat:
|
||||
:class:`~pywayland.protocol.wayland.WlSeat`
|
||||
:returns:
|
||||
:class:`~pywayland.protocol.river_status_unstable_v1.ZriverSeatStatusV1`
|
||||
"""
|
||||
id = self._marshal_constructor(2, ZriverSeatStatusV1, seat)
|
||||
return id
|
||||
|
||||
|
||||
class ZriverStatusManagerV1Resource(Resource):
|
||||
interface = ZriverStatusManagerV1
|
||||
|
||||
|
||||
class ZriverStatusManagerV1Global(Global):
|
||||
interface = ZriverStatusManagerV1
|
||||
|
||||
|
||||
ZriverStatusManagerV1._gen_c()
|
||||
ZriverStatusManagerV1.proxy_class = ZriverStatusManagerV1Proxy
|
||||
ZriverStatusManagerV1.resource_class = ZriverStatusManagerV1Resource
|
||||
ZriverStatusManagerV1.global_class = ZriverStatusManagerV1Global
|
||||
@@ -1,85 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="river_control_unstable_v1">
|
||||
<copyright>
|
||||
Copyright 2020 The River Developers
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<interface name="zriver_control_v1" version="1">
|
||||
<description summary="run compositor commands">
|
||||
This interface allows clients to run compositor commands and receive a
|
||||
success/failure response with output or a failure message respectively.
|
||||
|
||||
Each command is built up in a series of add_argument requests and
|
||||
executed with a run_command request. The first argument is the command
|
||||
to be run.
|
||||
|
||||
A complete list of commands should be made available in the man page of
|
||||
the compositor.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the river_control object">
|
||||
This request indicates that the client will not use the
|
||||
river_control object any more. Objects that have been created
|
||||
through this instance are not affected.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="add_argument">
|
||||
<description summary="add an argument to the current command">
|
||||
Arguments are stored by the server in the order they were sent until
|
||||
the run_command request is made.
|
||||
</description>
|
||||
<arg name="argument" type="string" summary="the argument to add"/>
|
||||
</request>
|
||||
|
||||
<request name="run_command">
|
||||
<description summary="run the current command">
|
||||
Execute the command built up using the add_argument request for the
|
||||
given seat.
|
||||
</description>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
<arg name="callback" type="new_id" interface="zriver_command_callback_v1"
|
||||
summary="callback object"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="zriver_command_callback_v1" version="1">
|
||||
<description summary="callback object">
|
||||
This object is created by the run_command request. Exactly one of the
|
||||
success or failure events will be sent. This object will be destroyed
|
||||
by the compositor after one of the events is sent.
|
||||
</description>
|
||||
|
||||
<event name="success" type="destructor">
|
||||
<description summary="command successful">
|
||||
Sent when the command has been successfully received and executed by
|
||||
the compositor. Some commands may produce output, in which case the
|
||||
output argument will be a non-empty string.
|
||||
</description>
|
||||
<arg name="output" type="string" summary="the output of the command"/>
|
||||
</event>
|
||||
|
||||
<event name="failure" type="destructor">
|
||||
<description summary="command failed">
|
||||
Sent when the command could not be carried out. This could be due to
|
||||
sending a non-existent command, no command, not enough arguments, too
|
||||
many arguments, invalid arguments, etc.
|
||||
</description>
|
||||
<arg name="failure_message" type="string"
|
||||
summary="a message explaining why failure occurred"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -1,148 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="river_status_unstable_v1">
|
||||
<copyright>
|
||||
Copyright 2020 The River Developers
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<interface name="zriver_status_manager_v1" version="4">
|
||||
<description summary="manage river status objects">
|
||||
A global factory for objects that receive status information specific
|
||||
to river. It could be used to implement, for example, a status bar.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the river_status_manager object">
|
||||
This request indicates that the client will not use the
|
||||
river_status_manager object any more. Objects that have been created
|
||||
through this instance are not affected.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="get_river_output_status">
|
||||
<description summary="create an output status object">
|
||||
This creates a new river_output_status object for the given wl_output.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="zriver_output_status_v1"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
|
||||
<request name="get_river_seat_status">
|
||||
<description summary="create a seat status object">
|
||||
This creates a new river_seat_status object for the given wl_seat.
|
||||
</description>
|
||||
<arg name="id" type="new_id" interface="zriver_seat_status_v1"/>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="zriver_output_status_v1" version="4">
|
||||
<description summary="track output tags and focus">
|
||||
This interface allows clients to receive information about the current
|
||||
windowing state of an output.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the river_output_status object">
|
||||
This request indicates that the client will not use the
|
||||
river_output_status object any more.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="focused_tags">
|
||||
<description summary="focused tags of the output">
|
||||
Sent once binding the interface and again whenever the tag focus of
|
||||
the output changes.
|
||||
</description>
|
||||
<arg name="tags" type="uint" summary="32-bit bitfield"/>
|
||||
</event>
|
||||
|
||||
<event name="view_tags">
|
||||
<description summary="tag state of an output's views">
|
||||
Sent once on binding the interface and again whenever the tag state
|
||||
of the output changes.
|
||||
</description>
|
||||
<arg name="tags" type="array" summary="array of 32-bit bitfields"/>
|
||||
</event>
|
||||
|
||||
<event name="urgent_tags" since="2">
|
||||
<description summary="tags of the output with an urgent view">
|
||||
Sent once on binding the interface and again whenever the set of
|
||||
tags with at least one urgent view changes.
|
||||
</description>
|
||||
<arg name="tags" type="uint" summary="32-bit bitfield"/>
|
||||
</event>
|
||||
|
||||
<event name="layout_name" since="4">
|
||||
<description summary="name of the layout">
|
||||
Sent once on binding the interface should a layout name exist and again
|
||||
whenever the name changes.
|
||||
</description>
|
||||
<arg name="name" type="string" summary="layout name"/>
|
||||
</event>
|
||||
|
||||
<event name="layout_name_clear" since="4">
|
||||
<description summary="name of the layout">
|
||||
Sent when the current layout name has been removed without a new one
|
||||
being set, for example when the active layout generator disconnects.
|
||||
</description>
|
||||
</event>
|
||||
</interface>
|
||||
|
||||
<interface name="zriver_seat_status_v1" version="3">
|
||||
<description summary="track seat focus">
|
||||
This interface allows clients to receive information about the current
|
||||
focus of a seat. Note that (un)focused_output events will only be sent
|
||||
if the client has bound the relevant wl_output globals.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the river_seat_status object">
|
||||
This request indicates that the client will not use the
|
||||
river_seat_status object any more.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="focused_output">
|
||||
<description summary="the seat focused an output">
|
||||
Sent on binding the interface and again whenever an output gains focus.
|
||||
</description>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</event>
|
||||
|
||||
<event name="unfocused_output">
|
||||
<description summary="the seat unfocused an output">
|
||||
Sent whenever an output loses focus.
|
||||
</description>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</event>
|
||||
|
||||
<event name="focused_view">
|
||||
<description summary="information on the focused view">
|
||||
Sent once on binding the interface and again whenever the focused
|
||||
view or a property thereof changes. The title may be an empty string
|
||||
if no view is focused or the focused view did not set a title.
|
||||
</description>
|
||||
<arg name="title" type="string" summary="title of the focused view"/>
|
||||
</event>
|
||||
|
||||
<event name="mode" since="3">
|
||||
<description summary="the active mode changed">
|
||||
Sent once on binding the interface and again whenever a new mode
|
||||
is entered (e.g. with riverctl enter-mode foobar).
|
||||
</description>
|
||||
<arg name="name" type="string" summary="name of the mode"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -1,288 +0,0 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from loguru import logger
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any, Set
|
||||
|
||||
from fabric.core.service import Service, Signal, Property
|
||||
from fabric.utils.helpers import idle_add
|
||||
|
||||
# Import pywayland components - ensure these imports are correct
|
||||
from pywayland.client import Display
|
||||
from pywayland.protocol.wayland import WlOutput, WlRegistry, WlSeat
|
||||
from .generated.river_status_unstable_v1 import ZriverStatusManagerV1
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputInfo:
|
||||
"""Information about a River output"""
|
||||
|
||||
name: int
|
||||
output: WlOutput
|
||||
status: Any = None # ZriverOutputStatusV1
|
||||
tags_view: List[int] = field(default_factory=list)
|
||||
tags_focused: List[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiverEvent:
|
||||
"""Event data from River compositor"""
|
||||
|
||||
name: str
|
||||
data: List[Any]
|
||||
output_id: Optional[int] = None
|
||||
|
||||
|
||||
class River(Service):
|
||||
"""Connection to River Wayland compositor via river-status protocol"""
|
||||
|
||||
@Property(bool, "readable", "is-ready", default_value=False)
|
||||
def ready(self) -> bool:
|
||||
return self._ready
|
||||
|
||||
@Property(str, "readable", "active-window", default_value="")
|
||||
def active_window(self) -> str:
|
||||
"""Get the title of the currently active window"""
|
||||
return self._active_window_title
|
||||
|
||||
@Signal
|
||||
def ready(self):
|
||||
return self.notify("ready")
|
||||
|
||||
@Signal("event", flags="detailed")
|
||||
def event(self, event: object): ...
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize the River service"""
|
||||
super().__init__(**kwargs)
|
||||
self._ready = False
|
||||
self._active_window_title = ""
|
||||
self.outputs: Dict[int, OutputInfo] = {}
|
||||
self.river_status_mgr = None
|
||||
self.seat = None
|
||||
self.seat_status = None
|
||||
|
||||
# Start the connection in a separate thread
|
||||
self.river_thread = threading.Thread(
|
||||
target=self._river_connection_task, daemon=True, name="river-status-service"
|
||||
)
|
||||
self.river_thread.start()
|
||||
|
||||
def _river_connection_task(self):
|
||||
"""Main thread that connects to River and listens for events"""
|
||||
try:
|
||||
# Create a new display connection - THIS IS WHERE THE ERROR OCCURS
|
||||
logger.info("[RiverService] Starting connection to River")
|
||||
|
||||
# Let's add some more diagnostic info to help troubleshoot
|
||||
logger.debug(
|
||||
f"[RiverService] XDG_RUNTIME_DIR={os.environ.get('XDG_RUNTIME_DIR', 'Not set')}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[RiverService] WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY', 'Not set')}"
|
||||
)
|
||||
|
||||
# Create the display connection
|
||||
# with Display() as display:
|
||||
# registry = display.get_registry()
|
||||
# logger.debug("[RiverService] Registry obtained")
|
||||
|
||||
# Discover globals
|
||||
|
||||
display = Display("wayland-1")
|
||||
display.connect()
|
||||
logger.debug("[RiverService] Display connection created")
|
||||
|
||||
# Get the registry
|
||||
registry = display.get_registry()
|
||||
logger.debug("[RiverService] Registry obtained")
|
||||
|
||||
# Create state object to hold our data
|
||||
state = {
|
||||
"display": display,
|
||||
"registry": registry,
|
||||
"outputs": {},
|
||||
"river_status_mgr": None,
|
||||
"seat": None,
|
||||
"seat_status": None,
|
||||
}
|
||||
|
||||
# Set up registry handlers - using more direct approach like your example
|
||||
def handle_global(registry, name, iface, version):
|
||||
logger.debug(
|
||||
f"[RiverService] Global: {iface} (v{version}, name={name})"
|
||||
)
|
||||
if iface == "zriver_status_manager_v1":
|
||||
state["river_status_mgr"] = registry.bind(
|
||||
name, ZriverStatusManagerV1, version
|
||||
)
|
||||
logger.info("[RiverService] Found river status manager")
|
||||
elif iface == "wl_output":
|
||||
output = registry.bind(name, WlOutput, version)
|
||||
state["outputs"][name] = OutputInfo(name=name, output=output)
|
||||
logger.info(f"[RiverService] Found output {name}")
|
||||
elif iface == "wl_seat":
|
||||
state["seat"] = registry.bind(name, WlSeat, version)
|
||||
logger.info("[RiverService] Found seat")
|
||||
|
||||
def handle_global_remove(registry, name):
|
||||
if name in state["outputs"]:
|
||||
logger.info(f"[RiverService] Output {name} removed")
|
||||
del state["outputs"][name]
|
||||
idle_add(
|
||||
lambda: self.emit(
|
||||
"event::output_removed",
|
||||
RiverEvent("output_removed", [name]),
|
||||
)
|
||||
)
|
||||
|
||||
# Set up the dispatchers
|
||||
registry.dispatcher["global"] = handle_global
|
||||
registry.dispatcher["global_remove"] = handle_global_remove
|
||||
|
||||
# Discover globals
|
||||
logger.debug("[RiverService] Performing initial roundtrip")
|
||||
display.roundtrip()
|
||||
|
||||
# Check if we found the river status manager
|
||||
if not state["river_status_mgr"]:
|
||||
logger.error("[RiverService] River status manager not found")
|
||||
return
|
||||
|
||||
# Handle the window title updates through seat status
|
||||
|
||||
def focused_view_handler(_, title):
|
||||
logger.debug(f"[RiverService] Focused view title: {title}")
|
||||
self._active_window_title = title
|
||||
idle_add(lambda: self._emit_active_window(title))
|
||||
|
||||
# Get the seat status to track active window
|
||||
|
||||
if state["seat"]:
|
||||
seat_status = state["river_status_mgr"].get_river_seat_status(
|
||||
state["seat"]
|
||||
)
|
||||
seat_status.dispatcher["focused_view"] = focused_view_handler
|
||||
state["seat_status"] = seat_status
|
||||
logger.info("[RiverService] Set up seat status for window tracking")
|
||||
|
||||
# Create view tags and focused tags handlers
|
||||
def make_view_tags_handler(output_id):
|
||||
def handler(_, tags):
|
||||
decoded = self._decode_bitfields(tags)
|
||||
state["outputs"][output_id].tags_view = decoded
|
||||
logger.debug(
|
||||
f"[RiverService] Output {output_id} view tags: {decoded}"
|
||||
)
|
||||
idle_add(lambda: self._emit_view_tags(output_id, decoded))
|
||||
|
||||
return handler
|
||||
|
||||
def make_focused_tags_handler(output_id):
|
||||
def handler(_, tags):
|
||||
decoded = self._decode_bitfields(tags)
|
||||
state["outputs"][output_id].tags_focused = decoded
|
||||
logger.debug(
|
||||
f"[RiverService] Output {output_id} focused tags: {decoded}"
|
||||
)
|
||||
idle_add(lambda: self._emit_focused_tags(output_id, decoded))
|
||||
|
||||
return handler
|
||||
|
||||
# Bind output status listeners
|
||||
for name, info in list(state["outputs"].items()):
|
||||
status = state["river_status_mgr"].get_river_output_status(info.output)
|
||||
status.dispatcher["view_tags"] = make_view_tags_handler(name)
|
||||
status.dispatcher["focused_tags"] = make_focused_tags_handler(name)
|
||||
info.status = status
|
||||
logger.info(f"[RiverService] Set up status for output {name}")
|
||||
|
||||
# Initial data fetch
|
||||
logger.debug("[RiverService] Performing second roundtrip")
|
||||
display.roundtrip()
|
||||
|
||||
# Update our outputs dictionary
|
||||
self.outputs.update(state["outputs"])
|
||||
self.river_status_mgr = state["river_status_mgr"]
|
||||
self.seat = state["seat"]
|
||||
self.seat_status = state.get("seat_status")
|
||||
|
||||
# Mark service as ready
|
||||
idle_add(self._set_ready)
|
||||
|
||||
# Main event loop
|
||||
logger.info("[RiverService] Entering main event loop")
|
||||
while True:
|
||||
display.dispatch(block=True)
|
||||
time.sleep(0.01) # Small sleep to prevent CPU spinning
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[RiverService] Error in River connection: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
def _set_ready(self):
|
||||
"""Set the service as ready (called on main thread via idle_add)"""
|
||||
self._ready = True
|
||||
logger.info("[RiverService] Service ready")
|
||||
self.ready.emit()
|
||||
return False # Don't repeat
|
||||
|
||||
def _emit_view_tags(self, output_id, tags):
|
||||
"""Emit view_tags events (called on main thread)"""
|
||||
event = RiverEvent("view_tags", tags, output_id)
|
||||
self.emit("event::view_tags", event)
|
||||
self.emit(f"event::view_tags::{output_id}", tags)
|
||||
return False # Don't repeat
|
||||
|
||||
def _emit_focused_tags(self, output_id, tags):
|
||||
"""Emit focused_tags events (called on main thread)"""
|
||||
event = RiverEvent("focused_tags", tags, output_id)
|
||||
self.emit("event::focused_tags", event)
|
||||
self.emit(f"event::focused_tags::{output_id}", tags)
|
||||
return False # Don't repeat
|
||||
|
||||
def _emit_active_window(self, title):
|
||||
"""Emit active window title events (called on main thread)"""
|
||||
event = RiverEvent("active_window", [title])
|
||||
self.emit("event::active_window", event)
|
||||
self.notify("active-window")
|
||||
return False # Don't repeat
|
||||
|
||||
@staticmethod
|
||||
def _decode_bitfields(bitfields) -> List[int]:
|
||||
"""Decode River's tag bitfields into a list of tag indices"""
|
||||
tags: Set[int] = set()
|
||||
|
||||
# Ensure we have an iterable
|
||||
if not hasattr(bitfields, "__iter__"):
|
||||
bitfields = [bitfields]
|
||||
|
||||
for bits in bitfields:
|
||||
for i in range(32):
|
||||
if bits & (1 << i):
|
||||
tags.add(i)
|
||||
|
||||
return sorted(tags)
|
||||
|
||||
def run_command(self, command, *args):
|
||||
"""Run a riverctl command"""
|
||||
import subprocess
|
||||
|
||||
cmd = ["riverctl", command] + [str(arg) for arg in args]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
logger.info(f"[RiverService] Ran command: {' '.join(cmd)}")
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(
|
||||
f"[RiverService] Command failed: {' '.join(cmd)}, error: {e.stderr}"
|
||||
)
|
||||
return None
|
||||
|
||||
def toggle_focused_tag(self, tag):
|
||||
"""Toggle a tag in the focused tags"""
|
||||
tag_mask = 1 << int(tag)
|
||||
self.run_command("set-focused-tags", str(tag_mask))
|
||||
@@ -1,231 +0,0 @@
|
||||
from loguru import logger
|
||||
from fabric.core.service import Property
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.utils.helpers import bulk_connect
|
||||
from .service import River
|
||||
|
||||
|
||||
from gi.repository import Gdk
|
||||
|
||||
_connection: River | None = None
|
||||
|
||||
|
||||
def get_river_connection() -> River:
|
||||
global _connection
|
||||
if not _connection:
|
||||
_connection = River()
|
||||
return _connection
|
||||
|
||||
|
||||
class RiverWorkspaceButton(Button):
|
||||
@Property(int, "readable")
|
||||
def id(self) -> int:
|
||||
return self._id
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
@active.setter
|
||||
def active(self, value: bool):
|
||||
self._active = value
|
||||
(self.remove_style_class if not value else self.add_style_class)("active")
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def empty(self) -> bool:
|
||||
return self._empty
|
||||
|
||||
@empty.setter
|
||||
def empty(self, value: bool):
|
||||
self._empty = value
|
||||
(self.remove_style_class if not value else self.add_style_class)("empty")
|
||||
|
||||
def __init__(self, id: int, label: str = None, **kwargs):
|
||||
super().__init__(label or str(id), **kwargs)
|
||||
self._id = id
|
||||
self._active = False
|
||||
self._empty = True
|
||||
|
||||
|
||||
class RiverWorkspaces(EventBox):
|
||||
def __init__(self, output_id=None, max_tags=9, **kwargs):
|
||||
super().__init__(events="scroll")
|
||||
self.service = get_river_connection()
|
||||
self._box = Box(**kwargs)
|
||||
self.children = self._box
|
||||
|
||||
# Store output_id as received
|
||||
self.output_id = output_id
|
||||
|
||||
self.max_tags = max_tags
|
||||
# Create buttons for tags 0 to max_tags-1 (to match River's 0-based tag indexing)
|
||||
self._buttons = {i: RiverWorkspaceButton(i) for i in range(max_tags)}
|
||||
|
||||
for btn in self._buttons.values():
|
||||
btn.connect("clicked", self.on_workspace_click)
|
||||
self._box.add(btn)
|
||||
|
||||
# Connect to service events
|
||||
self.service.connect("event::focused_tags", self.on_focus_change_general)
|
||||
self.service.connect("event::view_tags", self.on_view_change_general)
|
||||
self.service.connect("event::output_removed", self.on_output_removed)
|
||||
|
||||
# Initial setup when service is ready
|
||||
if self.service.ready:
|
||||
self.on_ready(None)
|
||||
else:
|
||||
self.service.connect("event::ready", self.on_ready)
|
||||
|
||||
self.connect("scroll-event", self.on_scroll)
|
||||
|
||||
def on_ready(self, _):
|
||||
"""Initialize widget state when service is ready"""
|
||||
logger.debug(
|
||||
f"[RiverWorkspaces] Service ready, outputs: {list(self.service.outputs.keys())}"
|
||||
)
|
||||
|
||||
# If no output_id was specified, take the first one
|
||||
if self.output_id is None and self.service.outputs:
|
||||
self.output_id = next(iter(self.service.outputs.keys()))
|
||||
logger.info(f"[RiverWorkspaces] Selected output {self.output_id}")
|
||||
|
||||
# Initialize state from selected output
|
||||
if self.output_id is not None and self.output_id in self.service.outputs:
|
||||
output_info = self.service.outputs[self.output_id]
|
||||
|
||||
# Initialize buttons with current state
|
||||
# Access fields directly on the OutputInfo dataclass
|
||||
focused_tags = output_info.tags_focused
|
||||
view_tags = output_info.tags_view
|
||||
|
||||
logger.debug(
|
||||
f"[RiverWorkspaces] Initial state - focused: {focused_tags}, view: {view_tags}"
|
||||
)
|
||||
|
||||
for i, btn in self._buttons.items():
|
||||
btn.active = i in focused_tags
|
||||
btn.empty = i not in view_tags
|
||||
|
||||
def on_focus_change(self, _, tags):
|
||||
"""Handle focused tags change for our specific output"""
|
||||
logger.debug(
|
||||
f"[RiverWorkspaces] Focus change on output {self.output_id}: {tags}"
|
||||
)
|
||||
for i, btn in self._buttons.items():
|
||||
btn.active = i in tags
|
||||
|
||||
def on_view_change(self, _, tags):
|
||||
"""Handle view tags change for our specific output"""
|
||||
logger.debug(
|
||||
f"[RiverWorkspaces] View change on output {self.output_id}: {tags}"
|
||||
)
|
||||
for i, btn in self._buttons.items():
|
||||
btn.empty = i not in tags
|
||||
|
||||
def on_focus_change_general(self, _, event):
|
||||
"""Handle general focused tags event"""
|
||||
# Only handle event if it's for our output
|
||||
if event.output_id == self.output_id:
|
||||
logger.debug(
|
||||
f"[RiverWorkspaces] General focus change for output {self.output_id}"
|
||||
)
|
||||
self.on_focus_change(_, event.data)
|
||||
|
||||
def on_view_change_general(self, _, event):
|
||||
"""Handle general view tags event"""
|
||||
# Only handle event if it's for our output
|
||||
if event.output_id == self.output_id:
|
||||
logger.debug(
|
||||
f"[RiverWorkspaces] General view change for output {self.output_id}"
|
||||
)
|
||||
self.on_view_change(_, event.data)
|
||||
|
||||
def on_output_removed(self, _, event):
|
||||
"""Handle output removal"""
|
||||
removed_id = event.data[0]
|
||||
|
||||
if removed_id == self.output_id:
|
||||
logger.info(f"[RiverWorkspaces] Our output {self.output_id} was removed")
|
||||
|
||||
# Try to find another output
|
||||
if self.service.outputs:
|
||||
self.output_id = next(iter(self.service.outputs.keys()))
|
||||
logger.info(f"[RiverWorkspaces] Switching to output {self.output_id}")
|
||||
|
||||
# Update state for new output
|
||||
if self.output_id in self.service.outputs:
|
||||
output_info = self.service.outputs[self.output_id]
|
||||
# Access fields directly on the OutputInfo dataclass
|
||||
focused_tags = output_info.tags_focused
|
||||
view_tags = output_info.tags_view
|
||||
|
||||
for i, btn in self._buttons.items():
|
||||
btn.active = i in focused_tags
|
||||
btn.empty = i not in view_tags
|
||||
|
||||
def on_workspace_click(self, btn):
|
||||
"""Handle workspace button click"""
|
||||
logger.info(f"[RiverWorkspaces] Clicked on workspace {btn.id}")
|
||||
self.service.toggle_focused_tag(btn.id)
|
||||
|
||||
def on_scroll(self, _, event):
|
||||
"""Handle scroll events"""
|
||||
direction = event.direction
|
||||
if direction == Gdk.ScrollDirection.DOWN:
|
||||
logger.info("[RiverWorkspaces] Scroll down - focusing next view")
|
||||
self.service.run_command("focus-view", "next")
|
||||
elif direction == Gdk.ScrollDirection.UP:
|
||||
logger.info("[RiverWorkspaces] Scroll up - focusing previous view")
|
||||
self.service.run_command("focus-view", "previous")
|
||||
|
||||
|
||||
class RiverActiveWindow(Label):
|
||||
"""Widget to display the currently active window's title"""
|
||||
|
||||
def __init__(self, max_length=None, ellipsize="end", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.service = get_river_connection()
|
||||
self.max_length = max_length
|
||||
self.ellipsize = ellipsize
|
||||
|
||||
# Set initial state
|
||||
if self.service.ready:
|
||||
self.on_ready(None)
|
||||
else:
|
||||
self.service.connect("event::ready", self.on_ready)
|
||||
|
||||
# Connect to active window changes
|
||||
self.service.connect("event::active_window", self.on_active_window_changed)
|
||||
|
||||
def on_ready(self, _):
|
||||
"""Initialize widget when service is ready"""
|
||||
logger.debug("[RiverActiveWindow] Service ready")
|
||||
self.update_title(self.service.active_window)
|
||||
|
||||
def on_active_window_changed(self, _, event):
|
||||
"""Update widget when active window changes"""
|
||||
title = event.data[0] if event.data else ""
|
||||
logger.debug(f"[RiverActiveWindow] Window changed to: {title}")
|
||||
self.update_title(title)
|
||||
|
||||
def update_title(self, title):
|
||||
"""Update the label with the window title"""
|
||||
if not title:
|
||||
self.label = ""
|
||||
self.set_label(self.label)
|
||||
return
|
||||
|
||||
if self.max_length and len(title) > self.max_length:
|
||||
if self.ellipsize == "end":
|
||||
title = title[: self.max_length] + "..."
|
||||
elif self.ellipsize == "middle":
|
||||
half = (self.max_length - 3) // 2
|
||||
title = title[:half] + "..." + title[-half:]
|
||||
elif self.ellipsize == "start":
|
||||
title = "..." + title[-self.max_length :]
|
||||
|
||||
self.label = title
|
||||
self.set_label(self.label)
|
||||
282
bar/services/mpris.py
Normal file
282
bar/services/mpris.py
Normal file
@@ -0,0 +1,282 @@
|
||||
# Standard library imports
|
||||
import contextlib
|
||||
|
||||
# Third-party imports
|
||||
import gi
|
||||
from gi.repository import GLib # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
# Fabric imports
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import bulk_connect
|
||||
|
||||
|
||||
class PlayerctlImportError(ImportError):
|
||||
"""An error to raise when playerctl is not installed."""
|
||||
|
||||
def __init__(self, *args):
|
||||
super().__init__(
|
||||
"Playerctl is not installed, please install it first",
|
||||
*args,
|
||||
)
|
||||
|
||||
|
||||
# Try to import Playerctl, raise custom error if not available
|
||||
try:
|
||||
gi.require_version("Playerctl", "2.0")
|
||||
from gi.repository import Playerctl
|
||||
except ValueError:
|
||||
raise PlayerctlImportError
|
||||
|
||||
|
||||
class MprisPlayer(Service):
|
||||
"""A service to manage a mpris player."""
|
||||
|
||||
@Signal
|
||||
def exit(self, value: bool) -> bool: ...
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: Playerctl.Player,
|
||||
**kwargs,
|
||||
):
|
||||
self._signal_connectors: dict = {}
|
||||
self._player: Playerctl.Player = player
|
||||
super().__init__(**kwargs)
|
||||
for sn in ["playback-status", "loop-status", "shuffle", "volume", "seeked"]:
|
||||
self._signal_connectors[sn] = self._player.connect(
|
||||
sn,
|
||||
lambda *args, sn=sn: self.notifier(sn, args),
|
||||
)
|
||||
|
||||
self._signal_connectors["exit"] = self._player.connect(
|
||||
"exit",
|
||||
self.on_player_exit,
|
||||
)
|
||||
self._signal_connectors["metadata"] = self._player.connect(
|
||||
"metadata",
|
||||
lambda *args: self.update_status(),
|
||||
)
|
||||
GLib.idle_add(lambda *args: self.update_status_once())
|
||||
|
||||
def update_status(self):
|
||||
# schedule each notifier asynchronously.
|
||||
def notify_property(prop):
|
||||
if self.get_property(prop) is not None:
|
||||
self.notifier(prop)
|
||||
|
||||
for prop in [
|
||||
"metadata",
|
||||
"title",
|
||||
"artist",
|
||||
"arturl",
|
||||
"length",
|
||||
]:
|
||||
GLib.idle_add(lambda p=prop: (notify_property(p), False))
|
||||
for prop in [
|
||||
"can-seek",
|
||||
"can-pause",
|
||||
"can-shuffle",
|
||||
"can-go-next",
|
||||
"can-go-previous",
|
||||
]:
|
||||
GLib.idle_add(lambda p=prop: (self.notifier(p), False))
|
||||
|
||||
def update_status_once(self):
|
||||
# schedule notifier calls for each property
|
||||
def notify_all():
|
||||
for prop in self.list_properties(): # type: ignore
|
||||
self.notifier(prop.name)
|
||||
return False
|
||||
|
||||
GLib.idle_add(notify_all, priority=GLib.PRIORITY_DEFAULT_IDLE)
|
||||
|
||||
def notifier(self, name: str, args=None):
|
||||
def notify_and_emit():
|
||||
self.notify(name)
|
||||
self.emit("changed")
|
||||
return False
|
||||
|
||||
GLib.idle_add(notify_and_emit, priority=GLib.PRIORITY_DEFAULT_IDLE)
|
||||
|
||||
def on_player_exit(self, player):
|
||||
for id in list(self._signal_connectors.values()):
|
||||
with contextlib.suppress(Exception):
|
||||
self._player.disconnect(id)
|
||||
del self._signal_connectors
|
||||
GLib.idle_add(lambda: (self.emit("exit", True), False))
|
||||
del self._player
|
||||
|
||||
def toggle_shuffle(self):
|
||||
if self.can_shuffle:
|
||||
# schedule the shuffle toggle in the GLib idle loop
|
||||
GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False))
|
||||
# else do nothing
|
||||
|
||||
def play_pause(self):
|
||||
if self.can_pause:
|
||||
GLib.idle_add(lambda: (self._player.play_pause(), False))
|
||||
|
||||
def next(self):
|
||||
if self.can_go_next:
|
||||
GLib.idle_add(lambda: (self._player.next(), False))
|
||||
|
||||
def previous(self):
|
||||
if self.can_go_previous:
|
||||
GLib.idle_add(lambda: (self._player.previous(), False))
|
||||
|
||||
# Properties
|
||||
@Property(str, "readable")
|
||||
def player_name(self) -> int:
|
||||
return self._player.get_property("player-name") # type: ignore
|
||||
|
||||
@Property(int, "read-write", default_value=0)
|
||||
def position(self) -> int:
|
||||
return self._player.get_property("position") # type: ignore
|
||||
|
||||
@position.setter
|
||||
def position(self, new_pos: int):
|
||||
self._player.set_position(new_pos)
|
||||
|
||||
@Property(object, "readable")
|
||||
def metadata(self) -> dict:
|
||||
return self._player.get_property("metadata") # type: ignore
|
||||
|
||||
@Property(str or None, "readable")
|
||||
def arturl(self) -> str | None:
|
||||
if "mpris:artUrl" in self.metadata.keys(): # type: ignore # noqa: SIM118
|
||||
return self.metadata["mpris:artUrl"] # type: ignore
|
||||
return None
|
||||
|
||||
@Property(str or None, "readable")
|
||||
def length(self) -> str | None:
|
||||
if "mpris:length" in self.metadata.keys(): # type: ignore # noqa: SIM118
|
||||
return self.metadata["mpris:length"] # type: ignore
|
||||
return None
|
||||
|
||||
@Property(str, "readable")
|
||||
def artist(self) -> str:
|
||||
artist = self._player.get_artist() # type: ignore
|
||||
if isinstance(artist, (list, tuple)):
|
||||
return ", ".join(artist)
|
||||
return artist
|
||||
|
||||
@Property(str, "readable")
|
||||
def album(self) -> str:
|
||||
return self._player.get_album() # type: ignore
|
||||
|
||||
@Property(str, "readable")
|
||||
def title(self):
|
||||
return self._player.get_title()
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def shuffle(self) -> bool:
|
||||
return self._player.get_property("shuffle") # type: ignore
|
||||
|
||||
@shuffle.setter
|
||||
def shuffle(self, do_shuffle: bool):
|
||||
self.notifier("shuffle")
|
||||
return self._player.set_shuffle(do_shuffle)
|
||||
|
||||
@Property(str, "readable")
|
||||
def playback_status(self) -> str:
|
||||
return {
|
||||
Playerctl.PlaybackStatus.PAUSED: "paused",
|
||||
Playerctl.PlaybackStatus.PLAYING: "playing",
|
||||
Playerctl.PlaybackStatus.STOPPED: "stopped",
|
||||
}.get(self._player.get_property("playback_status"), "unknown") # type: ignore
|
||||
|
||||
@Property(str, "read-write")
|
||||
def loop_status(self) -> str:
|
||||
return {
|
||||
Playerctl.LoopStatus.NONE: "none",
|
||||
Playerctl.LoopStatus.TRACK: "track",
|
||||
Playerctl.LoopStatus.PLAYLIST: "playlist",
|
||||
}.get(self._player.get_property("loop_status"), "unknown") # type: ignore
|
||||
|
||||
@loop_status.setter
|
||||
def loop_status(self, status: str):
|
||||
loop_status = {
|
||||
"none": Playerctl.LoopStatus.NONE,
|
||||
"track": Playerctl.LoopStatus.TRACK,
|
||||
"playlist": Playerctl.LoopStatus.PLAYLIST,
|
||||
}.get(status)
|
||||
self._player.set_loop_status(loop_status) if loop_status else None
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_go_next(self) -> bool:
|
||||
return self._player.get_property("can_go_next") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_go_previous(self) -> bool:
|
||||
return self._player.get_property("can_go_previous") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_seek(self) -> bool:
|
||||
return self._player.get_property("can_seek") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_pause(self) -> bool:
|
||||
return self._player.get_property("can_pause") # type: ignore
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_shuffle(self) -> bool:
|
||||
try:
|
||||
self._player.set_shuffle(self._player.get_property("shuffle"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def can_loop(self) -> bool:
|
||||
try:
|
||||
self._player.set_shuffle(self._player.get_property("shuffle"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class MprisPlayerManager(Service):
|
||||
"""A service to manage mpris players."""
|
||||
|
||||
@Signal
|
||||
def player_appeared(self, player: Playerctl.Player) -> Playerctl.Player: ...
|
||||
|
||||
@Signal
|
||||
def player_vanished(self, player_name: str) -> str: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
**kwargs,
|
||||
):
|
||||
self._manager = Playerctl.PlayerManager.new()
|
||||
bulk_connect(
|
||||
self._manager,
|
||||
{
|
||||
"name-appeared": self.on_name_appeard,
|
||||
"name-vanished": self.on_name_vanished,
|
||||
},
|
||||
)
|
||||
self.add_players()
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def on_name_appeard(self, manager, player_name: Playerctl.PlayerName):
|
||||
logger.info(f"[MprisPlayer] {player_name.name} appeared")
|
||||
new_player = Playerctl.Player.new_from_name(player_name)
|
||||
manager.manage_player(new_player)
|
||||
self.emit("player-appeared", new_player) # type: ignore
|
||||
|
||||
def on_name_vanished(self, manager, player_name: Playerctl.PlayerName):
|
||||
logger.info(f"[MprisPlayer] {player_name.name} vanished")
|
||||
self.emit("player-vanished", player_name.name) # type: ignore
|
||||
|
||||
def add_players(self):
|
||||
for player in self._manager.get_property("player-names"): # type: ignore
|
||||
self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore
|
||||
|
||||
@Property(object, "readable")
|
||||
def players(self):
|
||||
return self._manager.get_property("players") # type: ignore
|
||||
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
|
||||
}
|
||||
28
bar/styles/colors.css
Normal file
28
bar/styles/colors.css
Normal file
@@ -0,0 +1,28 @@
|
||||
:vars {
|
||||
--background: #17181C;
|
||||
--mid-bg: #1E1F24;
|
||||
--light-bg: #26272B;
|
||||
--dark-grey: #333438;
|
||||
--light-grey: #8F9093;
|
||||
--dark-fg: #B0B1B4;
|
||||
--mid-fg: #CBCCCE;
|
||||
--foreground: #E4E5E7;
|
||||
|
||||
--pink: #FA3867;
|
||||
--orange: #F3872F;
|
||||
--gold: #FEBD16;
|
||||
--lime: #3FD43B;
|
||||
--turquoise: #47E7CE;
|
||||
--blue: #53ADE1;
|
||||
--violet: #AD60FF;
|
||||
--red: #FC3F2C;
|
||||
|
||||
--window-bg: alpha(var(--background), 0.9);
|
||||
--module-bg: alpha(var(--mid-bg), 0.8);
|
||||
--border-color: var(--light-bg);
|
||||
--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 */
|
||||
}
|
||||
20
bar/styles/main.css
Normal file
20
bar/styles/main.css
Normal file
@@ -0,0 +1,20 @@
|
||||
@import url("./colors.css");
|
||||
@import url("./workspaces.css");
|
||||
@import url("./menu.css");
|
||||
@import url("./vinyl.css");
|
||||
@import url("./bar.css");
|
||||
@import url("./finder.css");
|
||||
|
||||
|
||||
/* unset so we can style everything from the ground up. */
|
||||
* {
|
||||
all: unset;
|
||||
color: var(--foreground);
|
||||
font-size: 16px;
|
||||
font-family: "Jost*", sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
38
bar/styles/menu.css
Normal file
38
bar/styles/menu.css
Normal file
@@ -0,0 +1,38 @@
|
||||
#date-time,
|
||||
menu>menuitem>label,
|
||||
#date-time>label,
|
||||
/* system tray */
|
||||
#system-tray {
|
||||
padding: 2px 4px;
|
||||
background-color: var(--module-bg);
|
||||
}
|
||||
|
||||
/* menu and menu items (written for the system tray) */
|
||||
menu {
|
||||
border: solid 2px;
|
||||
border-radius: 10px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
}
|
||||
|
||||
menu>menuitem {
|
||||
border-radius: 0px;
|
||||
background-color: var(--module-bg);
|
||||
padding: 6px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
menu>menuitem:first-child {
|
||||
margin-top: 1px;
|
||||
border-radius: 8px 8px 0px 0px;
|
||||
}
|
||||
|
||||
menu>menuitem:last-child {
|
||||
margin-bottom: 1px;
|
||||
border-radius: 0px 0px 8px 8px;
|
||||
}
|
||||
|
||||
menu>menuitem:hover {
|
||||
background-color: var(--pink);
|
||||
}
|
||||
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);
|
||||
}
|
||||
42
bar/styles/workspaces.css
Normal file
42
bar/styles/workspaces.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#workspaces {
|
||||
padding: 6px;
|
||||
min-width: 0px;
|
||||
background-color: var(--module-bg);
|
||||
}
|
||||
|
||||
#workspaces>button {
|
||||
padding: 0px 8px;
|
||||
transition: padding 0.05s steps(8);
|
||||
background-color: var(--foreground);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
#workspaces>button>label {
|
||||
font-size: 0px;
|
||||
}
|
||||
|
||||
#workspaces button.hover {
|
||||
background-color: var(--ws-hover);
|
||||
}
|
||||
|
||||
#workspaces button.urgent {
|
||||
background-color: var(--ws-urgent);
|
||||
color: var(--foreground);
|
||||
font-weight: bold;
|
||||
animation: urgent-blink 1s infinite;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
122
bar/test.py
122
bar/test.py
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
from pywayland.client import Display
|
||||
from pywayland.protocol.wayland import WlOutput, WlRegistry, WlSeat
|
||||
from generated.river_status_unstable_v1 import (
|
||||
ZriverStatusManagerV1,
|
||||
ZriverOutputStatusV1,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputInfo:
|
||||
name: int
|
||||
output: WlOutput
|
||||
status: ZriverOutputStatusV1
|
||||
tags_view: list[int] = field(default_factory=list)
|
||||
tags_focused: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
display: Display
|
||||
registry: WlRegistry
|
||||
outputs: dict[int, OutputInfo] = field(default_factory=dict)
|
||||
river_status_mgr: ZriverStatusManagerV1 | None = None
|
||||
seat: WlSeat | None = None
|
||||
seat_status: ZriverSeatStatusV1 | None = None
|
||||
|
||||
|
||||
def decode_bitfields(bitfields: list[int] | int) -> list[int]:
|
||||
tags = set()
|
||||
if isinstance(bitfields, int):
|
||||
bitfields = [bitfields]
|
||||
for bits in bitfields:
|
||||
for i in range(32):
|
||||
if bits & (1 << i):
|
||||
tags.add(i)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def handle_global(
|
||||
state: State, registry: WlRegistry, name: int, iface: str, version: int
|
||||
) -> None:
|
||||
if iface == "zriver_status_manager_v1":
|
||||
state.river_status_mgr = registry.bind(name, ZriverStatusManagerV1, version)
|
||||
|
||||
elif iface == "wl_output":
|
||||
output = registry.bind(name, WlOutput, version)
|
||||
state.outputs[name] = OutputInfo(name=name, output=output, status=None)
|
||||
elif iface == "wl_seat":
|
||||
seat = registry.bind(name, WlSeat, version)
|
||||
state.seat = seat
|
||||
|
||||
|
||||
def handle_global_remove(state: State, registry: WlRegistry, name: int) -> None:
|
||||
if name in state.outputs:
|
||||
print(f"Output {name} removed.")
|
||||
del state.outputs[name]
|
||||
|
||||
|
||||
def make_view_tags_handler(state: State, name: int) -> Callable:
|
||||
def handler(self, tags: list[int]) -> None:
|
||||
decoded = decode_bitfields(tags)
|
||||
state.outputs[name].tags_view = decoded
|
||||
print(f"[Output {name}] View tags: {decoded}")
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def make_focused_tags_handler(state: State, name: int) -> Callable:
|
||||
def handler(self, tags: int) -> None:
|
||||
decoded = decode_bitfields(tags)
|
||||
state.outputs[name].tags_focused = decoded
|
||||
print(f"[Output {name}] Focused tags: {decoded}")
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with Display() as display:
|
||||
registry = display.get_registry()
|
||||
state = State(display=display, registry=registry)
|
||||
|
||||
registry.dispatcher["global"] = lambda reg, name, iface, ver: handle_global(
|
||||
state, reg, name, iface, ver
|
||||
)
|
||||
registry.dispatcher["global_remove"] = lambda reg, name: handle_global_remove(
|
||||
state, reg, name
|
||||
)
|
||||
|
||||
# Discover globals
|
||||
display.roundtrip()
|
||||
|
||||
if not state.river_status_mgr:
|
||||
print("❌ River status manager not found.")
|
||||
return
|
||||
|
||||
# Bind output status listeners
|
||||
for name, info in state.outputs.items():
|
||||
status = state.river_status_mgr.get_river_output_status(info.output)
|
||||
status.dispatcher["view_tags"] = make_view_tags_handler(state, name)
|
||||
status.dispatcher["focused_tags"] = make_focused_tags_handler(state, name)
|
||||
info.status = status
|
||||
|
||||
if state.seat:
|
||||
state.seat_status = state.river_status_mgr.get_river_seat_status(state.seat)
|
||||
print("✅ Bound seat status")
|
||||
|
||||
# Initial data
|
||||
display.roundtrip()
|
||||
|
||||
print("🟢 Listening for tag changes. Press Ctrl+C to exit.")
|
||||
while True:
|
||||
display.roundtrip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
550
bar/utils/icons.py
Normal file
550
bar/utils/icons.py
Normal file
@@ -0,0 +1,550 @@
|
||||
common_text_icons = {
|
||||
"playing": "",
|
||||
"paused": "",
|
||||
"power": "",
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"storage": "",
|
||||
"updates": "",
|
||||
"thermometer": "",
|
||||
}
|
||||
|
||||
distro_text_icons = {
|
||||
"deepin": "",
|
||||
"fedora": "",
|
||||
"arch": "",
|
||||
"nixos": "",
|
||||
"debian": "",
|
||||
"opensuse-tumbleweed": "",
|
||||
"ubuntu": "",
|
||||
"endeavouros": "",
|
||||
"manjaro": "",
|
||||
"popos": "",
|
||||
"garuda": "",
|
||||
"zorin": "",
|
||||
"mxlinux": "",
|
||||
"arcolinux": "",
|
||||
"gentoo": "",
|
||||
"artix": "",
|
||||
"centos": "",
|
||||
"hyperbola": "",
|
||||
"kubuntu": "",
|
||||
"mandriva": "",
|
||||
"xerolinux": "",
|
||||
"parabola": "",
|
||||
"void": "",
|
||||
"linuxmint": "",
|
||||
"archlabs": "",
|
||||
"devuan": "",
|
||||
"freebsd": "",
|
||||
"openbsd": "",
|
||||
"slackware": "",
|
||||
}
|
||||
|
||||
# sourced from wttr.in
|
||||
weather_text_icons = {
|
||||
"113": {"description": "Sunny", "icon": ""},
|
||||
"116": {"description": "PartlyCloudy", "icon": ""},
|
||||
"119": {"description": "Cloudy", "icon": ""},
|
||||
"122": {"description": "VeryCloudy", "icon": ""},
|
||||
"143": {"description": "Fog", "icon": ""},
|
||||
"176": {"description": "LightShowers", "icon": ""},
|
||||
"179": {"description": "LightSleetShowers", "icon": ""},
|
||||
"182": {"description": "LightSleet", "icon": ""},
|
||||
"185": {"description": "LightSleet", "icon": ""},
|
||||
"200": {"description": "ThunderyShowers", "icon": ""},
|
||||
"227": {"description": "LightSnow", "icon": ""},
|
||||
"230": {"description": "HeavySnow", "icon": ""},
|
||||
"248": {"description": "Fog", "icon": ""},
|
||||
"260": {"description": "Fog", "icon": ""},
|
||||
"263": {"description": "LightShowers", "icon": ""},
|
||||
"266": {"description": "LightRain", "icon": ""},
|
||||
"281": {"description": "LightSleet", "icon": ""},
|
||||
"284": {"description": "LightSleet", "icon": ""},
|
||||
"293": {"description": "LightRain", "icon": ""},
|
||||
"296": {"description": "LightRain", "icon": ""},
|
||||
"299": {"description": "HeavyShowers", "icon": ""},
|
||||
"302": {"description": "HeavyRain", "icon": ""},
|
||||
"305": {"description": "HeavyShowers", "icon": ""},
|
||||
"308": {"description": "HeavyRain", "icon": ""},
|
||||
"311": {"description": "LightSleet", "icon": ""},
|
||||
"314": {"description": "LightSleet", "icon": ""},
|
||||
"317": {"description": "LightSleet", "icon": ""},
|
||||
"320": {"description": "LightSnow", "icon": ""},
|
||||
"323": {"description": "LightSnowShowers", "icon": ""},
|
||||
"326": {"description": "LightSnowShowers", "icon": ""},
|
||||
"329": {"description": "HeavySnow", "icon": ""},
|
||||
"332": {"description": "HeavySnow", "icon": ""},
|
||||
"335": {"description": "HeavySnowShowers", "icon": ""},
|
||||
"338": {"description": "HeavySnow", "icon": ""},
|
||||
"350": {"description": "LightSleet", "icon": ""},
|
||||
"353": {"description": "LightShowers", "icon": ""},
|
||||
"356": {"description": "HeavyShowers", "icon": ""},
|
||||
"359": {"description": "HeavyRain", "icon": ""},
|
||||
"362": {"description": "LightSleetShowers", "icon": ""},
|
||||
"365": {"description": "LightSleetShowers", "icon": ""},
|
||||
"368": {"description": "LightSnowShowers", "icon": ""},
|
||||
"371": {"description": "HeavySnowShowers", "icon": ""},
|
||||
"374": {"description": "LightSleetShowers", "icon": ""},
|
||||
"377": {"description": "LightSleet", "icon": ""},
|
||||
"386": {"description": "ThunderyShowers", "icon": ""},
|
||||
"389": {"description": "ThunderyHeavyRain", "icon": ""},
|
||||
"392": {"description": "ThunderySnowShowers", "icon": ""},
|
||||
"395": {"description": "HeavySnowShowers", "icon": ""},
|
||||
}
|
||||
|
||||
weather_text_icons_v2 = {
|
||||
"113": {
|
||||
"description": "Sunny",
|
||||
"icon": "",
|
||||
"image": "clear-day",
|
||||
"icon-night": "",
|
||||
"image-night": "clear-night",
|
||||
},
|
||||
"116": {
|
||||
"description": "PartlyCloudy",
|
||||
"icon": "",
|
||||
"image": "cloudy",
|
||||
"icon-night": "",
|
||||
"image-night": "cloudy",
|
||||
},
|
||||
"119": {
|
||||
"description": "Cloudy",
|
||||
"icon": "",
|
||||
"image": "cloudy",
|
||||
"icon-night": "",
|
||||
"image-night": "cloudy",
|
||||
},
|
||||
"122": {
|
||||
"description": "VeryCloudy",
|
||||
"icon": "",
|
||||
"image": "cloudy",
|
||||
"icon-night": "",
|
||||
"image-night": "cloudy",
|
||||
},
|
||||
"143": {
|
||||
"description": "Fog",
|
||||
"icon": "",
|
||||
"image": "fog",
|
||||
"icon-night": "",
|
||||
"image-night": "fog",
|
||||
},
|
||||
"176": {
|
||||
"description": "LightShowers",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"179": {
|
||||
"description": "LightSleetShowers",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"182": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"185": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"200": {
|
||||
"description": "ThunderyShowers",
|
||||
"icon": "",
|
||||
"image": "thunderstorms",
|
||||
"icon-night": "",
|
||||
"image-night": "thunderstorms",
|
||||
},
|
||||
"227": {
|
||||
"description": "LightSnow",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"230": {
|
||||
"description": "HeavySnow",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"248": {
|
||||
"description": "Fog",
|
||||
"icon": "",
|
||||
"image": "fog",
|
||||
"icon-night": "",
|
||||
"image-night": "fog",
|
||||
},
|
||||
"260": {
|
||||
"description": "Fog",
|
||||
"icon": "",
|
||||
"image": "fog",
|
||||
"icon-night": "",
|
||||
"image-night": "fog",
|
||||
},
|
||||
"263": {
|
||||
"description": "LightShowers",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"266": {
|
||||
"description": "LightRain",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"281": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"284": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"293": {
|
||||
"description": "LightRain",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"296": {
|
||||
"description": "LightRain",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"299": {
|
||||
"description": "HeavyShowers",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"302": {
|
||||
"description": "HeavyRain",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"305": {
|
||||
"description": "HeavyShowers",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"308": {
|
||||
"description": "HeavyRain",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"311": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"314": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"317": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"320": {
|
||||
"description": "LightSnow",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"323": {
|
||||
"description": "LightSnowShowers",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"326": {
|
||||
"description": "LightSnowShowers",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"329": {
|
||||
"description": "HeavySnow",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"332": {
|
||||
"description": "HeavySnow",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"335": {
|
||||
"description": "HeavySnowShowers",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"338": {
|
||||
"description": "HeavySnow",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"350": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"353": {
|
||||
"description": "LightShowers",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"356": {
|
||||
"description": "HeavyShowers",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"359": {
|
||||
"description": "HeavyRain",
|
||||
"icon": "",
|
||||
"image": "rain",
|
||||
"icon-night": "",
|
||||
"image-night": "rain",
|
||||
},
|
||||
"362": {
|
||||
"description": "LightSleetShowers",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"365": {
|
||||
"description": "HeavySleetShowers",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"368": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"371": {
|
||||
"description": "HeavySleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
"374": {
|
||||
"description": "HeavySnowShowers",
|
||||
"icon": "",
|
||||
"image": "snow",
|
||||
"icon-night": "",
|
||||
"image-night": "snow",
|
||||
},
|
||||
"377": {
|
||||
"description": "LightSleet",
|
||||
"icon": "",
|
||||
"image": "sleet",
|
||||
"icon-night": "",
|
||||
"image-night": "sleet",
|
||||
},
|
||||
}
|
||||
|
||||
volume_text_icons = {
|
||||
"overamplified": "",
|
||||
"high": "",
|
||||
"medium": "",
|
||||
"low": "",
|
||||
"muted": "",
|
||||
}
|
||||
|
||||
volume_text_icons = {
|
||||
"overamplified": "",
|
||||
"high": "",
|
||||
"medium": "",
|
||||
"low": "",
|
||||
"muted": "",
|
||||
}
|
||||
|
||||
brightness_text_icons = {
|
||||
"off": "", # lowest brightness
|
||||
"low": "",
|
||||
"medium": "",
|
||||
"high": "", # highest brightness
|
||||
}
|
||||
|
||||
icons = {
|
||||
"missing": "image-missing-symbolic",
|
||||
"nix": {
|
||||
"nix": "nix-snowflake-symbolic",
|
||||
},
|
||||
"app": {
|
||||
"terminal": "terminal-symbolic",
|
||||
},
|
||||
"fallback": {
|
||||
"executable": "application-x-executable",
|
||||
"notification": "dialog-information-symbolic",
|
||||
"video": "video-x-generic-symbolic",
|
||||
"audio": "audio-x-generic-symbolic",
|
||||
},
|
||||
"ui": {
|
||||
"close": "window-close-symbolic",
|
||||
"colorpicker": "color-select-symbolic",
|
||||
"info": "info-symbolic",
|
||||
"link": "external-link-symbolic",
|
||||
"lock": "system-lock-screen-symbolic",
|
||||
"menu": "open-menu-symbolic",
|
||||
"refresh": "view-refresh-symbolic",
|
||||
"search": "system-search-symbolic",
|
||||
"settings": "emblem-system-symbolic",
|
||||
"themes": "preferences-desktop-theme-symbolic",
|
||||
"tick": "object-select-symbolic",
|
||||
"time": "hourglass-symbolic",
|
||||
"toolbars": "toolbars-symbolic",
|
||||
"warning": "dialog-warning-symbolic",
|
||||
"avatar": "avatar-default-symbolic",
|
||||
"arrow": {
|
||||
"right": "pan-end-symbolic",
|
||||
"left": "pan-start-symbolic",
|
||||
"down": "pan-down-symbolic",
|
||||
"up": "pan-up-symbolic",
|
||||
},
|
||||
},
|
||||
"audio": {
|
||||
"mic": {
|
||||
"muted": "microphone-disabled-symbolic",
|
||||
"low": "microphone-sensitivity-low-symbolic",
|
||||
"medium": "microphone-sensitivity-medium-symbolic",
|
||||
"high": "microphone-sensitivity-high-symbolic",
|
||||
},
|
||||
"volume": {
|
||||
"muted": "audio-volume-muted-symbolic",
|
||||
"low": "audio-volume-low-symbolic",
|
||||
"medium": "audio-volume-medium-symbolic",
|
||||
"high": "audio-volume-high-symbolic",
|
||||
"overamplified": "audio-volume-overamplified-symbolic",
|
||||
},
|
||||
"type": {
|
||||
"headset": "audio-headphones-symbolic",
|
||||
"speaker": "audio-speakers-symbolic",
|
||||
"card": "audio-card-symbolic",
|
||||
},
|
||||
"mixer": "mixer-symbolic",
|
||||
},
|
||||
"powerprofile": {
|
||||
"balanced": "power-profile-balanced-symbolic",
|
||||
"power-saver": "power-profile-power-saver-symbolic",
|
||||
"performance": "power-profile-performance-symbolic",
|
||||
},
|
||||
"battery": {
|
||||
"charging": "battery-flash-symbolic",
|
||||
"warning": "battery-empty-symbolic",
|
||||
},
|
||||
"bluetooth": {
|
||||
"enabled": "bluetooth-active-symbolic",
|
||||
"disabled": "bluetooth-disabled-symbolic",
|
||||
},
|
||||
"brightness": {
|
||||
"indicator": "display-brightness-symbolic",
|
||||
"keyboard": "keyboard-brightness-symbolic",
|
||||
"screen": "display-brightness-symbolic",
|
||||
},
|
||||
"powermenu": {
|
||||
"sleep": "weather-clear-night-symbolic",
|
||||
"reboot": "system-reboot-symbolic",
|
||||
"logout": "system-log-out-symbolic",
|
||||
"shutdown": "system-shutdown-symbolic",
|
||||
},
|
||||
"recorder": {
|
||||
"recording": "media-record-symbolic",
|
||||
"stopped": "media-record-symbolic",
|
||||
},
|
||||
"notifications": {
|
||||
"noisy": "org.gnome.Settings-notifications-symbolic",
|
||||
"silent": "notifications-disabled-symbolic",
|
||||
"message": "chat-bubbles-symbolic",
|
||||
},
|
||||
"trash": {
|
||||
"full": "user-trash-full-symbolic",
|
||||
"empty": "user-trash-symbolic",
|
||||
},
|
||||
"mpris": {
|
||||
"shuffle": {
|
||||
"enabled": "media-playlist-shuffle-symbolic",
|
||||
"disabled": "media-playlist-consecutive-symbolic",
|
||||
},
|
||||
"loop": {
|
||||
"none": "media-playlist-repeat-symbolic",
|
||||
"track": "media-playlist-repeat-song-symbolic",
|
||||
"playlist": "media-playlist-repeat-symbolic",
|
||||
},
|
||||
"playing": "media-playback-pause-symbolic",
|
||||
"paused": "media-playback-start-symbolic",
|
||||
"stopped": "media-playback-start-symbolic",
|
||||
"prev": "media-skip-backward-symbolic",
|
||||
"next": "media-skip-forward-symbolic",
|
||||
},
|
||||
"system": {
|
||||
"cpu": "org.gnome.SystemMonitor-symbolic",
|
||||
"ram": "drive-harddisk-solidstate-symbolic",
|
||||
"temp": "temperature-symbolic",
|
||||
},
|
||||
"color": {
|
||||
"dark": "dark-mode-symbolic",
|
||||
"light": "light-mode-symbolic",
|
||||
},
|
||||
}
|
||||
126
bar/widgets/circle_image.py
Normal file
126
bar/widgets/circle_image.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from fabric.core.service import Property
|
||||
from fabric.widgets.widget import Widget
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||
|
||||
|
||||
class CircleImage(Gtk.DrawingArea, Widget):
|
||||
"""A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
|
||||
|
||||
@Property(int, "read-write")
|
||||
def angle(self) -> int:
|
||||
return self._angle
|
||||
|
||||
@angle.setter
|
||||
def angle(self, value: int):
|
||||
self._angle = value % 360
|
||||
self.queue_draw()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_file: str | None = None,
|
||||
pixbuf: GdkPixbuf.Pixbuf | None = None,
|
||||
name: str | None = None,
|
||||
visible: bool = True,
|
||||
all_visible: bool = False,
|
||||
style: str | None = None,
|
||||
tooltip_text: str | None = None,
|
||||
tooltip_markup: str | None = None,
|
||||
h_align: Literal["fill", "start", "end", "center", "baseline"]
|
||||
| Gtk.Align
|
||||
| None = None,
|
||||
v_align: Literal["fill", "start", "end", "center", "baseline"]
|
||||
| Gtk.Align
|
||||
| None = None,
|
||||
h_expand: bool = False,
|
||||
v_expand: bool = False,
|
||||
size: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
Gtk.DrawingArea.__init__(self)
|
||||
Widget.__init__(
|
||||
self,
|
||||
name=name,
|
||||
visible=visible,
|
||||
all_visible=all_visible,
|
||||
style=style,
|
||||
tooltip_text=tooltip_text,
|
||||
tooltip_markup=tooltip_markup,
|
||||
h_align=h_align,
|
||||
v_align=v_align,
|
||||
h_expand=h_expand,
|
||||
v_expand=v_expand,
|
||||
size=size,
|
||||
**kwargs,
|
||||
)
|
||||
self.size = size if size is not None else 100 # Default size if not provided
|
||||
self._angle = 0
|
||||
self._orig_image: GdkPixbuf.Pixbuf | None = (
|
||||
None # Original image for reprocessing
|
||||
)
|
||||
self._image: GdkPixbuf.Pixbuf | None = None
|
||||
if image_file:
|
||||
pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
|
||||
self._orig_image = pix
|
||||
self._image = self._process_image(pix)
|
||||
elif pixbuf:
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.connect("draw", self.on_draw)
|
||||
|
||||
def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
|
||||
"""Crop the image to a centered square and scale it to the widget’s size."""
|
||||
width, height = pixbuf.get_width(), pixbuf.get_height()
|
||||
if width != height:
|
||||
square_size = min(width, height)
|
||||
x_offset = (width - square_size) // 2
|
||||
y_offset = (height - square_size) // 2
|
||||
pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
|
||||
else:
|
||||
square_size = width
|
||||
if square_size != self.size:
|
||||
pixbuf = pixbuf.scale_simple(
|
||||
self.size, self.size, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
return pixbuf
|
||||
|
||||
def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
|
||||
if self._image:
|
||||
ctx.save()
|
||||
# Create a circular clipping path
|
||||
ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
|
||||
ctx.clip()
|
||||
# Rotate around the center of the square image
|
||||
ctx.translate(self.size / 2, self.size / 2)
|
||||
ctx.rotate(self._angle * math.pi / 180.0)
|
||||
ctx.translate(-self.size / 2, -self.size / 2)
|
||||
Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
|
||||
ctx.paint()
|
||||
ctx.restore()
|
||||
|
||||
def set_image_from_file(self, new_image_file: str):
|
||||
if not new_image_file:
|
||||
return
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.queue_draw()
|
||||
|
||||
def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
|
||||
if not pixbuf:
|
||||
return
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.queue_draw()
|
||||
|
||||
def set_image_size(self, size: int):
|
||||
self.size = size
|
||||
if self._orig_image:
|
||||
self._image = self._process_image(self._orig_image)
|
||||
self.queue_draw()
|
||||
2
example.yaml
Normal file
2
example.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
vinyl:
|
||||
enabled: true
|
||||
57
flake.lock
generated
57
flake.lock
generated
@@ -6,47 +6,67 @@
|
||||
"utils": "utils"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1725442219,
|
||||
"narHash": "sha256-xgTjqwlAgfY0Kv6G6CogOV2pN6U0wllRYteVAAZs7BU=",
|
||||
"owner": "wholikeel",
|
||||
"repo": "fabric-nix",
|
||||
"rev": "3bc86cfb8c988ff5488526a47e1914f03a34a87c",
|
||||
"lastModified": 1747045720,
|
||||
"narHash": "sha256-2Z0F4hnluJZunwRfx80EQXpjGLhunV2wrseT42nzh7M=",
|
||||
"owner": "Makesesama",
|
||||
"repo": "fabric",
|
||||
"rev": "dae50c763e8bf2b4e5807b49b9e62425e0725cfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "wholikeel",
|
||||
"repo": "fabric-nix",
|
||||
"owner": "Makesesama",
|
||||
"repo": "fabric",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1746369725,
|
||||
"narHash": "sha256-m3ai7LLFYsymMK0uVywCceWfUhP0k3CALyFOfcJACqE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "1a1793f6d940d22c6e49753548c5b6cb7dc5545d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1717179513,
|
||||
"narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=",
|
||||
"lastModified": 1733261153,
|
||||
"narHash": "sha256-eq51hyiaIwtWo19fPEeE0Zr2s83DYMKJoukNLgGGpek=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0",
|
||||
"rev": "b681065d0919f7eb5309a93cea2cfa84dec9aa88",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "24.05",
|
||||
"ref": "nixos-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1717179513,
|
||||
"narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=",
|
||||
"lastModified": 1731603435,
|
||||
"narHash": "sha256-CqCX4JG7UiHvkrBTpYC3wcEurvbtTADLbo3Ns2CEoL8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0",
|
||||
"rev": "8b27c1239e5c421a2bbc2c65d52e4a6fbf2ff296",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "24.05",
|
||||
"ref": "24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -54,6 +74,7 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"fabric": "fabric",
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"unstable": "unstable",
|
||||
"utils": "utils_2"
|
||||
@@ -110,11 +131,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
80
flake.nix
80
flake.nix
@@ -2,10 +2,12 @@
|
||||
description = "Fabric Bar Example";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/24.05";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/24.11";
|
||||
unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
fabric.url = "github:wholikeel/fabric-nix";
|
||||
fabric.url = "github:Makesesama/fabric";
|
||||
home-manager.url = "github:nix-community/home-manager";
|
||||
home-manager.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
@@ -29,12 +31,80 @@
|
||||
in
|
||||
{
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
devShells.default = pkgs.callPackage ./shell.nix { inherit pkgs; };
|
||||
packages.default = pkgs.callPackage ./derivation.nix { inherit (pkgs) lib python3Packages; };
|
||||
devShells.default = pkgs.callPackage ./nix/shell.nix { inherit pkgs; };
|
||||
packages = {
|
||||
default = pkgs.callPackage ./nix/derivation.nix { inherit (pkgs) lib python3Packages; };
|
||||
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/bar";
|
||||
};
|
||||
}
|
||||
);
|
||||
)
|
||||
// {
|
||||
homeManagerModules.makku-bar =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.makku-bar;
|
||||
|
||||
settingsFormat = pkgs.formats.yaml { };
|
||||
in
|
||||
{
|
||||
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 makku-bar package to use.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
vinyl = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
default = {
|
||||
vinyl.enable = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.services.makku-bar.enable {
|
||||
systemd.user.services.makku-bar =
|
||||
let
|
||||
configFile = settingsFormat.generate "config.yaml" cfg.settings;
|
||||
in
|
||||
{
|
||||
Unit = {
|
||||
Description = "Makku Status Bar";
|
||||
After = [ "graphical-session.target" ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
ExecStart = "${config.services.makku-bar.package}/bin/bar --config ${configFile}";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
|
||||
Install = {
|
||||
WantedBy = [ "default.target" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
gobject-introspection,
|
||||
libdbusmenu-gtk3,
|
||||
gdk-pixbuf,
|
||||
gnome,
|
||||
cinnamon,
|
||||
gnome-bluetooth,
|
||||
cinnamon-desktop,
|
||||
wrapGAppsHook3,
|
||||
playerctl,
|
||||
webp-pixbuf-loader,
|
||||
...
|
||||
}:
|
||||
|
||||
@@ -18,29 +20,49 @@ python3Packages.buildPythonApplication {
|
||||
version = "0.0.1";
|
||||
pyproject = true;
|
||||
|
||||
src = ./.;
|
||||
src = ../.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
wrapGAppsHook3
|
||||
gtk3
|
||||
gobject-introspection
|
||||
python3Packages.pygobject3
|
||||
cairo
|
||||
playerctl
|
||||
];
|
||||
buildInputs = [
|
||||
libdbusmenu-gtk3
|
||||
gtk-layer-shell
|
||||
gnome-bluetooth
|
||||
cinnamon-desktop
|
||||
gdk-pixbuf
|
||||
playerctl
|
||||
webp-pixbuf-loader
|
||||
];
|
||||
# buildInputs = [
|
||||
# libdbusmenu-gtk3
|
||||
# gtk-layer-shell
|
||||
# gnome.gnome-bluetooth
|
||||
# cinnamon.cinnamon-desktop
|
||||
# gdk-pixbuf
|
||||
# ];
|
||||
|
||||
dependencies = with python3Packages; [
|
||||
python-fabric
|
||||
pywayland
|
||||
pyyaml
|
||||
platformdirs
|
||||
];
|
||||
doCheck = false;
|
||||
dontWrapGApps = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $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/bar
|
||||
chmod +x $out/bin/bar
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
preFixup = ''
|
||||
makeWrapperArgs+=("''${gappsWrapperArgs[@]}")
|
||||
'';
|
||||
@@ -22,11 +22,13 @@ pkgs.mkShell {
|
||||
gobject-introspection
|
||||
libdbusmenu-gtk3
|
||||
gdk-pixbuf
|
||||
gnome.gnome-bluetooth
|
||||
cinnamon.cinnamon-desktop
|
||||
gnome-bluetooth
|
||||
cinnamon-desktop
|
||||
wayland-scanner
|
||||
wayland
|
||||
wayland-protocols
|
||||
playerctl
|
||||
|
||||
(python3.withPackages (
|
||||
ps: with ps; [
|
||||
setuptools
|
||||
@@ -39,6 +41,8 @@ pkgs.mkShell {
|
||||
pylsp-mypy
|
||||
pyls-isort
|
||||
python-lsp-ruff
|
||||
pyyaml
|
||||
platformdirs
|
||||
]
|
||||
))
|
||||
];
|
||||
@@ -14,11 +14,11 @@ description = "Fabric using Nix example."
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.scripts]
|
||||
bar = "bar.bar:main"
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
[tool.setuptools.packages]
|
||||
find = { namespaces = true }
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
bar = ["bar.css"]
|
||||
"*" = ["*.css", "styles"]
|
||||
|
||||
21
scripts/launcher.py
Normal file
21
scripts/launcher.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 bar.main import *
|
||||
|
||||
sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__))
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user