feat: control center

This commit is contained in:
2026-05-05 00:19:10 +02:00
parent 4a271ac4d8
commit 47e104465e
9 changed files with 478 additions and 835 deletions

View File

@@ -25,10 +25,12 @@ from .modules.launcher.clipboard import ClipboardMenu
from .modules.launcher.power import PowerMenu from .modules.launcher.power import PowerMenu
from .modules.launcher.screenrec import ScreenrecMenu from .modules.launcher.screenrec import ScreenrecMenu
from .modules.launcher.screenshot import ScreenshotMenu from .modules.launcher.screenshot import ScreenshotMenu
from .modules.notification_center import NotificationCenter from .modules.calendar import CalendarService
from .modules.control_center import ControlCenter
from .modules.notifications import NotificationToasts from .modules.notifications import NotificationToasts
from .modules.stylix import get_stylix_css_path from .modules.stylix import get_stylix_css_path
from .config import NOTIFICATIONS, POWER, SCREENREC, STYLIX from .modules.vinyl import VinylButton
from .config import CALENDAR, NOTIFICATIONS, POWER, SCREENREC, STYLIX, VINYL
from .services.fenster import get_i3_connection from .services.fenster import get_i3_connection
from .services.notification_history import NotificationHistoryService from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService from .services.screenrec import ScreenrecService
@@ -57,7 +59,6 @@ if SCREENREC.get("enable", False):
notifications_service: Notifications | None = None notifications_service: Notifications | None = None
notification_history: NotificationHistoryService | None = None notification_history: NotificationHistoryService | None = None
notification_toasts: NotificationToasts | None = None notification_toasts: NotificationToasts | None = None
notification_center: NotificationCenter | None = None
if NOTIFICATIONS.get("enable", False): if NOTIFICATIONS.get("enable", False):
notifications_service = Notifications() notifications_service = Notifications()
notification_history = NotificationHistoryService( notification_history = NotificationHistoryService(
@@ -73,8 +74,18 @@ if NOTIFICATIONS.get("enable", False):
width=NOTIFICATIONS.get("width", 360), width=NOTIFICATIONS.get("width", 360),
timeout_ms=NOTIFICATIONS.get("timeout_ms", 10_000), timeout_ms=NOTIFICATIONS.get("timeout_ms", 10_000),
) )
notification_center = NotificationCenter(
notification_history, vinyl_button: VinylButton | None = VinylButton() if VINYL.get("enable", False) else None
calendar_service: CalendarService | None = (
CalendarService(update_interval=120000) if CALENDAR.get("enable", True) else None
)
control_center: ControlCenter | None = None
if notification_history is not None:
control_center = ControlCenter(
history=notification_history,
calendar_service=calendar_service,
vinyl_button=vinyl_button,
monitor=0, monitor=0,
width=NOTIFICATIONS.get("center_width", 380), width=NOTIFICATIONS.get("center_width", 380),
) )
@@ -87,8 +98,8 @@ if screenrec_menu is not None:
_app_windows.append(screenrec_menu) _app_windows.append(screenrec_menu)
if notification_toasts is not None: if notification_toasts is not None:
_app_windows.append(notification_toasts) _app_windows.append(notification_toasts)
if notification_center is not None: if control_center is not None:
_app_windows.append(notification_center) _app_windows.append(control_center)
app = Application("sims", *_app_windows) app = Application("sims", *_app_windows)
@@ -148,9 +159,9 @@ def screenrec_stop():
@Application.action() @Application.action()
def toggle_notification_center(): def toggle_control_center():
if notification_center is not None: if control_center is not None:
notification_center.toggle() control_center.toggle()
def _set_all_bars_rounded(rounded: bool): def _set_all_bars_rounded(rounded: bool):
@@ -210,8 +221,7 @@ def spawn_bars():
tray=tray if i == 0 else None, tray=tray if i == 0 else None,
monitor=i, monitor=i,
screenrec_service=screenrec_service if i == 0 else None, screenrec_service=screenrec_service if i == 0 else None,
notification_history=notification_history if i == 0 else None, control_center=control_center if i == 0 else None,
notification_center=notification_center if i == 0 else None,
) )
bar_windows.append(bar) bar_windows.append(bar)
if i == 0 and bar.notmuch: if i == 0 and bar.notmuch:

View File

