Compare commits

...
2 Commits
Author SHA1 Message Date
Makussu 377889db8c feat: unified bar styling
- semantic tokens (accent/surface/hairline/text-muted) derived from the
  palette so they follow stylix in light and dark mode
- every bar module is a uniform 28px rounded chip; one accent for active
  state, tints for attention, solid fill only for mail-debt alarm
- workspaces as dots with a stretching accent pill
- mic/cpu-ram/tray/battery grouped into one chip; cpu/ram as concentric
  rings with tooltip; clock split into bold time + muted date
- stylix provider only sets palette + font; scope its font-size so the
  bar's own CSS sizes are no longer flattened
- drop inline widget styles, 16px tray icons, fix broken menu.css selector
2026-11-09 23:29:01 +01:00
Makussu a890604001 feat: media hover card with playback controls 2026-11-09 22:20:34 +01:00
17 changed files with 1263 additions and 523 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ from gi.repository import GLib, Gtk
from fabric.notifications import Notifications
tray = SystemTray(name="system-tray", spacing=4)
tray = SystemTray(name="system-tray", spacing=2, icon_size=16)
get_i3_connection()
# Subscribe before the main loop runs so fenster's replayed capture state
# (sent right after SUBSCRIBE) is not missed.
+49 -24
View File
@@ -1,8 +1,10 @@
import time
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox
from fabric.utils import invoke_repeater
from sims.modules.player import Player, PlayerSmall
from sims.modules.battery import Battery
from sims.modules.control_center import ControlCenter
@@ -55,12 +57,10 @@ class StatusBar(Window):
spacing=4,
)
datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M")
self.date_time = Button(
name="date-time-button",
child=datetime_widget,
child=Clock(),
on_clicked=self._on_date_time_clicked,
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
)
self.control_center = control_center
@@ -69,25 +69,24 @@ class StatusBar(Window):
self.active_window = FensterActiveWindow(
name="active-window",
max_length=50,
style="font-size: 14px; font-weight: bold;",
)
# CPU/RAM as concentric "activity rings": outer = RAM, inner = CPU.
ring = dict(line_width=3, start_angle=-90, end_angle=270,
h_align="center", v_align="center")
self.ram_progress_bar = CircularProgressBar(
name="ram-progress-bar", pie=True, size=24
name="ram-progress-bar", size=18, **ring
)
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"
name="cpu-progress-bar", size=8, **ring
)
self.progress_bars_overlay = Overlay(
name="system-stats",
child=self.ram_progress_bar,
overlays=[self.cpu_progress_bar, self.progress_label],
overlays=[self.cpu_progress_bar],
)
self.player = Player()
self.player_small = PlayerSmall()
self.player_small = PlayerSmall(monitor=monitor)
self.battery = None
if BATTERY["enable"]:
@@ -110,11 +109,18 @@ class StatusBar(Window):
self.microphone = MicrophoneWidget()
# One chip for the ambient system state; attention items (org,
# mail, screen share) stay separate so they can light up.
status_children = [self.microphone, self.progress_bars_overlay]
if self.system_tray:
status_children.append(self.system_tray)
if self.battery:
status_children.append(self.battery)
self.status_container = Box(
name="widgets-container",
spacing=4,
spacing=6,
orientation="h",
children=[self.microphone, self.progress_bars_overlay],
children=status_children,
)
end_container_children = []
@@ -122,17 +128,11 @@ class StatusBar(Window):
if self.org is not None:
end_container_children.append(self.org)
end_container_children.append(self.status_container)
if self.system_tray:
end_container_children.append(self.system_tray)
if self.battery:
end_container_children.append(self.battery)
if self.notmuch:
end_container_children.append(self.notmuch)
end_container_children.append(self.capture)
end_container_children.append(self.status_container)
end_container_children.append(self.date_time)
@@ -155,7 +155,7 @@ class StatusBar(Window):
name="sims-inner",
start_children=Box(
name="start-container",
spacing=6,
spacing=8,
orientation="h",
children=start_container_children,
),
@@ -167,7 +167,7 @@ class StatusBar(Window):
),
end_children=Box(
name="end-container",
spacing=4,
spacing=6,
orientation="h",
children=end_container_children,
),
@@ -218,7 +218,32 @@ class StatusBar(Window):
def update_progress_bars(self, service, cpu_percent, memory_percent):
self.cpu_progress_bar.value = cpu_percent
self.ram_progress_bar.value = memory_percent
self.progress_bars_overlay.set_tooltip_text(
f"CPU {cpu_percent * 100:.0f}% · RAM {memory_percent * 100:.0f}%"
)
def _on_date_time_clicked(self, _button=None):
if self.control_center is not None:
self.control_center.toggle()
class Clock(Box):
"""Bold time + muted date, e.g. `20:26 Mon 22 Jul`."""
def __init__(self, **kwargs):
self._time = Label(name="clock-time")
self._date = Label(name="clock-date")
super().__init__(
name="date-time",
orientation="h",
spacing=8,
children=[self._time, self._date],
**kwargs,
)
invoke_repeater(1000, self._update)
def _update(self) -> bool:
now = time.localtime()
self._time.set_label(time.strftime("%H:%M", now))
self._date.set_label(time.strftime("%a %d %b", now))
return True
+503
View File
@@ -0,0 +1,503 @@
"""Hover card for the bar's compact media player.
A layer-shell popup that drops down below `PlayerSmall` while the pointer
rests on it: large spinning cover, full track metadata, a seekable progress
bar, transport controls (shuffle / previous / play-pause / next / loop) and,
when more than one MPRIS player is around, a row to switch between them.
The card holds no MPRIS state of its own — `PlayerSmall` owns the tracked
players and the active selection and pushes updates in via `set_players`,
`set_active`, `refresh` and `set_cover`.
"""
from collections.abc import Callable
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.stack import Stack
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import GLib, Gdk
from loguru import logger
from sims.services.mpris import MprisPlayer
from sims.utils.artwork import file_key
from sims.widgets.circle_image import CircleImage
CARD_WIDTH = 360
COVER_SIZE = 96
PROGRESS_INTERVAL_MS = 500
PLAY_ICON = "media-playback-start-symbolic"
PAUSE_ICON = "media-playback-pause-symbolic"
PREV_ICON = "media-skip-backward-symbolic"
NEXT_ICON = "media-skip-forward-symbolic"
SHUFFLE_ICON = "media-playlist-shuffle-symbolic"
LOOP_ICON = "media-playlist-repeat-symbolic"
LOOP_TRACK_ICON = "media-playlist-repeat-song-symbolic"
FALLBACK_ICON = "audio-x-generic-symbolic"
LOOP_CYCLE = {"none": "playlist", "playlist": "track", "track": "none"}
def _safe(fn: Callable, default=None):
"""MPRIS properties raise once the underlying player has exited (the
wrapper drops `_player`) and DBus calls can fail at any time; the card
should degrade to defaults instead of throwing out of a GTK callback."""
try:
return fn()
except Exception:
return default
def _format_time(us: int) -> str:
seconds = max(0, int(us / 1_000_000))
hours, rem = divmod(seconds, 3600)
minutes, seconds = divmod(rem, 60)
if hours:
return f"{hours}:{minutes:02}:{seconds:02}"
return f"{minutes}:{seconds:02}"
def _icon_button(name: str, icon: str, size: int = 16, **kwargs) -> Button:
return Button(
name=name,
child=Image(icon_name=icon, icon_size=size),
**kwargs,
)
class MediaCard(Window):
def __init__(
self,
monitor: int | None = None,
on_select_player: Callable[[MprisPlayer], None] | None = None,
):
super().__init__(
name="media-card",
title="sims-media-card",
layer="top",
anchor="top left",
margin=(6, 0, 0, 0),
exclusivity="none",
keyboard_mode="none",
visible=False,
monitor=monitor,
)
self._on_select_player = on_select_player
self._player: MprisPlayer | None = None
self._players: list[MprisPlayer] = []
self._progress_timer_id: int | None = None
self._spin_timer_id: int | None = None
self._seeking = False
self._cover_key: tuple[str, int] | None = None
# Player names whose shuffle / loop writes were rejected; their
# buttons stay disabled so they don't look like they do something.
self._no_shuffle: set[str] = set()
self._no_loop: set[str] = set()
# --- cover -------------------------------------------------------
self.cover = CircleImage(
name="media-card-cover", size=COVER_SIZE, h_align="center", v_align="center"
)
self.cover_fallback = Image(
name="media-card-cover-fallback", icon_name=FALLBACK_ICON, icon_size=48
)
self.cover_stack = Stack(
name="media-card-cover-stack",
transition_type="crossfade",
transition_duration=200,
children=[self.cover_fallback, self.cover],
v_align="center",
)
self.cover_stack.set_size_request(COVER_SIZE, COVER_SIZE)
self.cover_stack.set_visible_child(self.cover_fallback)
# --- metadata ----------------------------------------------------
def meta_label(name: str) -> Label:
label = Label(
name=name, h_align="fill", ellipsization="end", max_chars_width=1
)
label.set_xalign(0.0)
label.set_hexpand(True)
return label
self.player_label = meta_label("media-card-player")
self.title = meta_label("media-card-title")
self.artist = meta_label("media-card-artist")
self.album = meta_label("media-card-album")
meta = Box(
name="media-card-meta",
orientation="v",
spacing=2,
h_expand=True,
v_align="center",
children=[self.player_label, self.title, self.artist, self.album],
)
header = Box(
name="media-card-header",
orientation="h",
spacing=14,
children=[self.cover_stack, meta],
)
# --- progress ----------------------------------------------------
self.progress = Scale(
name="media-card-progress",
min_value=0.0,
max_value=1.0,
value=0.0,
draw_value=False,
h_expand=True,
increments=(0.01, 0.05),
)
self.progress.connect("button-press-event", self._on_seek_press)
self.progress.connect("button-release-event", self._on_seek_release)
# Scrolling over the card shouldn't silently scrub the track.
self.progress.connect("scroll-event", lambda *_: True)
self.time_current = Label(name="media-card-time", label="0:00", h_align="start")
self.time_total = Label(name="media-card-time", label="0:00", h_align="end")
times = Box(orientation="h", h_expand=True)
times.pack_start(self.time_current, False, False, 0)
times.pack_end(self.time_total, False, False, 0)
progress_box = Box(
name="media-card-progress-box",
orientation="v",
spacing=2,
children=[self.progress, times],
)
# --- controls ----------------------------------------------------
self.shuffle_btn = _icon_button(
"media-card-toggle", SHUFFLE_ICON, on_clicked=self._on_shuffle
)
self.prev_btn = _icon_button(
"media-card-btn", PREV_ICON, size=20, on_clicked=self._on_prev
)
self.play_image = Image(icon_name=PLAY_ICON, icon_size=24)
self.play_btn = Button(
name="media-card-play", child=self.play_image, on_clicked=self._on_play_pause
)
self.next_btn = _icon_button(
"media-card-btn", NEXT_ICON, size=20, on_clicked=self._on_next
)
self.loop_image = Image(icon_name=LOOP_ICON, icon_size=16)
self.loop_btn = Button(
name="media-card-toggle", child=self.loop_image, on_clicked=self._on_loop
)
self.shuffle_btn.set_tooltip_text("Shuffle")
self.prev_btn.set_tooltip_text("Previous")
self.next_btn.set_tooltip_text("Next")
self.loop_btn.set_tooltip_text("Repeat")
controls = Box(
name="media-card-controls",
orientation="h",
spacing=10,
h_align="center",
children=[
self.shuffle_btn,
self.prev_btn,
self.play_btn,
self.next_btn,
self.loop_btn,
],
)
# --- player switcher (only shown with >1 player) -----------------
self.players_box = Box(
name="media-card-players", orientation="h", spacing=6, h_align="center"
)
self.players_box.set_no_show_all(True)
self.body = Box(
name="media-card-body",
orientation="v",
spacing=12,
children=[header, progress_box, controls, self.players_box],
)
self.body.set_size_request(CARD_WIDTH, -1)
self.add(self.body)
self.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
self.connect("map", lambda *_: self._on_mapped())
self.connect("unmap", lambda *_: self._on_unmapped())
self.refresh()
# ------------------------------------------------------------------
# public API (driven by PlayerSmall)
# ------------------------------------------------------------------
@property
def player(self) -> MprisPlayer | None:
return self._player
@property
def seeking(self) -> bool:
return self._seeking
def popup(self, x: int) -> None:
"""Show the card with its left edge at `x` (output coordinates)."""
self.margin = (6, 0, 0, max(0, x))
if not self.get_visible():
# Not show_all(): that would resurrect labels/rows refresh() hid.
self.show()
def popdown(self) -> None:
if self.get_visible():
self.hide()
def set_players(self, players: list[MprisPlayer]) -> None:
self._players = list(players)
self._rebuild_player_switcher()
def set_active(self, player: MprisPlayer | None) -> None:
self._player = player
self._seeking = False
self._sync_player_switcher()
self.refresh()
def set_cover(self, path: str | None) -> None:
key = file_key(path)
if key is not None and key == self._cover_key:
return
self._cover_key = None
if key is not None:
try:
self.cover.set_image_from_file(path)
self._cover_key = key
self.cover_stack.set_visible_child(self.cover)
self._update_spin()
return
except Exception as e:
logger.debug(f"[MediaCard] failed to load cover {path}: {e}")
self.cover_stack.set_visible_child(self.cover_fallback)
self._update_spin()
def refresh(self) -> None:
mp = self._player
if mp is None:
self.player_label.set_text("")
self.title.set_text("Nothing playing")
self.artist.set_text("")
self.album.set_text("")
self.artist.set_visible(False)
self.album.set_visible(False)
self.play_image.set_property("icon-name", PLAY_ICON)
for btn in (self.shuffle_btn, self.prev_btn, self.play_btn, self.next_btn, self.loop_btn):
btn.set_sensitive(False)
self.progress.set_sensitive(False)
self.progress.set_value(0.0)
self.time_current.set_text("0:00")
self.time_total.set_text("0:00")
self.cover_stack.set_visible_child(self.cover_fallback)
self._cover_key = None
self._update_spin()
return
name = _safe(lambda: mp.player_name, "") or ""
self.player_label.set_text(name.upper())
title = (_safe(lambda: mp.title, "") or "").strip()
artist = (_safe(lambda: mp.artist, "") or "").strip()
album = (_safe(lambda: mp.album, "") or "").strip()
self.title.set_text(title or "Unknown title")
self.title.set_tooltip_text(title or None)
self.artist.set_text(artist)
self.artist.set_visible(bool(artist))
self.album.set_text(album)
self.album.set_visible(bool(album))
playing = _safe(lambda: mp.playback_status, "") == "playing"
self.play_image.set_property("icon-name", PAUSE_ICON if playing else PLAY_ICON)
self.play_btn.set_tooltip_text("Pause" if playing else "Play")
self.play_btn.set_sensitive(bool(_safe(lambda: mp.can_pause, False)))
self.prev_btn.set_sensitive(bool(_safe(lambda: mp.can_go_previous, False)))
self.next_btn.set_sensitive(bool(_safe(lambda: mp.can_go_next, False)))
self.shuffle_btn.set_sensitive(name not in self._no_shuffle)
self._set_class(self.shuffle_btn, "active", bool(_safe(lambda: mp.shuffle, False)))
loop = _safe(lambda: mp.loop_status, "none")
self.loop_btn.set_sensitive(name not in self._no_loop)
self.loop_image.set_property(
"icon-name", LOOP_TRACK_ICON if loop == "track" else LOOP_ICON
)
self._set_class(self.loop_btn, "active", loop in ("playlist", "track"))
self.loop_btn.set_tooltip_text(
{"playlist": "Repeat: playlist", "track": "Repeat: track"}.get(
loop, "Repeat: off"
)
)
self._update_progress()
self._update_spin()
# ------------------------------------------------------------------
# player switcher
# ------------------------------------------------------------------
def _rebuild_player_switcher(self) -> None:
for child in self.players_box.get_children():
self.players_box.remove(child)
child.destroy()
if len(self._players) < 2:
self.players_box.hide()
return
for mp in self._players:
label = _safe(lambda mp=mp: mp.player_name, "?") or "?"
btn = Button(
name="media-card-player-chip",
child=Label(label=label),
on_clicked=lambda *_, mp=mp: self._select(mp),
)
btn.player = mp
self.players_box.add(btn)
btn.show_all()
self.players_box.show()
self._sync_player_switcher()
def _sync_player_switcher(self) -> None:
for btn in self.players_box.get_children():
self._set_class(btn, "active", getattr(btn, "player", None) is self._player)
def _select(self, mp: MprisPlayer) -> None:
if self._on_select_player is not None:
self._on_select_player(mp)
# ------------------------------------------------------------------
# controls
# ------------------------------------------------------------------
def _on_play_pause(self, *_):
if self._player is not None:
_safe(self._player.play_pause)
def _on_prev(self, *_):
if self._player is not None:
_safe(self._player.previous)
def _on_next(self, *_):
if self._player is not None:
_safe(self._player.next)
def _on_shuffle(self, *_):
mp = self._player
if mp is None:
return
try:
mp.set_shuffle(not mp.shuffle)
except Exception as e:
logger.debug(f"[MediaCard] shuffle unsupported by {mp.player_name}: {e}")
self._no_shuffle.add(mp.player_name)
self.refresh()
def _on_loop(self, *_):
mp = self._player
if mp is None:
return
try:
mp.set_loop(LOOP_CYCLE.get(mp.loop_status, "none"))
except Exception as e:
logger.debug(f"[MediaCard] loop unsupported by {mp.player_name}: {e}")
self._no_loop.add(mp.player_name)
self.refresh()
# ------------------------------------------------------------------
# progress / seeking
# ------------------------------------------------------------------
def _length(self) -> int:
mp = self._player
if mp is None:
return 0
return _safe(lambda: int(mp.length or 0), 0)
def _update_progress(self) -> bool:
mp = self._player
total = self._length()
can_seek = bool(mp is not None and _safe(lambda: mp.can_seek, False))
self.progress.set_sensitive(can_seek and total > 0)
if mp is None or total <= 0:
if not self._seeking:
self.progress.set_value(0.0)
self.time_current.set_text("0:00" if mp is None else "--:--")
self.time_total.set_text("0:00" if mp is None else "--:--")
return True
self.time_total.set_text(_format_time(total))
if self._seeking:
# While dragging, show where the thumb is instead of the player.
self.time_current.set_text(_format_time(self.progress.get_value() * total))
return True
position = _safe(lambda: mp.position, 0) or 0
self.progress.set_value(min(1.0, max(0.0, position / total)))
self.time_current.set_text(_format_time(position))
return True
def _on_seek_press(self, *_):
self._seeking = True
return False
def _on_seek_release(self, *_):
if self._seeking and self._player is not None:
total = self._length()
if total > 0:
target = int(self.progress.get_value() * total)
_safe(lambda: setattr(self._player, "position", target))
self._seeking = False
# Let the scale finish its own release handling before we re-sync.
GLib.timeout_add(150, lambda: (self._update_progress(), False)[1])
return False
# ------------------------------------------------------------------
# timers — only run while the card is on screen
# ------------------------------------------------------------------
def _on_mapped(self):
self._update_progress()
if self._progress_timer_id is None:
self._progress_timer_id = GLib.timeout_add(
PROGRESS_INTERVAL_MS, self._update_progress
)
self._update_spin()
def _on_unmapped(self):
if self._progress_timer_id is not None:
GLib.source_remove(self._progress_timer_id)
self._progress_timer_id = None
self._seeking = False
self._update_spin()
def _update_spin(self):
should_spin = (
self.get_mapped()
and self._player is not None
and self.cover_stack.get_visible_child() is self.cover
and _safe(lambda: self._player.playback_status, "") == "playing"
)
if should_spin and self._spin_timer_id is None:
self._spin_timer_id = GLib.timeout_add(50, self._advance_spin)
elif not should_spin and self._spin_timer_id is not None:
GLib.source_remove(self._spin_timer_id)
self._spin_timer_id = None
def _advance_spin(self):
self.cover.angle = (self.cover.angle + 1) % 360
return True
@staticmethod
def _set_class(widget, cls: str, on: bool) -> None:
ctx = widget.get_style_context()
if on and not ctx.has_class(cls):
ctx.add_class(cls)
elif not on and ctx.has_class(cls):
ctx.remove_class(cls)
-1
View File
@@ -27,7 +27,6 @@ class MicrophoneWidget(Box):
name="microphone-button",
child=self.icon_label,
on_clicked=self.on_clicked,
style="background: transparent; border: none; margin: 0; box-shadow: none;",
)
self.add(self.button)
+176 -38
View File
@@ -12,7 +12,10 @@ from fabric.widgets.image import Image
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from fabric.widgets.eventbox import EventBox
from ..widgets.circle_image import CircleImage
from sims.modules.media_card import MediaCard
from sims.utils.artwork import file_key, resolve_artwork
import sims.modules.icons as icons
from sims.services.mpris import MprisPlayer, get_mpris_player_manager
from fabric import Fabricator
@@ -524,18 +527,41 @@ class Player(Box):
return False
class PlayerSmall(Box):
class PlayerSmall(EventBox):
"""Compact bar pill: spinning cover, title/artist and a play button.
Hovering the pill drops down a `MediaCard` with the full controls; the
card stays open while the pointer is on either the pill or the card.
"""
PLAY_ICON = "media-playback-start-symbolic"
PAUSE_ICON = "media-playback-pause-symbolic"
PREV_ICON = "media-skip-backward-symbolic"
NEXT_ICON = "media-skip-forward-symbolic"
FALLBACK_ICON = "audio-x-generic-symbolic"
COVER_SIZE = 22
# Delay before the card opens (so sweeping across the bar doesn't flash
# it) and before it closes (so the pointer can cross the gap between the
# pill and the card).
CARD_SHOW_DELAY_MS = 250
CARD_HIDE_DELAY_MS = 300
def __init__(self):
def __init__(self, monitor: int | None = None):
super().__init__(
name="player-small", orientation="h", v_align="center"
events=["enter-notify", "leave-notify"], v_align="center"
)
self.pill = Box(name="player-small", orientation="h", v_align="center")
self.add(self.pill)
self._monitor = monitor
self._card: MediaCard | None = None
self._hover_pill = False
self._hover_card = False
self._card_show_id = None
self._card_hide_id = None
self._cover_url = None
self._cover_path = None
self._cover_key = None
self._display_options = ["title", "artist"]
self._display_index = 0
self._current_display = "title"
@@ -621,7 +647,10 @@ class PlayerSmall(Box):
end_children=self.mpris_button,
)
self.add(self.mpris_small)
self.pill.add(self.mpris_small)
self.connect("enter-notify-event", self._on_pill_enter)
self.connect("leave-notify-event", self._on_pill_leave)
self.mpris_manager = get_mpris_player_manager()
self.mpris_player = None
@@ -718,16 +747,23 @@ class PlayerSmall(Box):
self._width_tween_id = GLib.timeout_add(16, tick)
def _set_cover_from_path(self, image_path):
if image_path and os.path.isfile(image_path):
try:
self.cover.set_image_from_file(image_path)
self.cover_stack.set_visible_child(self.cover)
self._update_spin()
return False
except Exception:
pass
self.cover_stack.set_visible_child(self.fallback_icon)
loaded = False
key = file_key(image_path)
if key is not None:
if key == self._cover_key:
loaded = True # already showing it; skip re-decoding
else:
try:
self.cover.set_image_from_file(image_path)
loaded = True
except Exception:
pass
self._cover_path = image_path if loaded else None
self._cover_key = key if loaded else None
self.cover_stack.set_visible_child(self.cover if loaded else self.fallback_icon)
self._update_spin()
if self._card is not None:
self._card.set_cover(self._cover_path)
return False
def _start_spin(self):
@@ -756,40 +792,31 @@ class PlayerSmall(Box):
else:
self._stop_spin()
def _download_artwork(self, arturl):
try:
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl) as response:
data = response.read()
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(data)
tmp.close()
local_path = tmp.name
except Exception:
local_path = None
GLib.idle_add(self._set_cover_from_path, local_path)
return None
def _update_cover(self, mp):
if not mp or not mp.arturl:
self.cover_stack.set_visible_child(self.fallback_icon)
return
parsed = urllib.parse.urlparse(mp.arturl)
if parsed.scheme == "file":
self._set_cover_from_path(urllib.parse.unquote(parsed.path))
elif parsed.scheme in ("http", "https"):
GLib.Thread.new("compact-artwork", self._download_artwork, mp.arturl)
else:
self._set_cover_from_path(mp.arturl)
try:
url = mp.arturl if mp else None
except Exception:
url = None
self._cover_url = url
def done(path):
# The track may have changed while a download was in flight.
if self._cover_url == url:
self._set_cover_from_path(path)
resolve_artwork(url, done)
def _apply_mpris_properties(self):
if not self.mpris_player:
self._set_label_text("")
self.play_image.set_property("icon-name", self.PLAY_ICON)
self.cover_stack.set_visible_child(self.fallback_icon)
self._cover_url = None
self._cover_path = None
self._cover_key = None
self.center_stack.set_visible_child(self.mpris_label)
self._update_spin()
self._sync_card()
return
mp = self.mpris_player
@@ -802,6 +829,113 @@ class PlayerSmall(Box):
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
self._set_label_text(text)
self.center_stack.set_visible_child(self.mpris_label)
self._sync_card()
# ------------------------------------------------------------------
# hover card
# ------------------------------------------------------------------
def _ensure_card(self) -> MediaCard:
if self._card is None:
card = MediaCard(monitor=self._monitor, on_select_player=self._select_player)
card.connect("enter-notify-event", self._on_card_enter)
card.connect("leave-notify-event", self._on_card_leave)
card.set_players(list(self._players.values()))
card.set_active(self.mpris_player)
card.set_cover(self._cover_path)
self.connect("destroy", lambda *_: card.destroy())
self._card = card
return self._card
def _sync_card(self):
card = self._card
if card is None:
return
if card.player is not self.mpris_player:
card.set_active(self.mpris_player)
card.set_cover(self._cover_path)
else:
card.refresh()
if self.mpris_player is None:
card.popdown()
def _select_player(self, mp):
if mp in self._players.values() and mp is not self.mpris_player:
self.mpris_player = mp
self._apply_mpris_properties()
@staticmethod
def _is_grab_crossing(event) -> bool:
# GTK synthesizes crossing events when a widget grabs the pointer
# (e.g. while dragging the card's seek bar); those aren't the pointer
# actually leaving.
return event.mode in (Gdk.CrossingMode.GRAB, Gdk.CrossingMode.GTK_GRAB)
def _on_pill_enter(self, _widget, _event):
self._hover_pill = True
self._cancel_card_hide()
card_open = self._card is not None and self._card.get_visible()
if not card_open and self._card_show_id is None:
self._card_show_id = GLib.timeout_add(
self.CARD_SHOW_DELAY_MS, self._show_card
)
return False
def _on_pill_leave(self, _widget, event):
if event.detail == Gdk.NotifyType.INFERIOR or self._is_grab_crossing(event):
return False
self._hover_pill = False
if self._card_show_id is not None:
GLib.source_remove(self._card_show_id)
self._card_show_id = None
self._schedule_card_hide()
return False
def _on_card_enter(self, _widget, _event):
self._hover_card = True
self._cancel_card_hide()
return False
def _on_card_leave(self, _widget, event):
if event.detail == Gdk.NotifyType.INFERIOR or self._is_grab_crossing(event):
return False
self._hover_card = False
self._schedule_card_hide()
return False
def _show_card(self):
self._card_show_id = None
if not self._hover_pill or not self._players or self.mpris_player is None:
return False
card = self._ensure_card()
res = self.translate_coordinates(self.get_toplevel(), 0, 0)
# PyGObject returns (x, y), or (ok, x, y) depending on version.
x = 0
if res:
x = res[1] if len(res) == 3 else res[0]
card.popup(int(x))
return False
def _schedule_card_hide(self):
if self._card_hide_id is None:
self._card_hide_id = GLib.timeout_add(
self.CARD_HIDE_DELAY_MS, self._maybe_hide_card
)
def _cancel_card_hide(self):
if self._card_hide_id is not None:
GLib.source_remove(self._card_hide_id)
self._card_hide_id = None
def _maybe_hide_card(self):
self._card_hide_id = None
if self._hover_pill or self._hover_card or self._card is None:
return False
if self._card.seeking:
# Pointer slipped off while scrubbing; close once released.
self._schedule_card_hide()
return False
self._card.popdown()
return False
def _on_icon_button_press(self, widget, event):
if event.type != Gdk.EventType.BUTTON_PRESS:
@@ -868,6 +1002,8 @@ class PlayerSmall(Box):
self._players[name] = mp
self._player_handlers[name] = handler_id
self._last_status[name] = mp.playback_status
if self._card is not None:
self._card.set_players(list(self._players.values()))
def _untrack_player(self, name):
mp = self._players.pop(name, None)
@@ -876,6 +1012,8 @@ class PlayerSmall(Box):
if mp and handler_id is not None:
with contextlib.suppress(Exception):
mp.disconnect(handler_id)
if self._card is not None:
self._card.set_players(list(self._players.values()))
def _select_initial_player(self):
for mp in self._players.values():
+13 -253
View File
@@ -60,58 +60,6 @@ def resolve_colors(mode: str | None = None) -> dict[str, str]:
return colors
def is_light(colors: dict[str, str]) -> bool:
"""Whether the palette is a light one, judged by base00's relative
luminance (so light base schemes work without `variants` too)."""
def channel(c: int) -> float:
c = c / 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
hex_bg = colors["base00"].lstrip("#")
r, g, b = (channel(int(hex_bg[i : i + 2], 16)) for i in (0, 2, 4))
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5
# Accent "status pills": (widget selector, child selectors, base16 slot). Later
# entries win over earlier ones at equal specificity, mirroring org.css and
# notmuch.css (debt state overrides unread, in-progress+next overrides both).
ACCENT_PILLS = [
("#org-widget.has-in-progress", ("#org-icon", "#org-count"), "base0E"),
("#org-widget.has-next", ("#org-icon", "#org-count"), "base0D"),
("#org-widget.has-in-progress.has-next", ("#org-icon", "#org-count"), "base0C"),
("#notmuch-widget.has-unread", ("#notmuch-icon", "#unread-count"), "base0D"),
("#notmuch-widget.debt-warn", ("#notmuch-icon", "#unread-count"), "base09"),
("#notmuch-widget.debt-alarm", ("#notmuch-icon", "#unread-count"), "base08"),
]
def accent_pill_css(colors: dict[str, str]) -> str:
"""Org/notmuch alert pills. Dark palettes get a solid accent fill with
background-colored text (pastel accents carry that well); light palettes
get a pale accent tint with accent-colored text, since a solid fill of a
light scheme's saturated accents is far louder than the rest of the bar."""
light = is_light(colors)
rules = []
for widget, children, slot in ACCENT_PILLS:
accent = f"#{colors[slot]}"
if light:
bg, hover_bg, fg = (
f"alpha({accent}, 0.18)",
f"alpha({accent}, 0.30)",
accent,
)
else:
bg, hover_bg, fg = accent, f"shade({accent}, 1.12)", f"#{colors['base00']}"
child_sel = ",\n".join(f"{widget} {c}" for c in children)
rules.append(
f"{widget} {{\n background-color: {bg};\n}}\n\n"
f"{widget}:hover {{\n background-color: {hover_bg};\n}}\n\n"
f"{child_sel} {{\n color: {fg};\n}}\n"
)
return "\n".join(rules)
def generate_stylix_css(mode: str | None = None) -> str | None:
"""Generate CSS using Stylix colors (for `mode`, if variants are set)"""
if not STYLIX.get("enable", False):
@@ -144,129 +92,27 @@ def generate_stylix_css(mode: str | None = None) -> str | None:
{palette}
/* Apply Stylix font */
/* Apply Stylix font. This provider sits in front of main.css and GTK lets
the first provider that sets a property win regardless of selector
specificity, so a blanket `* {{ font-size }}` here would flatten every
size in the bar's own CSS. The bar (window#sims) therefore only gets the
size on its toplevel and inherits it; other windows keep the blanket
rule they were designed against. */
* {{
font-family: "{font_family}", sans-serif;
}}
window,
tooltip {{
font-size: {font_size}px;
}}
/* Bar styling */
#sims-inner {{
padding: 4px;
border-bottom: solid 2px;
border-color: #{colors["base02"]};
background-color: #{colors["base00"]};
border-radius: 0;
transition: border-radius 200ms ease;
}}
#sims-inner.rounded-bottom {{
border-radius: 0 0 28px 28px;
}}
#center-container {{
color: #{colors["base05"]};
}}
.active-window {{
color: #{colors["base05"]};
font-weight: bold;
}}
/* Battery */
#battery-widget {{
background-color: #{colors["base01"]};
padding: 4px 8px;
border-radius: 12px;
}}
#bat-icon {{
color: #{colors["base0D"]};
margin-right: 2px;
}}
#bat-label {{
color: #{colors["base05"]};
window:not(#sims) * {{
font-size: {font_size}px;
}}
#bat-label.battery-low {{
color: #{colors["base08"]};
font-weight: bold;
}}
/* Progress bars */
#cpu-progress-bar,
#ram-progress-bar,
#volume-progress-bar {{
color: transparent;
background-color: transparent;
}}
#cpu-progress-bar {{
border: solid 0px alpha(#{colors["base0E"]}, 0.8);
}}
#ram-progress-bar,
#volume-progress-bar {{
border: solid 0px #{colors["base0D"]};
}}
/* Widgets container */
#widgets-container {{
background-color: #{colors["base01"]};
padding: 2px;
border-radius: 16px;
}}
/* NixOS label */
#nixos-label {{
color: #{colors["base0D"]};
font-size: 20px;
}}
/* CPU/RAM pies: the RAM pie (underneath) draws a faint full-circle track in
`color` so the usage wedge reads as a pie instead of a stray triangle; the
CPU pie on top keeps a transparent track. */
#ram-progress-bar {{
color: alpha(#{colors["base03"]}, 0.4);
}}
/* System tray: match the other bar pills */
#system-tray {{
background-color: #{colors["base01"]};
padding: 2px 6px;
border-radius: 12px;
}}
/* Org / notmuch pills */
#org-widget,
#notmuch-widget {{
background-color: #{colors["base01"]};
}}
#org-widget:hover,
#notmuch-widget:hover {{
background-color: #{colors["base02"]};
}}
{accent_pill_css(colors)}
/* Date time */
#date-time {{
color: #{colors["base05"]};
background-color: #{colors["base01"]};
padding: 4px 8px;
border-radius: 12px;
}}
#date-time-button {{
background: transparent;
border: none;
padding: 0;
margin: 0;
box-shadow: none;
}}
/* Bar widgets are styled purely in the static styles/ css through the palette
above (see styles/colors.css for the semantic tokens). */
/* Generic popup styling */
.popup-window,
@@ -370,92 +216,6 @@ def generate_stylix_css(mode: str | None = None) -> str | None:
opacity: 0.8;
}}
/* Tooltips */
tooltip {{
border: solid 2px;
border-color: #{colors["base02"]};
background-color: #{colors["base00"]};
color: #{colors["base05"]};
border-radius: 16px;
}}
tooltip>* {{
padding: 2px 4px;
}}
/* Workspaces */
#workspaces {{
background-color: #{colors["base01"]};
padding: 6px 6px;
border-radius: 16px;
}}
#workspaces>button {{
background-color: #{colors["base05"]};
}}
#workspaces>button.empty:not(.active):not(.visible) {{
background-color: #{colors["base03"]};
}}
#workspaces>button.visible:not(.active) {{
background-color: #{colors["base0E"]};
}}
#workspaces>button.active {{
background-color: #{colors["base0D"]};
}}
#workspaces>button.urgent {{
background-color: #{colors["base08"]};
}}
/* Workspace shown on a different monitor than this bar — dim it. */
#workspaces>button.foreign {{
opacity: 0.45;
}}
#workspaces>button>label {{
font-size: 0px;
}}
/* Compact MPRIS player */
#player-small {{
background-color: #{colors["base01"]};
padding: 6px;
border-radius: 100px;
}}
#compact-mpris-icon,
#compact-mpris-button {{
background: transparent;
border: none;
padding: 0 4px;
margin: 0;
box-shadow: none;
min-height: 0;
min-width: 0;
}}
#compact-mpris-button-icon,
#compact-mpris-fallback {{
color: #{colors["base05"]};
}}
#compact-mpris-button:hover #compact-mpris-button-icon {{
color: #{colors["base0D"]};
}}
#compact-mpris-fallback {{
opacity: 0.7;
}}
#compact-mpris-label {{
color: #{colors["base05"]};
font-size: {font_size}px;
margin: 0 6px;
}}
/* Quick Menu styling */
#quick-menu-container {{
background-color: #{colors["base00"]};
+18
View File
@@ -116,6 +116,24 @@ class MprisPlayer(Service):
GLib.idle_add(lambda: (setattr(self, "shuffle", not self.shuffle), False))
# else do nothing
def set_shuffle(self, do_shuffle: bool) -> None:
"""Like assigning `shuffle`, but lets DBus errors propagate (GObject
property setters swallow them), so callers can tell when a player
doesn't support shuffling."""
self._player.set_shuffle(do_shuffle)
self.notifier("shuffle")
def set_loop(self, status: str) -> None:
"""Like assigning `loop_status` ("none" / "track" / "playlist"), but
lets DBus errors propagate."""
loop_status = {
"none": Playerctl.LoopStatus.NONE,
"track": Playerctl.LoopStatus.TRACK,
"playlist": Playerctl.LoopStatus.PLAYLIST,
}[status]
self._player.set_loop_status(loop_status)
self.notifier("loop-status")
def play_pause(self):
if self.can_pause:
GLib.idle_add(lambda: (self._player.play_pause(), False))
+150 -81
View File
@@ -1,8 +1,18 @@
/* ------------------------------------------------------------------------
Bar shell
One visual language for every module:
- "chips": --surface fill, same height (bar height minus the 6px
vertical padding below), fully rounded, 10px side padding.
- a single accent color for "this is active" (workspace, play state),
tinted chips for "needs attention", solid fill only for alarms.
- text is foreground / --text-muted, icons are 16px.
------------------------------------------------------------------------ */
#sims-inner {
padding: 4px;
border-bottom: solid 2px;
border-color: var(--border-color);
padding: 6px 12px;
background-color: var(--window-bg);
border-bottom: solid 1px var(--hairline);
border-radius: 0;
transition: border-radius 200ms ease;
}
@@ -11,19 +21,31 @@
border-radius: 0 0 28px 28px;
}
#center-container {
color: var(--foreground);
/* Shared chip look (workspaces.css applies the same to #workspaces) */
#player-small,
#widgets-container,
#org-widget,
#notmuch-widget,
#date-time {
min-height: 28px;
padding: 0 10px;
border-radius: 14px;
background-color: var(--surface);
transition: background-color 150ms ease;
}
.active-window {
color: var(--foreground);
font-weight: bold;
#org-widget:hover,
#notmuch-widget:hover,
#date-time-button:hover #date-time {
background-color: var(--surface-hover);
}
#battery-widget {
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 4px;
/* ---- left ------------------------------------------------------------- */
#nixos-label {
color: var(--accent);
font-size: 18px;
padding: 0 6px 0 2px;
}
#buddy-container,
@@ -34,125 +56,172 @@
#buddy-button {
padding: 0;
margin: 0 2px;
background: transparent;
border: none;
}
#bat-icon {
color: var(--blue);
margin-right: 2px;
/* Compact player chip: circular cover hugging the left edge */
#player-small {
padding: 0 6px 0 3px;
}
#bat-label {
#compact-mpris-icon,
#compact-mpris-button {
padding: 0;
margin: 0;
min-height: 0;
min-width: 0;
}
#compact-mpris-label {
color: var(--foreground);
font-size: 14px;
font-weight: 500;
margin: 0 8px;
}
#bat-label.battery-low {
color: var(--red);
font-weight: bold;
#compact-mpris-fallback {
color: var(--text-muted);
margin: 0 3px;
}
#microphone-icon {
color: var(--foreground);
font-size: 20px;
#compact-mpris-button-icon {
color: var(--text-muted);
transition: color 150ms ease;
}
#compact-mpris-button:hover #compact-mpris-button-icon {
color: var(--accent);
}
/* ---- center ----------------------------------------------------------- */
#active-window,
.active-window {
color: var(--text-muted);
font-weight: 500;
}
/* ---- right ------------------------------------------------------------ */
/* System cluster: mic, cpu/ram rings, tray, battery in one chip */
#widgets-container {
padding: 0 8px;
}
#microphone-button {
min-width: 28px;
padding: 0 6px;
padding: 0 4px;
border-radius: 10px;
}
#microphone-icon {
color: var(--text-muted);
font-size: 16px;
}
#microphone-button:hover #microphone-icon {
color: var(--foreground);
}
#microphone-icon.muted {
color: var(--red);
}
#cpu-progress-bar,
/* CPU / RAM "activity rings": outer ring = RAM, inner ring = CPU.
CircularProgressBar draws its track with `color` and progress with
`border-color`; the border *width* must stay 0 or it inflates the line. */
#system-stats {
padding: 0 4px;
}
#ram-progress-bar,
#volume-progress-bar {
color: transparent;
background-color: transparent
#cpu-progress-bar {
background-color: transparent;
color: alpha(var(--foreground), 0.12);
border: solid 0 transparent;
}
#ram-progress-bar {
border-color: var(--accent);
}
#cpu-progress-bar {
border: solid 0px alpha(var(--violet), 0.8);
border-color: var(--violet);
}
#ram-progress-bar,
#volume-progress-bar {
border: solid 0px var(--blue);
#system-tray {
padding: 0 2px;
}
#widgets-container {
background-color: var(--module-bg);
padding: 2px;
#system-tray > button {
padding: 0 3px;
border-radius: 8px;
}
#player-small {
background-color: var(--module-bg);
padding: 6px;
border-radius: 100px;
#system-tray > button:hover {
background-color: var(--surface-hover);
}
#compact-mpris-icon,
#compact-mpris-button {
background: transparent;
border: none;
padding: 0 4px;
margin: 0;
box-shadow: none;
min-height: 0;
min-width: 0;
#battery-widget {
padding: 0 2px 0 4px;
}
#compact-mpris-button-icon {
#bat-icon {
color: var(--text-muted);
}
#bat-label {
color: var(--foreground);
font-feature-settings: "tnum";
}
#compact-mpris-button:hover #compact-mpris-button-icon {
color: var(--blue);
}
#compact-mpris-fallback {
color: var(--foreground);
opacity: 0.7;
}
#compact-mpris-label {
color: var(--foreground);
font-size: 13px;
}
#nixos-label {
color: var(--blue);
font-size: 20px;
margin: 0 2px 0 4px;
#bat-icon.battery-low,
#bat-label.battery-low {
color: var(--red);
font-weight: bold;
}
/* Screen-share indicator: a bare red dot, only visible while sharing */
#capture-widget {
background: transparent;
border: none;
padding: 0 6px;
margin: 0;
box-shadow: none;
}
#capture-dot {
color: var(--red);
font-size: 14px;
font-size: 12px;
}
#capture-summary {
font-size: 12px;
color: var(--text-muted);
margin-right: 6px;
}
tooltip {
border: solid 2px;
border-color: var(--border-color);
background-color: var(--window-bg);
/* Clock: bold time, muted date */
#date-time-button {
padding: 0;
margin: 0;
}
tooltip>* {
padding: 2px 4px
#date-time {
padding: 0 12px;
}
#clock-time {
font-weight: bold;
font-feature-settings: "tnum";
}
#clock-date {
color: var(--text-muted);
font-feature-settings: "tnum";
}
/* ---- tooltips --------------------------------------------------------- */
tooltip {
background-color: var(--window-bg);
border: solid 1px var(--hairline);
border-radius: 10px;
}
tooltip > * {
padding: 4px 8px;
}
-16
View File
@@ -1,21 +1,5 @@
/* Calendar widget styling */
/* Date time button */
#date-time-button {
background: transparent;
border: none;
padding: 0;
margin: 0;
box-shadow: none;
}
#date-time {
color: var(--foreground);
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 12px;
}
/* Calendar event items */
.event-item {
border-radius: 8px;
+17 -6
View File
@@ -17,13 +17,24 @@
--violet: #AD60FF;
--red: #FC3F2C;
--window-bg: alpha(var(--background), 0.9);
/* Semantic tokens. Surfaces/text are derived from --foreground so they
work on both dark and light palettes (stylix redefines the base
colors at runtime and these follow). */
--accent: var(--blue);
--surface: alpha(var(--foreground), 0.06);
--surface-hover: alpha(var(--foreground), 0.11);
--surface-active: alpha(var(--foreground), 0.16);
--hairline: alpha(var(--foreground), 0.08);
--text-muted: alpha(var(--foreground), 0.62);
--text-faint: alpha(var(--foreground), 0.38);
--window-bg: alpha(var(--background), 0.94);
--module-bg: alpha(var(--mid-bg), 0.8);
--border-color: var(--light-bg);
--ws-active: var(--blue);
--ws-visible: var(--violet);
--ws-inactive: var(--light-grey);
--ws-empty: var(--dark-grey);
--ws-hover: var(--turquoise);
--ws-active: var(--accent);
--ws-visible: alpha(var(--accent), 0.5);
--ws-inactive: alpha(var(--foreground), 0.55);
--ws-empty: alpha(var(--foreground), 0.18);
--ws-hover: alpha(var(--foreground), 0.85);
--ws-urgent: var(--red);
}
+8 -1
View File
@@ -8,12 +8,19 @@
@import url("./notmuch.css");
@import url("./notifications.css");
@import url("./org.css");
@import url("./media.css");
/* unset so we can style everything from the ground up. */
/* unset so we can style everything from the ground up. `all: unset`
makes font-size/font-family inherit, so the base font is set once on the
toplevels and any widget rule (e.g. icon sizes) can still override it. */
* {
all: unset;
color: var(--foreground);
}
window,
tooltip {
font-size: 16px;
font-family: "Jost*", sans-serif;
}
+143
View File
@@ -0,0 +1,143 @@
/* Media hover card (drops down from the bar's compact player) */
#media-card {
background-color: transparent;
}
#media-card-body {
background-color: var(--window-bg);
border: solid 2px var(--border-color);
border-radius: 20px;
padding: 16px 18px 14px 18px;
}
#media-card-cover-fallback {
color: var(--light-grey);
background-color: var(--module-bg);
border-radius: 100px;
}
#media-card-player {
color: var(--light-grey);
font-size: 11px;
font-weight: bold;
letter-spacing: 1px;
}
#media-card-title {
font-size: 17px;
font-weight: bold;
}
#media-card-artist {
font-size: 14px;
color: var(--mid-fg);
}
#media-card-album {
font-size: 12px;
color: var(--light-grey);
}
/* Seek bar */
#media-card-progress {
min-height: 14px;
}
#media-card-progress trough {
min-height: 4px;
border-radius: 4px;
background-color: var(--light-bg);
}
#media-card-progress highlight {
min-height: 4px;
border-radius: 4px;
background-color: var(--blue);
}
#media-card-progress slider {
min-width: 12px;
min-height: 12px;
margin: -4px;
border-radius: 100px;
background-color: var(--foreground);
}
#media-card-progress:disabled highlight {
background-color: var(--dark-grey);
}
#media-card-time {
font-size: 11px;
color: var(--light-grey);
}
/* Transport controls */
#media-card-btn,
#media-card-toggle {
padding: 6px;
border-radius: 100px;
transition: background-color 150ms ease;
}
#media-card-btn:hover,
#media-card-toggle:hover {
background-color: var(--light-bg);
}
/* `* { color }` in main.css sets every widget's color directly, so icons
have to be targeted themselves rather than inheriting from the button. */
#media-card-toggle image {
color: var(--light-grey);
}
#media-card-toggle.active image {
color: var(--blue);
}
#media-card-btn:disabled,
#media-card-toggle:disabled,
#media-card-play:disabled {
opacity: 0.35;
}
#media-card-play {
padding: 10px;
border-radius: 100px;
background-color: var(--blue);
transition: background-color 150ms ease;
}
#media-card-play image {
color: var(--background);
}
#media-card-play:hover {
background-color: var(--turquoise);
}
/* Player switcher (only shown with more than one player) */
#media-card-player-chip {
padding: 2px 10px;
border-radius: 100px;
background-color: var(--module-bg);
transition: background-color 150ms ease;
}
#media-card-player-chip label {
font-size: 12px;
color: var(--light-grey);
}
#media-card-player-chip:hover {
background-color: var(--light-bg);
}
#media-card-player-chip.active {
background-color: var(--light-bg);
}
#media-card-player-chip.active label {
color: var(--foreground);
}
+19 -27
View File
@@ -1,38 +1,30 @@
#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) */
/* Context menus (system tray) */
menu {
border: solid 2px;
border-radius: 10px;
border-color: var(--border-color);
padding: 4px;
border: solid 1px var(--hairline);
border-radius: 12px;
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 {
padding: 6px 10px;
border-radius: 8px;
}
menu>menuitem:first-child {
margin-top: 1px;
border-radius: 8px 8px 0px 0px;
menu > menuitem:hover {
background-color: alpha(var(--accent), 0.18);
}
menu>menuitem:last-child {
margin-bottom: 1px;
border-radius: 0px 0px 8px 8px;
menu > menuitem:hover > label {
color: var(--accent);
}
menu>menuitem:hover {
background-color: var(--pink);
menu > menuitem:disabled > label {
color: var(--text-faint);
}
menu > separator {
min-height: 1px;
margin: 4px 6px;
background-color: var(--hairline);
}
+24 -27
View File
@@ -1,34 +1,42 @@
/* Notmuch email widget styling */
/* Notmuch unread badge. Base chip look lives in bar.css.
Calm by default; tinted when there is unread mail, solid red only when
the email debt timer is in alarm. */
#notmuch-widget {
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 12px;
transition: background-color 0.2s ease;
#notmuch-icon {
color: var(--text-muted);
}
#notmuch-widget:hover {
background-color: var(--light-bg);
#unread-count {
color: var(--foreground);
font-weight: bold;
font-feature-settings: "tnum";
min-width: 16px;
}
#notmuch-widget.has-unread {
background-color: var(--blue);
background-color: alpha(var(--accent), 0.18);
}
#notmuch-widget.has-unread:hover {
background-color: var(--turquoise);
background-color: alpha(var(--accent), 0.28);
}
#notmuch-widget.no-unread {
background-color: var(--module-bg);
#notmuch-widget.has-unread #notmuch-icon,
#notmuch-widget.has-unread #unread-count {
color: var(--accent);
}
#notmuch-widget.debt-warn {
background-color: var(--orange);
background-color: alpha(var(--orange), 0.2);
}
#notmuch-widget.debt-warn:hover {
background-color: var(--gold);
background-color: alpha(var(--orange), 0.3);
}
#notmuch-widget.debt-warn #notmuch-icon,
#notmuch-widget.debt-warn #unread-count {
color: var(--orange);
}
#notmuch-widget.debt-alarm {
@@ -36,21 +44,10 @@
}
#notmuch-widget.debt-alarm:hover {
background-color: var(--pink);
background-color: shade(var(--red), 1.1);
}
#unread-count {
color: var(--foreground);
font-size: 14px;
font-weight: bold;
min-width: 16px;
}
#notmuch-widget.has-unread #notmuch-icon,
#notmuch-widget.has-unread #unread-count,
#notmuch-widget.debt-warn #notmuch-icon,
#notmuch-widget.debt-warn #unread-count,
#notmuch-widget.debt-alarm #notmuch-icon,
#notmuch-widget.debt-alarm #unread-count {
color: var(--background);
}
}
+22 -27
View File
@@ -1,48 +1,43 @@
/* Org-agenda bar widget + dropdown */
/* Org-agenda bar widget + dropdown. Base chip look lives in bar.css. */
#org-widget {
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 12px;
transition: background-color 0.2s ease;
#org-icon {
color: var(--text-muted);
}
#org-widget:hover {
background-color: var(--light-bg);
#org-count {
font-weight: bold;
font-feature-settings: "tnum";
min-width: 16px;
}
#org-widget.empty {
background-color: var(--module-bg);
opacity: 0.6;
}
#org-widget.has-next {
background-color: var(--blue);
background-color: alpha(var(--accent), 0.18);
}
#org-widget.has-next:hover {
background-color: var(--turquoise);
}
#org-widget.has-in-progress {
background-color: var(--violet);
}
#org-widget.has-in-progress.has-next {
background-color: var(--turquoise);
}
#org-count {
font-size: 14px;
font-weight: bold;
min-width: 16px;
background-color: alpha(var(--accent), 0.28);
}
#org-widget.has-next #org-icon,
#org-widget.has-next #org-count,
#org-widget.has-next #org-count {
color: var(--accent);
}
#org-widget.has-in-progress {
background-color: alpha(var(--violet), 0.2);
}
#org-widget.has-in-progress:hover {
background-color: alpha(var(--violet), 0.3);
}
#org-widget.has-in-progress #org-icon,
#org-widget.has-in-progress #org-count {
color: var(--background);
color: var(--violet);
}
/* Dropdown */
+25 -21
View File
@@ -1,50 +1,54 @@
/* Workspace rail: small dots, the focused one stretches into an accent pill.
The chip is 28px tall; 10px vertical padding leaves 8px dots. */
#workspaces {
padding: 6px;
min-width: 0px;
background-color: var(--module-bg);
min-height: 8px;
padding: 10px 12px;
border-radius: 14px;
background-color: var(--surface);
}
#workspaces>button {
padding: 8px;
transition: padding 0.05s steps(8), background-color 0.15s ease;
#workspaces > button {
min-width: 8px;
min-height: 8px;
border-radius: 99px;
background-color: var(--ws-inactive);
border-radius: 100px;
transition: min-width 200ms cubic-bezier(0.4, 0, 0.2, 1),
background-color 150ms ease;
}
#workspaces>button>label {
#workspaces > button > label {
font-size: 0px;
}
#workspaces>button:hover {
background-color: var(--ws-hover);
}
#workspaces>button.empty {
#workspaces > button.empty {
background-color: var(--ws-empty);
}
#workspaces>button.visible {
#workspaces > button.visible {
background-color: var(--ws-visible);
}
#workspaces>button.active {
padding: 8px 32px;
#workspaces > button:hover {
background-color: var(--ws-hover);
}
#workspaces > button.active {
min-width: 24px;
background-color: var(--ws-active);
}
#workspaces>button.urgent {
#workspaces > button.urgent {
background-color: var(--ws-urgent);
color: var(--foreground);
font-weight: bold;
animation: urgent-blink 1s infinite;
}
#workspaces>button.foreign {
/* Workspace shown on a different monitor than this bar */
#workspaces > button.foreign {
opacity: 0.45;
}
@keyframes urgent-blink {
0% { opacity: 1.0; }
50% { opacity: 0.5; }
50% { opacity: 0.4; }
100% { opacity: 1.0; }
}
+95
View File
@@ -0,0 +1,95 @@
"""Resolve MPRIS `mpris:artUrl` values to local file paths.
Remote artwork (Spotify, browsers) is downloaded once per URL on a worker
thread and cached for the lifetime of the process, so every widget showing
the cover — the bar pill and the hover card — shares a single download
instead of re-fetching on every MPRIS `changed` emission.
"""
import os
import tempfile
import urllib.parse
import urllib.request
from collections.abc import Callable
from gi.repository import GLib
from loguru import logger
_CACHE_LIMIT = 32
_cache: dict[str, str | None] = {}
# url -> callbacks waiting for an in-flight download of that url
_pending: dict[str, list[Callable[[str | None], None]]] = {}
def _remember(url: str, path: str | None) -> None:
_cache[url] = path
while len(_cache) > _CACHE_LIMIT:
old_url = next(iter(_cache))
old_path = _cache.pop(old_url)
if old_path and old_path.startswith(tempfile.gettempdir()):
try:
os.unlink(old_path)
except OSError:
pass
def _download(url: str) -> None:
path: str | None = None
try:
suffix = os.path.splitext(urllib.parse.urlparse(url).path)[1] or ".png"
with urllib.request.urlopen(url, timeout=10) as response:
data = response.read()
with tempfile.NamedTemporaryFile(
delete=False, prefix="sims-art-", suffix=suffix
) as tmp:
tmp.write(data)
path = tmp.name
except Exception as e:
logger.debug(f"[Artwork] download failed for {url}: {e}")
def finish():
_remember(url, path)
for cb in _pending.pop(url, []):
cb(path)
return False
GLib.idle_add(finish)
def file_key(path: str | None) -> tuple[str, int] | None:
"""`(path, mtime)` for an existing file, else None. Some players rewrite
their artwork in place at a fixed path, so widgets compare this rather
than the bare path to decide whether the cover needs reloading."""
if not path:
return None
try:
return (path, os.stat(path).st_mtime_ns)
except OSError:
return None
def resolve_artwork(
arturl: str | None, callback: Callable[[str | None], None]
) -> None:
"""Call `callback(path_or_None)` on the main loop with a local file for
`arturl`. Local files resolve synchronously; http(s) URLs resolve after a
(cached) background download."""
if not arturl:
callback(None)
return
parsed = urllib.parse.urlparse(arturl)
if parsed.scheme in ("http", "https"):
if arturl in _cache:
callback(_cache[arturl])
return
waiters = _pending.get(arturl)
if waiters is not None:
waiters.append(callback)
return
_pending[arturl] = [callback]
GLib.Thread.new("sims-artwork", _download, arturl)
return
path = urllib.parse.unquote(parsed.path) if parsed.scheme == "file" else arturl
callback(path if path and os.path.isfile(path) else None)