better calendar styling

This commit is contained in:
2025-09-29 17:00:53 +02:00
parent dd6feb4170
commit 0b3ee96ccf
4 changed files with 123 additions and 328 deletions

View File

@@ -35,13 +35,19 @@ class CalendarService:
"""Start periodic event updates""" """Start periodic event updates"""
if self._timer_id is None: if self._timer_id is None:
from fabric.utils import invoke_repeater from fabric.utils import invoke_repeater
self._timer_id = invoke_repeater(self._update_interval, self._periodic_update)
logger.info(f"[Calendar] Started periodic updates every {self._update_interval/1000/60:.1f} minutes") self._timer_id = invoke_repeater(
self._update_interval, self._periodic_update
)
logger.info(
f"[Calendar] Started periodic updates every {self._update_interval/1000/60:.1f} minutes"
)
def stop_monitoring(self): def stop_monitoring(self):
"""Stop periodic event updates""" """Stop periodic event updates"""
if self._timer_id is not None: if self._timer_id is not None:
from gi.repository import GLib from gi.repository import GLib
GLib.source_remove(self._timer_id) GLib.source_remove(self._timer_id)
self._timer_id = None self._timer_id = None
logger.info("[Calendar] Stopped periodic updates") logger.info("[Calendar] Stopped periodic updates")
@@ -60,14 +66,26 @@ class CalendarService:
"""Fetch today's events from khal""" """Fetch today's events from khal"""
try: try:
result = subprocess.run( result = subprocess.run(
["khal", "list", "--json", "title", "--json", "start", "--json", "end", "--json", "location", "today"], [
"khal",
"list",
"--json",
"title",
"--json",
"start",
"--json",
"end",
"--json",
"location",
"today",
],
capture_output=True, capture_output=True,
text=True, text=True,
check=True check=True,
) )
if result.stdout.strip(): if result.stdout.strip():
lines = result.stdout.strip().split('\n') lines = result.stdout.strip().split("\n")
all_events = [] all_events = []
for line in lines: for line in lines:
@@ -87,9 +105,15 @@ class CalendarService:
upcoming_events = [] upcoming_events = []
for event in all_events: for event in all_events:
event_date = event.get("start", "").split()[0] if event.get("start") else "" event_date = (
event_start_time = event.get("start", "").split()[1] if event.get("start") else "" event.get("start", "").split()[0] if event.get("start") else ""
event_end_time = event.get("end", "").split()[1] if event.get("end") else "" )
event_start_time = (
event.get("start", "").split()[1] if event.get("start") else ""
)
event_end_time = (
event.get("end", "").split()[1] if event.get("end") else ""
)
# Only process events from today # Only process events from today
if event_date == current_date: if event_date == current_date:
@@ -111,7 +135,9 @@ class CalendarService:
self.events = selected_past + selected_upcoming self.events = selected_past + selected_upcoming
logger.info(f"[Calendar] Found {len(self.events)} upcoming events") logger.info(f"[Calendar] Found {len(self.events)} upcoming events")
for i, event in enumerate(self.events): for i, event in enumerate(self.events):
logger.info(f"[Calendar] Event {i+1}: {event.get('title', 'No title')} at {event.get('start', 'No time')}") logger.info(
f"[Calendar] Event {i+1}: {event.get('title', 'No title')} at {event.get('start', 'No time')}"
)
self.emit_events_changed(self.events) self.emit_events_changed(self.events)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
@@ -128,37 +154,33 @@ class CalendarPopup(Window):
name="calendar-popup", name="calendar-popup",
layer="top", layer="top",
anchor="top right", anchor="top right",
margin="40px 10px 0px 0px", margin="10px 10px 0px 0px", # Just a few pixels under the bar
exclusivity="none", exclusivity="none",
visible=False, visible=False,
all_visible=False, all_visible=False,
**kwargs **kwargs,
) )
# Events container # Events container
self.events_box = Box( self.events_box = Box(
name="events-box", name="events-box",
orientation="v", orientation="v",
spacing=4, spacing=6,
style="min-width: 300px; min-height: 100px;" style="min-width: 450px; min-height: 200px;",
) )
# Add a test label to make sure popup is working # Add a test label to make sure popup is working
test_label = Label( test_label = Label("Calendar Events", name="calendar-title")
"Calendar Events",
name="calendar-title"
)
container = Box( container = Box(
orientation="v", orientation="v", spacing=4, children=[test_label, self.events_box]
spacing=4,
children=[test_label, self.events_box]
) )
self.children = container self.children = container
# Set explicit size # Set explicit size - much bigger
self.set_size_request(320, 200) self.set_size_request(500, 400)
def update_events_display(self, events): def update_events_display(self, events):
"""Update the events display""" """Update the events display"""
@@ -169,10 +191,7 @@ class CalendarPopup(Window):
if not events: if not events:
logger.info("[Calendar] No events, showing 'no events' message") logger.info("[Calendar] No events, showing 'no events' message")
no_events_label = Label( no_events_label = Label("No upcoming events today", name="no-events")
"No upcoming events today",
name="no-events"
)
self.events_box.add(no_events_label) self.events_box.add(no_events_label)
return return
@@ -197,15 +216,33 @@ class CalendarPopup(Window):
elif start_time: elif start_time:
time_str = start_time time_str = start_time
logger.info(f"[Calendar] Creating widget for: {title} ({time_str}) - {'Past' if is_past else 'Upcoming'}") logger.info(
f"[Calendar] Creating widget for: {title} ({time_str}) - {'Past' if is_past else 'Upcoming'}"
)
# Create event item with CSS classes for theming # Create event item with horizontal layout - time on left, content on right
event_status = "past" if is_past else "upcoming" event_status = "past" if is_past else "upcoming"
event_box = Box( event_box = Box(
name="event-item", name="event-item",
orientation="h", # Horizontal layout
spacing=12,
style_classes=[f"event-item", event_status],
)
# Left side: Time display (fixed width for alignment)
time_display = time_str if time_str else "All day"
time_label = Label(
time_display,
name="event-time",
style_classes=["event-time", event_status],
style="min-width: 100px;" # Fixed width for consistent alignment
)
# Right side: Content (title and location)
content_box = Box(
name="event-content",
orientation="v", orientation="v",
spacing=2, spacing=2
style_classes=[f"event-item", event_status]
) )
# Title with status prefix # Title with status prefix
@@ -213,25 +250,21 @@ class CalendarPopup(Window):
title_label = Label( title_label = Label(
f"{title_prefix}{title}", f"{title_prefix}{title}",
name="event-title", name="event-title",
style_classes=["event-title", event_status] style_classes=["event-title", event_status],
) )
event_box.add(title_label) content_box.add(title_label)
if time_str:
time_label = Label(
time_str,
name="event-time",
style_classes=["event-time", event_status]
)
event_box.add(time_label)
if location: if location:
location_label = Label( location_label = Label(
f"📍 {location}", f"📍 {location}",
name="event-location", name="event-location",
style_classes=["event-location", event_status] style_classes=["event-location", event_status],
) )
event_box.add(location_label) content_box.add(location_label)
# Add time and content to the main event box
event_box.add(time_label)
event_box.add(content_box)
self.events_box.add(event_box) self.events_box.add(event_box)
logger.info(f"[Calendar] Added event widget to events_box") logger.info(f"[Calendar] Added event widget to events_box")
@@ -247,7 +280,7 @@ class CalendarWidget(Button):
name="calendar-widget", name="calendar-widget",
child=Image(icon_name="x-office-calendar-symbolic", icon_size=16), child=Image(icon_name="x-office-calendar-symbolic", icon_size=16),
on_clicked=self.toggle_events, on_clicked=self.toggle_events,
**kwargs **kwargs,
) )
self.service = CalendarService() self.service = CalendarService()