@@ -1,4 +1,3 @@
import psutil
from fabric.widgets.box import Box from fabric.widgets.box import Box
from fabric.widgets.label import Label from fabric.widgets.label import Label
from fabric.widgets.image import Image from fabric.widgets.image import Image
@@ -6,25 +5,20 @@ from fabric.widgets.overlay import Overlay
from fabric.widgets.datetime import DateTime from fabric.widgets.datetime import DateTime
from fabric.widgets.centerbox import CenterBox from fabric.widgets.centerbox import CenterBox
from sims.modules.player import Player from sims.modules.player import Player
from sims.modules.vinyl import VinylButton
from sims.modules.quick_menu import QuickMenuOpener
from sims.modules.battery import Battery from sims.modules.battery import Battery
from sims.modules.calendar import CalendarService, CalendarPopup from sims.modules.control_center import ControlCenter
from sims.modules.notification_center import NotificationCenter
from sims.modules.notmuch import NotmuchWidget from sims.modules.notmuch import NotmuchWidget
from sims.modules.screenrec import ScreenrecWidget from sims.modules.screenrec import ScreenrecWidget
from sims.services.notification_history import NotificationHistoryService
from sims.widgets.notification_bell import NotificationBell
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from sims.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
from sims.services.fenster import get_i3_connection
from sims.services.screenrec import ScreenrecService from sims.services.screenrec import ScreenrecService
from sims.services.smart_corners import get_smart_corners_service from sims.services.smart_corners import get_smart_corners_service
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from sims.services.system_stats import SystemStatsService from sims.services.system_stats import SystemStatsService
from sims.config import VINYL, BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
class StatusBar(Window): class StatusBar(Window):
@@ -34,8 +28,7 @@ class StatusBar(Window):
tray: SystemTray | None = None, tray: SystemTray | None = None,
monitor: int = 1, monitor: int = 1,
screenrec_service: ScreenrecService | None = None, screenrec_service: ScreenrecService | None = None,
notification_history: NotificationHistoryService | None = None, control_center: ControlCenter | None = None,
notification_center: NotificationCenter | None = None,
): ):
super().__init__( super().__init__(
name="sims", name="sims",
@@ -49,29 +42,23 @@ class StatusBar(Window):
) )
self.output = display self.output = display
self._corners_rounded = False self._corners_rounded = False
self._right_flat = False
self.workspaces = FensterWorkspaces( self.workspaces = FensterWorkspaces(
output=display, output=display,
name="workspaces", name="workspaces",
spacing=4, spacing=4,
) )
# Create calendar components (refresh every 2 minutes)
self.calendar_service = CalendarService(update_interval=120000)
self.calendar_popup = CalendarPopup()
self.calendar_popup_visible = False
# Create clickable datetime widget
from fabric.widgets.button import Button
datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M") datetime_widget = DateTime(name="date-time", formatters="%d %b - %H:%M")
self.date_time = Button( self.date_time = Button(
name="date-time-button", name="date-time-button",
child=datetime_widget, child=datetime_widget,
on_clicked=self.toggle_calendar, on_clicked=self._on_date_time_clicked,
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;" style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;",
) )
self.control_center = control_center
# Connect calendar service to popup
self.calendar_service.connect("events-changed", self.update_calendar_display)
self.system_tray = tray self.system_tray = tray
self.active_window = FensterActiveWindow( self.active_window = FensterActiveWindow(
@@ -95,15 +82,6 @@ class StatusBar(Window):
overlays=[self.cpu_progress_bar, self.progress_label], overlays=[self.cpu_progress_bar, self.progress_label],
) )
self.player = Player() self.player = Player()
self.vinyl = None
if VINYL["enable"]:
self.vinyl = VinylButton()
# Create quick menu button
self.quick_menu = QuickMenuOpener(icon_name="open-menu-symbolic")
# Setup audio section with vinyl if enabled
if self.vinyl:
self.quick_menu.get_menu().setup_audio_section(vinyl_service=self.vinyl)
self.battery = None self.battery = None
if BATTERY["enable"]: if BATTERY["enable"]:
@@ -117,13 +95,6 @@ class StatusBar(Window):
if screenrec_service is not None: if screenrec_service is not None:
self.screenrec = ScreenrecWidget(screenrec_service) self.screenrec = ScreenrecWidget(screenrec_service)
self.notification_bell = None
if notification_history is not None and notification_center is not None:
self.notification_bell = NotificationBell(
notification_history,
on_clicked=notification_center.toggle,
)
self.status_container = Box( self.status_container = Box(
name="widgets-container", name="widgets-container",
spacing=4, spacing=4,
@@ -146,11 +117,6 @@ class StatusBar(Window):
if self.screenrec: if self.screenrec:
end_container_children.append(self.screenrec) end_container_children.append(self.screenrec)
if self.notification_bell:
end_container_children.append(self.notification_bell)
# Add quick menu button next to time
end_container_children.append(self.quick_menu)
end_container_children.append(self.date_time) end_container_children.append(self.date_time)
center_children = [] center_children = []
@@ -183,23 +149,19 @@ class StatusBar(Window):
) )
self.children = self.inner self.children = self.inner
# Create system stats service with signal-based updates
self.system_stats_service = SystemStatsService(update_interval=3000) self.system_stats_service = SystemStatsService(update_interval=3000)
self.system_stats_service.connect("stats-changed", self.update_progress_bars) self.system_stats_service.connect("stats-changed", self.update_progress_bars)
# Set the bar height
self.set_size_request(-1, BAR_HEIGHT) self.set_size_request(-1, BAR_HEIGHT)
smart_corners = get_smart_corners_service() smart_corners = get_smart_corners_service()
smart_corners.connect("state-changed", self._on_smart_corners_changed) smart_corners.connect("state-changed", self._on_smart_corners_changed)
self.set_corners_rounded(not smart_corners.get(display)) self.set_corners_rounded(not smart_corners.get(display))
self.show_all() if self.control_center is not None:
self.control_center.add_visibility_listener(self.set_right_flat)
def __del__(self): self.show_all()
"""Cleanup when bar is destroyed"""
if hasattr(self, 'calendar_service'):
self.calendar_service.stop_monitoring()
@property @property
def corners_rounded(self) -> bool: def corners_rounded(self) -> bool:
@@ -219,30 +181,19 @@ class StatusBar(Window):
return return
self.set_corners_rounded(not active) self.set_corners_rounded(not active)
def set_right_flat(self, flat: bool) -> None:
if flat == self._right_flat:
return
if flat:
self.inner.set_style("border-radius: 0 0 0 28px;")
else:
self.inner.set_style("")
self._right_flat = flat
def update_progress_bars(self, service, cpu_percent, memory_percent): def update_progress_bars(self, service, cpu_percent, memory_percent):
"""Update progress bars when system stats change"""
self.cpu_progress_bar.value = cpu_percent self.cpu_progress_bar.value = cpu_percent
self.ram_progress_bar.value = memory_percent self.ram_progress_bar.value = memory_percent
def toggle_calendar(self, button=None): def _on_date_time_clicked(self, _button=None):
"""Toggle the calendar popup when datetime is clicked""" if self.control_center is not None:
from loguru import logger self.control_center.toggle()
logger.info(f"[Calendar] DateTime clicked, popup_visible: {self.calendar_popup_visible}")
if self.calendar_popup_visible:
logger.info("[Calendar] Hiding calendar popup")
self.calendar_popup.set_visible(False)
self.calendar_popup_visible = False
else:
logger.info("[Calendar] Showing calendar popup")
# Use cached events - no need to refresh on click
cached_events = self.calendar_service.get_cached_events()
logger.info(f"[Calendar] Using {len(cached_events)} cached events")
self.calendar_popup.update_events_display(cached_events)
self.calendar_popup.set_visible(True)
self.calendar_popup.show_all()
self.calendar_popup_visible = True
def update_calendar_display(self, service, events):
"""Update the calendar popup with events"""
self.calendar_popup.update_events_display(events)

