Compare commits
2
Commits
a56d4d1c20
...
377889db8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377889db8c | ||
|
|
a890604001 |
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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"]};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user