View File

@@ -1,274 +0,0 @@
import os
import struct
import subprocess
import re
import ctypes
import signal
from gi.repository import GLib, Gtk, Gdk
from loguru import logger
from math import pi
from fabric.widgets.overlay import Overlay
from fabric.utils.helpers import get_relative_path
import configparser
def get_bars(file_path):
config = configparser.ConfigParser()
config.read(file_path)
return int(config["general"]["bars"])
CAVA_CONFIG = get_relative_path("../config/cavalcade/cava.ini")
bars = get_bars(CAVA_CONFIG)
def set_death_signal():
"""
Set the death signal of the child process to SIGTERM so that if the parent
process is killed, the child (cava) is automatically terminated.
"""
libc = ctypes.CDLL("libc.so.6")
PR_SET_PDEATHSIG = 1
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
class Cava:
"""
CAVA wrapper.
Launch cava process with certain settings and read output.
"""
NONE = 0
RUNNING = 1
RESTARTING = 2
CLOSING = 3
def __init__(self, mainapp):
self.bars = bars
self.path = "/tmp/cava.fifo"
self.cava_config_file = CAVA_CONFIG
self.data_handler = mainapp.draw.update
self.command = ["cava", "-p", self.cava_config_file]
self.state = self.NONE
self.env = dict(os.environ)
self.env["LC_ALL"] = "en_US.UTF-8" # not sure if it's necessary
is_16bit = True
self.byte_type, self.byte_size, self.byte_norm = (
("H", 2, 65535) if is_16bit else ("B", 1, 255)
)
if not os.path.exists(self.path):
os.mkfifo(self.path)
self.fifo_fd = None
self.fifo_dummy_fd = None
self.io_watch_id = None
def _run_process(self):
logger.debug("Launching cava process...")
try:
self.process = subprocess.Popen(
self.command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=self.env,
preexec_fn=set_death_signal, # Ensure cava gets killed when the parent dies.
)
logger.debug("cava successfully launched!")
self.state = self.RUNNING
except Exception:
logger.exception("Fail to launch cava")
def _start_io_reader(self):
logger.debug("Activating GLib IO watch for cava stream handler")
# Open FIFO in non-blocking mode for reading
self.fifo_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
# Open dummy write end to prevent getting an EOF on our FIFO
self.fifo_dummy_fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)
self.io_watch_id = GLib.io_add_watch(
self.fifo_fd, GLib.IO_IN, self._io_callback
)
def _io_callback(self, source, condition):
chunk = self.byte_size * self.bars # number of bytes for given format
try:
data = os.read(self.fifo_fd, chunk)
except OSError as e:
# logger.error("Error reading FIFO: {}".format(e))
return False
# When no data is read, do not remove the IO watch immediately.
if len(data) < chunk:
# Instead of closing the FIFO, we log a warning and continue.
# logger.warning("Incomplete data packet received (expected {} bytes, got {}). Waiting for more data...".format(chunk, len(data)))
# Returning True keeps the IO watch active. A real EOF will only occur when the writer closes.
return True
fmt = self.byte_type * self.bars # format string for struct.unpack
sample = [i / self.byte_norm for i in struct.unpack(fmt, data)]
GLib.idle_add(self.data_handler, sample)
return True
def _on_stop(self):
logger.debug("Cava stream handler deactivated")
if self.state == self.RESTARTING:
self.start()
elif self.state == self.RUNNING:
self.state = self.NONE
logger.error("Cava process was unexpectedly terminated.")
# self.restart() # May cause infinity loop, need more check
def start(self):
"""Launch cava"""
self._start_io_reader()
self._run_process()
def restart(self):
"""Restart cava process"""
if self.state == self.RUNNING:
logger.debug("Restarting cava process (normal mode) ...")
self.state = self.RESTARTING
if self.process.poll() is None:
self.process.kill()
elif self.state == self.NONE:
logger.warning("Restarting cava process (after crash) ...")
self.start()
def close(self):
"""Stop cava process"""
self.state = self.CLOSING
if self.process.poll() is None:
self.process.kill()
if self.io_watch_id:
GLib.source_remove(self.io_watch_id)
if self.fifo_fd:
os.close(self.fifo_fd)
if self.fifo_dummy_fd:
os.close(self.fifo_dummy_fd)
if os.path.exists(self.path):
os.remove(self.path)
class AttributeDict(dict):
"""Dictionary with keys as attributes. Does nothing but easy reading"""
def __getattr__(self, attr):
return self.get(attr, 3)
def __setattr__(self, attr, value):
self[attr] = value
class Spectrum:
"""Spectrum drawing"""
def __init__(self):
self.silence_value = 0
self.audio_sample = []
self.color = None
self.area = Gtk.DrawingArea()
self.area.connect("draw", self.redraw)
self.area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.sizes = AttributeDict()
self.sizes.area = AttributeDict()
self.sizes.bar = AttributeDict()
self.silence = 10
self.max_height = 12
self.area.connect("configure-event", self.size_update)
self.color_update()
def is_silence(self, value):
"""Check if volume level critically low during last iterations"""
self.silence_value = 0 if value > 0 else self.silence_value + 1
return self.silence_value > self.silence
def update(self, data):
"""Audio data processing"""
self.color_update()
self.audio_sample = data
if not self.is_silence(self.audio_sample[0]):
self.area.queue_draw()
elif self.silence_value == (self.silence + 1):
self.audio_sample = [0] * self.sizes.number
self.area.queue_draw()
def redraw(self, widget, cr):
"""Draw spectrum graph"""
cr.set_source_rgba(*self.color)
dx = 3
center_y = self.sizes.area.height / 2 # center vertical of the drawing area
for i, value in enumerate(self.audio_sample):
width = self.sizes.area.width / self.sizes.number - self.sizes.padding
radius = width / 2
height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2
if height == self.sizes.zero / 2 + 1:
height *= 0.5
height = min(height, self.max_height)
# Draw rectangle and arcs for rounded ends
cr.rectangle(dx, center_y - height, width, height * 2)
cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi)
cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi)
cr.close_path()
dx += width + self.sizes.padding
cr.fill()
def size_update(self, *args):
"""Update drawing geometry"""
self.sizes.number = bars
self.sizes.padding = 100 / bars
self.sizes.zero = 0
self.sizes.area.width = self.area.get_allocated_width()
self.sizes.area.height = self.area.get_allocated_height() - 2
tw = self.sizes.area.width - self.sizes.padding * (self.sizes.number - 1)
self.sizes.bar.width = max(int(tw / self.sizes.number), 1)
self.sizes.bar.height = self.sizes.area.height
def color_update(self):
"""Set drawing color according to current settings by reading primary color from CSS"""
color = "#a5c8ff" # default value
try:
with open(get_relative_path("../styles/colors.css"), "r") as f:
content = f.read()
m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
if m:
color = m.group(1)
except Exception as e:
logger.error("Failed to read primary color: {}".format(e))
red = int(color[1:3], 16) / 255
green = int(color[3:5], 16) / 255
blue = int(color[5:7], 16) / 255
self.color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
class SpectrumRender:
def __init__(self, mode=None, **kwargs):
super().__init__(**kwargs)
self.mode = mode
self.draw = Spectrum()
self.cava = Cava(self)
self.cava.start()
def get_spectrum_box(self):
# Get the spectrum box
box = Overlay(name="cavalcade", h_align="center", v_align="center")
box.set_size_request(180, 40)
box.add_overlay(self.draw.area)
return box