View File

@@ -2,15 +2,10 @@ import json
import os import os
import subprocess import subprocess
import shutil import shutil
from datetime import datetime, date from datetime import date
# Add common binary paths to PATH for user binaries # Add common binary paths to PATH for user binaries
os.environ['PATH'] = '/run/current-system/sw/bin:/home/' + os.environ.get('USER', 'user') + '/.nix-profile/bin:' + os.environ.get('PATH', '') os.environ['PATH'] = '/run/current-system/sw/bin:/home/' + os.environ.get('USER', 'user') + '/.nix-profile/bin:' + os.environ.get('PATH', '')
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.wayland import WaylandWindow as Window
from loguru import logger from loguru import logger
from sims.config import CALENDAR from sims.config import CALENDAR
@@ -203,203 +198,3 @@ class CalendarService:
else: else:
logger.info("[Calendar] Using khal subprocess") logger.info("[Calendar] Using khal subprocess")
self.update_events_subprocess() self.update_events_subprocess()
class CalendarPopup(Window):
def __init__(self, **kwargs):
super().__init__(
name="calendar-popup",
layer="top",
anchor="top right",
margin="10px 10px 0px 0px", # Just a few pixels under the bar
exclusivity="none",
visible=False,
all_visible=False,
**kwargs,
)
# Events container
self.events_box = Box(
name="events-box",
orientation="v",
spacing=6,
style="min-width: 450px; min-height: 200px;",
)
# Add a test label to make sure popup is working
test_label = Label("Calendar Events", name="calendar-title")
container = Box(
orientation="v", spacing=4, children=[test_label, self.events_box]
)
self.children = container
# Set explicit size - much bigger
self.set_size_request(500, 400)
def update_events_display(self, events):
"""Update the events display"""
logger.info(f"[Calendar] Updating popup with {len(events)} events")
# Clear existing children first
self.events_box.children = []
if not events:
logger.info("[Calendar] No events, showing 'no events' message")
no_events_label = Label("No events today", name="no-events")
self.events_box.add(no_events_label)
return
# Check current time for time indicator placement
now = datetime.now()
current_time = now.strftime("%H:%M")
current_time_added = False
for i, event in enumerate(events):
logger.info(f"[Calendar] Processing event {i+1} for display")
title = event.get("title", "No title")
start_time = event.get("start", "").split()[1] if event.get("start") else ""
end_time = event.get("end", "").split()[1] if event.get("end") else ""
location = event.get("location", "")
# Check if we should add current time indicator before this event
if not current_time_added and start_time and start_time > current_time:
self.add_current_time_indicator(current_time)
current_time_added = True
# Format time display
time_str = ""
if start_time and end_time:
time_str = f"{start_time} - {end_time}"
elif start_time:
time_str = start_time
logger.info(f"[Calendar] Creating widget for: {title} ({time_str})")
# Create event item with horizontal layout - time on left, content on right
event_box = Box(
name="event-item",
orientation="h", # Horizontal layout
spacing=12,
style_classes=["event-item"],
)
# 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"],
style="min-width: 100px;" # Fixed width for consistent alignment
)
# Right side: Content (title and location)
content_box = Box(
name="event-content",
orientation="v",
spacing=2
)
# Title (no more status prefix)
title_label = Label(
title,
name="event-title",
style_classes=["event-title"],
)
content_box.add(title_label)
if location:
location_label = Label(
f"📍 {location}",
name="event-location",
style_classes=["event-location"],
)
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)
logger.info(f"[Calendar] Added event widget to events_box")
# Add current time indicator at the end if not added yet
if not current_time_added:
self.add_current_time_indicator(current_time)
# Force refresh the popup display
self.events_box.show_all()
logger.info(f"[Calendar] Finished updating popup")
def add_current_time_indicator(self, current_time):
"""Add a current time indicator to the events list"""
time_indicator = Box(
name="current-time-indicator",
orientation="h",
spacing=8,
style_classes=["current-time-indicator"],
)
# Current time label
time_label = Label(
current_time,
name="current-time-label",
style_classes=["current-time-label"],
style="min-width: 100px; font-weight: bold;"
)
# Line indicator
line_label = Label(
"━━━ NOW",
name="current-time-line",
style_classes=["current-time-line"],
)
time_indicator.add(time_label)
time_indicator.add(line_label)
self.events_box.add(time_indicator)
logger.info(f"[Calendar] Added current time indicator at {current_time}")
class CalendarWidget(Button):
def __init__(self, **kwargs):
super().__init__(
name="calendar-widget",
child=Image(icon_name="x-office-calendar-symbolic", icon_size=16),
on_clicked=self.toggle_events,
**kwargs,
)
self.service = CalendarService()
self.service.connect("events-changed", self.update_events_display)
# Create popup window
self.popup = CalendarPopup()
self.popup_visible = False
logger.info("[Calendar] Calendar widget initialized with popup")
# Initial update
self.update_events_display(self.service, self.service.events)
def toggle_events(self, button=None):
"""Toggle the visibility of the events popup"""
logger.info(f"[Calendar] Button clicked, popup_visible: {self.popup_visible}")
if self.popup_visible:
logger.info("[Calendar] Hiding popup")
self.popup.set_visible(False)
self.popup_visible = False
else:
logger.info("[Calendar] Showing popup")
# Refresh events when opening
self.service.update_events()
self.popup.set_visible(True)
self.popup.show_all()
self.popup_visible = True
def update_events_display(self, service, events):
"""Update the events display in popup"""
self.popup.update_events_display(events)

View File

@@ -0,0 +1,352 @@
from datetime import datetime
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk
from sims.modules.calendar import CalendarService
from sims.modules.vinyl import VinylButton
from sims.services.notification_history import NotificationHistoryService
from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget
class ControlCenter(Window):
def __init__(
self,
history: NotificationHistoryService,
calendar_service: CalendarService | None = None,
vinyl_button: VinylButton | None = None,
monitor: int = 0,
width: int = 380,
):
super().__init__(
name="control-center",
anchor="top right bottom",
monitor=monitor,
margin="0",
exclusivity="none",
keyboard_mode="on-demand",
visible=False,
)
self._history = history
self._calendar_service = calendar_service
self._vinyl_button = vinyl_button
self._width = width
self._visibility_listeners: list = []
close_button = Button(
name="control-center-close",
image=Image(icon_name="window-close-symbolic", icon_size=16),
on_clicked=lambda *_: self.hide(),
)
header = Box(
name="control-center-header",
orientation="h",
spacing=8,
)
header.pack_start(
Label(name="control-center-title", label="Control Center", h_align="start"),
True,
True,
0,
)
header.pack_end(close_button, False, False, 0)
sections: list = []
if vinyl_button is not None:
sections.append(self._build_settings_section())
if calendar_service is not None:
sections.append(self._build_calendar_section())
sections.append(self._build_notifications_section())
sections_box = Box(
name="control-center-sections",
orientation="v",
spacing=12,
children=sections,
h_expand=True,
)
scroll = ScrolledWindow(
name="control-center-scroll",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=sections_box,
h_expand=True,
v_expand=True,
)
body = Box(
name="control-center-body",
orientation="v",
spacing=8,
children=[header, scroll],
h_expand=True,
v_expand=True,
)
body.set_size_request(self._width, -1)
self.add(body)
self.connect("key-press-event", self._on_key_press)
self._history.connect("changed", lambda *_: self._refresh_notifications())
if self._calendar_service is not None:
self._calendar_service.connect(
"events-changed",
lambda _service, events: self._refresh_calendar(events),
)
self._refresh_notifications()
if self._calendar_service is not None:
self._refresh_calendar(self._calendar_service.get_cached_events())
def _build_section(self, name: str, title: str | None) -> Box:
section = Box(
name=name,
orientation="v",
spacing=6,
h_expand=True,
)
if title is not None:
section.add(
Label(
name="control-center-section-title",
label=title,
h_align="start",
)
)
return section
def _build_settings_section(self) -> Box:
section = self._build_section("control-center-settings", "Settings")
row = Box(
name="control-center-settings-row",
orientation="h",
spacing=8,
)
row.pack_start(
Label(
name="control-center-settings-label",
label="Vinyl Passthrough",
h_align="start",
),
True,
True,
0,
)
row.pack_end(self._vinyl_button, False, False, 0)
section.add(row)
return section
def _build_calendar_section(self) -> Box:
section = self._build_section("control-center-calendar", "Calendar")
self._calendar_events_box = Box(
name="control-center-events",
orientation="v",
spacing=4,
h_expand=True,
)
section.add(self._calendar_events_box)
return section
def _build_notifications_section(self) -> Box:
section = self._build_section("control-center-notifications", None)
clear_button = Button(
name="control-center-notifications-clear",
label="Clear all",
on_clicked=lambda *_: self._history.clear(),
)
header = Box(
name="control-center-notifications-header",
orientation="h",
spacing=8,
)
header.pack_start(
Label(
name="control-center-section-title",
label="Notifications",
h_align="start",
),
True,
True,
0,
)
header.pack_end(clear_button, False, False, 0)
self._notifications_empty = Label(
name="control-center-notifications-empty",
label="No notifications",
h_align="start",
)
self._notifications_list = Box(
name="control-center-notifications-list",
orientation="v",
spacing=6,
h_expand=True,
)
section.add(header)
section.add(self._notifications_list)
return section
def add_visibility_listener(self, callback) -> None:
self._visibility_listeners.append(callback)
def _notify_visibility(self, visible: bool) -> None:
for callback in self._visibility_listeners:
callback(visible)
def toggle(self) -> None:
if self.get_visible():
self.hide()
else:
self.show()
def show(self) -> None: # type: ignore[override]
self._history.mark_all_seen()
super().show()
self.show_all()
self._notify_visibility(True)
def hide(self) -> None: # type: ignore[override]
super().hide()
self._notify_visibility(False)
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.hide()
return True
return False
def _refresh_notifications(self) -> None:
for child in self._notifications_list.get_children():
self._notifications_list.remove(child)
if child is not self._notifications_empty:
child.destroy()
entries = self._history.entries
if not entries:
self._notifications_list.add(self._notifications_empty)
self._notifications_empty.show_all()
return
for entry in entries:
self._notifications_list.add(
NotificationHistoryEntryWidget(
entry, on_dismiss=self._history.remove
)
)
self._notifications_list.show_all()
def _refresh_calendar(self, events) -> None:
for child in self._calendar_events_box.get_children():
self._calendar_events_box.remove(child)
child.destroy()
if not events:
self._calendar_events_box.add(
Label(
name="control-center-no-events",
label="No events today",
h_align="start",
)
)
self._calendar_events_box.show_all()
return
now = datetime.now()
current_time = now.strftime("%H:%M")
current_time_added = False
for event in events:
title = event.get("title", "No title")
start_raw = event.get("start", "")
end_raw = event.get("end", "")
start_time = start_raw.split()[1] if start_raw else ""
end_time = end_raw.split()[1] if end_raw else ""
location = event.get("location", "")
if not current_time_added and start_time and start_time > current_time:
self._calendar_events_box.add(self._build_now_indicator(current_time))
current_time_added = True
if start_time and end_time:
time_str = f"{start_time} - {end_time}"
elif start_time:
time_str = start_time
else:
time_str = "All day"
event_box = Box(
name="event-item",
orientation="h",
spacing=12,
style_classes=["event-item"],
)
event_box.add(
Label(
time_str,
name="event-time",
style_classes=["event-time"],
style="min-width: 90px;",
)
)
content_box = Box(
name="event-content",
orientation="v",
spacing=2,
)
content_box.add(
Label(
title,
name="event-title",
style_classes=["event-title"],
h_align="start",
)
)
if location:
content_box.add(
Label(
f"📍 {location}",
name="event-location",
style_classes=["event-location"],
h_align="start",
)
)
event_box.add(content_box)
self._calendar_events_box.add(event_box)
if not current_time_added:
self._calendar_events_box.add(self._build_now_indicator(current_time))
self._calendar_events_box.show_all()
def _build_now_indicator(self, current_time: str) -> Box:
indicator = Box(
name="current-time-indicator",
orientation="h",
spacing=8,
style_classes=["current-time-indicator"],
)
indicator.add(
Label(
current_time,
name="current-time-label",
style_classes=["current-time-label"],
style="min-width: 90px; font-weight: bold;",
)
)
indicator.add(
Label(
"━━━ NOW",
name="current-time-line",
style_classes=["current-time-line"],
)
)
return indicator