View File

@@ -147,7 +147,20 @@ def generate_stylix_css():
#calendar-popup {{ #calendar-popup {{
background-color: #{colors["base00"]}; background-color: #{colors["base00"]};
border: solid 2px #{colors["base02"]}; border: solid 2px #{colors["base02"]};
border-radius: 8px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: slide-down 200ms ease-out;
}}
@keyframes slide-down {{
from {{
opacity: 0;
margin-top: -20px;
}}
to {{
opacity: 1;
margin-top: 10px;
}}
}} }}
#calendar-title {{ #calendar-title {{
@@ -161,7 +174,7 @@ def generate_stylix_css():
background-color: #{colors["base00"]}; background-color: #{colors["base00"]};
border: solid 1px #{colors["base02"]}; border: solid 1px #{colors["base02"]};
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 16px;
}} }}
#no-events {{ #no-events {{
@@ -170,9 +183,14 @@ def generate_stylix_css():
/* Calendar event items */ /* Calendar event items */
.event-item {{ .event-item {{
border-radius: 4px; border-radius: 6px;
padding: 6px; padding: 8px 12px;
margin: 2px 0px; margin: 4px 0px;
transition: background-color 0.15s ease;
}}
#event-content {{
margin-left: 8px;
}} }}
.event-item.upcoming {{ .event-item.upcoming {{

View File

@@ -13,7 +13,20 @@
#calendar-popup { #calendar-popup {
background-color: var(--background-alt); background-color: var(--background-alt);
border: solid 2px var(--surface); border: solid 2px var(--surface);
border-radius: 8px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: slide-down 200ms ease-out;
}
@keyframes slide-down {
from {
opacity: 0;
margin-top: -20px;
}
to {
opacity: 1;
margin-top: 10px;
}
} }
#calendar-title { #calendar-title {
@@ -27,7 +40,7 @@
background-color: var(--background-alt); background-color: var(--background-alt);
border: solid 1px var(--surface); border: solid 1px var(--surface);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 16px;
} }
#no-events { #no-events {
@@ -38,9 +51,14 @@
/* Calendar event items */ /* Calendar event items */
.event-item { .event-item {
border-radius: 4px; border-radius: 6px;
padding: 6px; padding: 8px 12px;
margin: 2px 0px; margin: 4px 0px;
transition: background-color 0.15s ease;
}
#event-content {
margin-left: 8px;
} }
.event-item.upcoming { .event-item.upcoming {