View File

@@ -1,129 +0,0 @@
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk
from sims.services.notification_history import NotificationHistoryService
from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget
class NotificationCenter(Window):
def __init__(
self,
history: NotificationHistoryService,
monitor: int = 0,
width: int = 380,
):
super().__init__(
name="notification-center",
anchor="top right bottom",
monitor=monitor,
margin="0",
exclusivity="none",
keyboard_mode="on-demand",
visible=False,
)
self._history = history
self._width = width
self._empty_label = Label(
name="notification-center-empty",
label="No notifications",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self._list = Box(
name="notification-center-list",
spacing=6,
orientation="v",
h_expand=True,
)
clear_button = Button(
name="notification-center-clear",
label="Clear all",
on_clicked=lambda *_: self._history.clear(),
)
close_button = Button(
name="notification-center-close",
image=Image(icon_name="window-close-symbolic", icon_size=16),
on_clicked=lambda *_: self.hide(),
)
header = Box(
name="notification-center-header",
orientation="h",
spacing=8,
)
header.pack_start(
Label(name="notification-center-title", label="Notifications", h_align="start"),
True,
True,
0,
)
header.pack_end(close_button, False, False, 0)
header.pack_end(clear_button, False, False, 4)
scroll = ScrolledWindow(
name="notification-center-scroll",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=self._list,
h_expand=True,
v_expand=True,
)
body = Box(
name="notification-center-body",
orientation="v",
spacing=8,
children=[header, scroll],
h_expand=True,
v_expand=True,
)
body.set_size_request(self._width, -1)
self.add(body)
self.connect("key-press-event", self._on_key_press)
self._history.connect("changed", lambda *_: self._refresh())
self._refresh()
def toggle(self) -> None:
if self.get_visible():
self.hide()
else:
self.show()
def show(self) -> None: # type: ignore[override]
self._history.mark_all_seen()
super().show()
self.show_all()
def _on_key_press(self, _widget, event):
if event.keyval == Gdk.KEY_Escape:
self.hide()
return True
return False
def _refresh(self) -> None:
for child in self._list.get_children():
self._list.remove(child)
if child is not self._empty_label:
child.destroy()
entries = self._history.entries
if not entries:
self._list.add(self._empty_label)
self._empty_label.show_all()
return
for entry in entries:
self._list.add(
NotificationHistoryEntryWidget(
entry, on_dismiss=self._history.remove
)
)
self._list.show_all()

View File

@@ -1,283 +0,0 @@
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gtk
from loguru import logger
class QuickMenuItem(Box):
"""Base class for quick menu items"""
def __init__(self, title, icon_name=None, **kwargs):
super().__init__(
orientation="h",
spacing=12,
name="quick-menu-item",
**kwargs
)
self.set_style("padding: 8px 12px; min-width: 280px;")
# Icon and title on the left
left_box = Box(orientation="h", spacing=8)
if icon_name:
icon = Image(icon_name=icon_name, icon_size=16)
left_box.add(icon)
self.title_label = Label(title)
self.title_label.set_style("font-size: 14px;")
left_box.add(self.title_label)
self.add(left_box)
# Derived classes can add controls to the right side
class QuickMenuToggle(QuickMenuItem):
"""A menu item with a toggle switch"""
def __init__(self, title, icon_name=None, active=False, on_toggle=None, **kwargs):
super().__init__(title, icon_name, **kwargs)
# Create a custom toggle using a button with state tracking
self._active = active
self._on_toggle = on_toggle
# Create toggle indicator box
self.toggle_box = Box(
orientation="h",
spacing=0
)
self.toggle_box.set_style("min-width: 44px; min-height: 24px; border-radius: 12px; padding: 2px;")
# Toggle indicator (circle)
self.toggle_indicator = Label("")
self.toggle_indicator.set_style("min-width: 20px; min-height: 20px; border-radius: 10px; background: white;")
self.toggle_box.add(self.toggle_indicator)
# Make it clickable
self.toggle_button = Button(
child=self.toggle_box,
on_clicked=self._on_click
)
self.toggle_button.set_style("background: transparent; border: none; padding: 0;")
# Add spacer to push toggle to the right
spacer = Label("", h_expand=True)
self.add(spacer)
self.add(self.toggle_button)
# Set initial state
self._update_appearance()
def _on_click(self, button):
self._active = not self._active
self._update_appearance()
if self._on_toggle:
self._on_toggle(self._active)
def _update_appearance(self):
if self._active:
self.toggle_box.set_style_classes(["toggle-active"])
self.toggle_box.set_style(
"min-width: 44px; min-height: 24px; border-radius: 12px; padding: 2px; "
"transition: all 0.2s;"
)
self.toggle_indicator.set_style(
"min-width: 20px; min-height: 20px; border-radius: 10px; "
"background: white; margin-left: 20px; transition: all 0.2s;"
)
else:
self.toggle_box.set_style_classes(["toggle-inactive"])
self.toggle_box.set_style(
"min-width: 44px; min-height: 24px; border-radius: 12px; padding: 2px; "
"transition: all 0.2s;"
)
self.toggle_indicator.set_style(
"min-width: 20px; min-height: 20px; border-radius: 10px; "
"background: white; margin-left: 0px; transition: all 0.2s;"
)
def set_active(self, active):
self._active = active
self._update_appearance()
def get_active(self):
return self._active
class QuickMenuButton(QuickMenuItem):
"""A menu item that acts as a button"""
def __init__(self, title, icon_name=None, on_click=None, **kwargs):
super().__init__(title, icon_name, **kwargs)
if on_click:
# Make the entire item clickable
button_overlay = Button(
child=Box(), # Empty box as child
on_clicked=on_click
)
button_overlay.set_style("background: transparent; border: none; padding: 0; margin: 0;")
# Add arrow indicator on the right
arrow = Label("")
arrow.set_style("font-size: 18px; opacity: 0.5;")
spacer = Label("", h_expand=True)
self.add(spacer)
self.add(arrow)
class QuickMenuSection(Box):
"""A section in the quick menu with optional title"""
def __init__(self, title=None, **kwargs):
super().__init__(
orientation="v",
spacing=4,
name="quick-menu-section",
**kwargs
)
if title:
title_label = Label(
title,
name="section-title"
)
title_label.set_style("font-size: 12px; opacity: 0.6; padding: 8px 12px 4px 12px; font-weight: bold;")
self.add(title_label)
self.items_box = Box(orientation="v", spacing=2)
self.add(self.items_box)
def add_item(self, item):
self.items_box.add(item)
class QuickMenu(Window):
def __init__(self, **kwargs):
super().__init__(
name="quick-menu",
layer="overlay", # Changed from 'top' to 'overlay' for better shadow support
anchor="top right",
margin="40px 10px 0px 0px",
exclusivity="none",
visible=False,
all_visible=False,
style_classes=["popup-window"],
**kwargs,
)
# Main container
self.main_box = Box(
orientation="v",
spacing=8,
name="quick-menu-container"
)
# Remove redundant styling since it's handled in stylix.css
pass
# Title
title_box = Box(
orientation="h",
spacing=8
)
title_box.set_style("padding: 12px;")
title = Label("Quick Menu")
title.set_style("font-size: 16px; font-weight: bold;")
title_box.add(title)
self.main_box.add(title_box)
# Add a simple divider line
divider = Label("")
divider.set_style("min-height: 1px; background: rgba(255,255,255,0.1); margin: 0px 12px;")
self.main_box.add(divider)
# Sections container
self.sections_container = Box(
orientation="v",
spacing=8
)
self.sections_container.set_style("padding: 8px 0px;")
self.main_box.add(self.sections_container)
self.children = self.main_box
self.set_size_request(360, -1)
# Store references to dynamic items
self.vinyl_toggle = None
self.sections = {}
def add_section(self, section_id, title=None):
"""Add a new section to the menu"""
section = QuickMenuSection(title=title)
self.sections[section_id] = section
self.sections_container.add(section)
# Add separator before section if not the first
if len(self.sections) > 1:
separator = Label("")
separator.set_style("min-height: 1px; background: rgba(255,255,255,0.1); margin: 4px 12px;")
self.sections_container.add(separator)
return section
def setup_audio_section(self, vinyl_service=None):
"""Setup the audio controls section"""
audio_section = self.add_section("audio", None) # No section title since it's the only section
# Vinyl passthrough toggle
if vinyl_service:
self.vinyl_toggle = QuickMenuToggle(
title="Vinyl Passthrough",
icon_name="folder-music-symbolic",
active=vinyl_service.active,
on_toggle=lambda active: self._on_vinyl_toggle(active, vinyl_service)
)
audio_section.add_item(self.vinyl_toggle)
# Store reference to vinyl service
self.vinyl_service = vinyl_service
def _on_vinyl_toggle(self, active, vinyl_service):
"""Handle vinyl toggle"""
logger.info(f"[QuickMenu] Vinyl toggled: {active}")
vinyl_service.active = active
def setup_system_section(self):
"""Setup system controls section"""
# Removed for now - can add system controls later
pass
def update_vinyl_state(self, active):
"""Update vinyl toggle state from external source"""
if self.vinyl_toggle:
self.vinyl_toggle.set_active(active)
class QuickMenuOpener(Button):
"""Button to open the quick menu"""
def __init__(self, icon_name="open-menu-symbolic", **kwargs):
super().__init__(
name="quick-menu-button",
child=Image(icon_name=icon_name, icon_size=16),
on_clicked=self.toggle_menu,
**kwargs
)
self.menu = QuickMenu()
self.menu_visible = False
def toggle_menu(self, button=None):
"""Toggle the quick menu visibility"""
if self.menu_visible:
logger.info("[QuickMenu] Hiding menu")
self.menu.set_visible(False)
self.menu_visible = False
else:
logger.info("[QuickMenu] Showing menu")
self.menu.set_visible(True)
self.menu.show_all()
self.menu_visible = True
def get_menu(self):
"""Get the menu instance for configuration"""
return self.menu

View File

@@ -16,51 +16,13 @@
border-radius: 12px; border-radius: 12px;
} }
/* Calendar popup */
#calendar-popup {
background-color: var(--window-bg);
border: solid 2px var(--border-color);
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 {
color: var(--foreground);
font-weight: bold;
margin-bottom: 8px;
}
#events-box {
background-color: var(--window-bg);
border: none; /* Remove outline */
border-radius: 8px;
padding: 16px;
}
#no-events {
color: var(--light-grey);
padding: 4px;
}
/* Calendar event items */ /* Calendar event items */
.event-item { .event-item {
border-radius: 6px; border-radius: 8px;
padding: 8px 12px; padding: 8px 10px;
margin: 4px 0px; margin: 2px 0px;
background-color: var(--module-bg); background-color: var(--light-bg);
border: none; /* Remove outline */ border: none;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }

View File

@@ -43,43 +43,100 @@
color: var(--red); color: var(--red);
} }
/* Notification center side rail */ /* Control center side rail */
#notification-center { #control-center {
background-color: transparent; background-color: transparent;
} }
#notification-center-body { #control-center-body {
background-color: var(--mid-bg); background-color: var(--background);
border-left: solid 1px var(--border-color); border-left: solid 2px var(--border-color);
padding: 12px; border-bottom: solid 2px var(--border-color);
border-bottom-left-radius: 28px;
padding: 50px 12px 12px 12px;
} }
#notification-center-header { #control-center-header {
padding: 0 0 8px 0; padding: 4px 8px 12px 8px;
border-bottom: solid 1px var(--border-color); border-bottom: solid 1px var(--border-color);
} }
#notification-center-title { #control-center-title {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
color: var(--foreground);
} }
#notification-center-clear, #control-center-close {
#notification-center-close { padding: 4px 8px;
padding: 4px 10px; border-radius: 12px;
border-radius: 0.5rem; background-color: var(--module-bg);
}
#control-center-close:hover {
background-color: var(--light-bg); background-color: var(--light-bg);
} }
#notification-center-clear:hover, #control-center-sections {
#notification-center-close:hover { padding: 8px 0;
}
#control-center-settings,
#control-center-calendar,
#control-center-notifications {
padding: 8px;
background-color: var(--module-bg);
border-radius: 12px;
}
#control-center-section-title {
font-size: 12px;
font-weight: bold;
color: var(--light-grey);
padding: 4px 4px 8px 4px;
}
#control-center-settings-row {
padding: 4px 4px;
}
#control-center-settings-label {
font-size: 14px;
color: var(--foreground);
}
#control-center-events {
padding: 0 4px;
}
#control-center-no-events {
color: var(--light-grey);
padding: 6px 4px;
}
#control-center-notifications-header {
padding: 0 4px 6px 4px;
}
#control-center-notifications-clear {
padding: 2px 10px;
border-radius: 8px;
background-color: var(--light-bg);
font-size: 12px;
}
#control-center-notifications-clear:hover {
background-color: var(--dark-grey); background-color: var(--dark-grey);
} }
#notification-center-empty { #control-center-notifications-list {
padding: 0 4px;
}
#control-center-notifications-empty {
color: var(--light-grey); color: var(--light-grey);
padding: 24px; padding: 8px 4px;
} }
#notification-history-entry { #notification-history-entry {
@@ -120,25 +177,3 @@
background-color: var(--dark-grey); background-color: var(--dark-grey);
} }
/* Bar bell */
#notification-bell {
padding: 4px;
border-radius: 0.5rem;
}
#notification-bell:hover {
background-color: var(--light-bg);
}
#notification-bell-badge {
background-color: var(--red);
color: var(--foreground);
font-size: 10px;
font-weight: bold;
padding: 0 4px;
border-radius: 8px;
min-width: 14px;
min-height: 14px;
margin: -4px -4px 0 0;
}

View File

@@ -1,50 +0,0 @@
from typing import Callable
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from sims.services.notification_history import NotificationHistoryService
class NotificationBell(Button):
def __init__(
self,
history: NotificationHistoryService,
on_clicked: Callable[[], None],
**kwargs,
):
self._badge = Label(
name="notification-bell-badge",
label="",
visible=False,
v_align="start",
h_align="end",
)
self._badge.set_no_show_all(True)
super().__init__(
name="notification-bell",
child=Overlay(
child=Image(
icon_name="preferences-system-notifications-symbolic",
icon_size=18,
),
overlays=[self._badge],
),
on_clicked=lambda *_: on_clicked(),
**kwargs,
)
self._history = history
self._history.connect("changed", lambda *_: self._refresh())
self._refresh()
def _refresh(self) -> None:
count = self._history.unseen_count
if count <= 0:
self._badge.set_visible(False)
return
self._badge.set_label(str(count) if count < 10 else "9+")
self._badge.set_visible(True)