Compare commits

...

38 Commits

Author SHA1 Message Date
aee5be7f39 fix: stylix needs to generate visible colours 2026-05-03 18:53:37 +02:00
d1b6d3a560 feat: always show 9 bubbles 2026-05-03 14:58:52 +02:00
0d4c4caf10 feat: show visible and active workspaces 2026-05-03 14:47:20 +02:00
f1c45a7f8c feat: use fenster ipc (sway/i3 compatible) 2026-03-10 22:58:40 +01:00
7962947f80 feat: refresh mail widget 2025-10-18 20:38:03 +02:00
c256931b1d feat: log level in nix 2025-10-17 17:44:49 +02:00
5137379ac9 shadow preparation
the shadow needs to be done on the compositor
2025-09-30 20:54:57 +02:00
159eafbc73 quick menu 2025-09-30 20:52:03 +02:00
7b0a4a56db better vinyl styling 2025-09-30 12:56:05 +02:00
9666a2f7ae fix: nix paths 2025-09-30 00:48:41 +02:00
0be71cfddf prod logging 2025-09-30 00:35:27 +02:00
2d3f97cae1 fix: khal and notmuch in path 2025-09-30 00:29:02 +02:00
15077fe6fa feat: mail 2025-09-29 23:24:33 +02:00
34e837562f better calendar styling 2025-09-29 18:45:36 +02:00
a22f16a84f fix: run 2025-09-29 18:32:05 +02:00
17d11591ac dev mode 2025-09-29 18:29:57 +02:00
05f20d65b9 notch docs 2025-09-29 18:29:24 +02:00
0b3ee96ccf better calendar styling 2025-09-29 17:00:53 +02:00
dd6feb4170 stylix font size 2025-09-29 16:42:44 +02:00
5cea889af3 feat: calendar in bar 2025-09-29 16:36:43 +02:00
f781603907 example dev stylix yaml 2025-09-29 12:54:58 +02:00
5afada0fb3 better workspace styling 2025-09-29 12:52:03 +02:00
055f4ebb96 better snowflake symbol 2025-09-29 12:44:48 +02:00
da2a7d94d8 new workspace styling 2025-09-29 12:27:24 +02:00
c814eb01de smaller stylix workspace buttons 2025-09-29 11:40:51 +02:00
03598694fc fix: stylix workspace buttons 2025-09-29 11:39:16 +02:00
c4e522f17a fix battery usage 2025-09-29 11:36:33 +02:00
fe87de7580 fix stylix stylesheet 2025-09-29 11:19:13 +02:00
5c2ee58f4d fix: stylix enable option 2025-09-29 11:13:34 +02:00
4fda2670ac feat: stylix support 2025-09-29 11:10:25 +02:00
d9a176d4ec window title option 2025-09-29 11:02:48 +02:00
1a24c4eb99 add makefile 2025-09-29 10:56:37 +02:00
0ce3d286e2 bar height 2025-09-29 10:51:21 +02:00
7a6eca395d fix: correct battery icons 2025-09-29 10:34:24 +02:00
40ab13ab26 fix: really run battery updates 2025-09-29 10:12:12 +02:00
56c35ec7ec fix: battery run 2025-09-29 09:19:09 +02:00
e3396be9af battery icons 2025-09-29 09:13:40 +02:00
fc264dda44 feat: battery 2025-09-29 09:01:23 +02:00
33 changed files with 2339 additions and 413 deletions

2
Makefile Normal file
View File

@@ -0,0 +1,2 @@
run:
python -m bar.main --config ./example-stylix-dev.yaml

View File

@@ -1,3 +1,14 @@
# Todo # Todo
# Ideas
## Org-mode integration
- https://github.com/jlumpe/pyorg - https://github.com/jlumpe/pyorg
- https://github.com/jlumpe/ox-json - https://github.com/jlumpe/ox-json
## Emails not seen
with notmuch
## notch power bar
- show the power around the notch
- show watts charging/discharging in bar
- https://lo.cafe/notchnook
## Screenshot menu
## Media Playing

View File

@@ -50,3 +50,11 @@ if app_config is None:
raise Exception("Config file missing") raise Exception("Config file missing")
VINYL = app_config.get("vinyl", {"enable": False}) VINYL = app_config.get("vinyl", {"enable": False})
BATTERY = app_config.get("battery", {"enable": False})
WINDOW_TITLE = app_config.get("window_title", {"enable": True})
STYLIX = app_config.get("stylix", {"enable": False})
CALENDAR = app_config.get("calendar", {"enable": True, "khal_path": "khal"})
NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient"})
BAR_HEIGHT = app_config.get("height", 40)
LOG_LEVEL = app_config.get("logLevel", "WARNING")
DEV = app_config.get("dev", False)

View File

@@ -1,52 +1,88 @@
from loguru import logger from loguru import logger
# Configure logging based on dev flag
from .config import DEV, LOG_LEVEL
if DEV:
# In dev mode, disable fabric logs but keep stylix and bar logs
logger.disable("fabric")
else:
# In production, disable fabric logs but keep bar logs with configurable level
import sys
logger.disable("fabric")
logger.configure(handlers=[{"sink": sys.stderr, "level": LOG_LEVEL, "format": "{time} | {level} | {name}:{function}:{line} - {message}"}])
from fabric import Application from fabric import Application
from fabric.i3 import I3, I3MessageType
from fabric.system_tray.widgets import SystemTray from fabric.system_tray.widgets import SystemTray
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
from fabric.river.widgets import (
get_river_connection,
)
from fabric.utils import ( from fabric.utils import (
get_relative_path, get_relative_path,
) )
from .modules.bar import StatusBar from .modules.bar import StatusBar
from .modules.window_fuzzy import FuzzyWindowFinder from .modules.window_fuzzy import FuzzyWindowFinder
from .modules.stylix import get_stylix_css_path
from .config import STYLIX
from .services.fenster import get_i3_connection
tray = SystemTray(name="system-tray", spacing=4) tray = SystemTray(name="system-tray", spacing=4)
river = get_river_connection() i3 = get_i3_connection()
dummy = Window(visible=False) dummy = Window(visible=False)
finder = FuzzyWindowFinder() finder = FuzzyWindowFinder()
bar_windows = [] bar_windows = []
notmuch_widget = None
app = Application("bar", dummy, finder) app = Application("bar", dummy, finder)
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
# Load CSS - use Stylix if enabled, otherwise use default
if STYLIX.get("enable", False):
stylix_css_path = get_stylix_css_path()
if stylix_css_path:
logger.info("[Bar] Using Stylix CSS")
# Load base styles first for structure
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
# Then apply Stylix theme colors
app.set_stylesheet_from_file(stylix_css_path)
else:
logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default")
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
else:
logger.info("[Bar] Using default CSS")
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
def spawn_bars(): def spawn_bars():
logger.info("[Bar] Spawning bars after river ready") global notmuch_widget
outputs = river.outputs logger.info("[Bar] Spawning bars")
outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
if not outputs: if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)):
logger.warning("[Bar] No outputs found — skipping bar spawn") logger.warning("[Bar] Failed to get outputs — skipping bar spawn")
return return
output_ids = sorted(outputs.keys()) outputs = [o for o in outputs_reply.reply if o.get("active")]
for i, output_id in enumerate(output_ids): if not outputs:
bar = StatusBar(display=output_id, tray=tray if i == 0 else None, monitor=i) logger.warning("[Bar] No active outputs found — skipping bar spawn")
return
for i, output in enumerate(outputs):
output_name = output.get("name", f"Unknown-{i}")
bar = StatusBar(display=output_name, tray=tray if i == 0 else None, monitor=i)
bar_windows.append(bar) bar_windows.append(bar)
if i == 0 and bar.notmuch:
notmuch_widget = bar.notmuch
return False return False
def main(): def main():
if river.ready: if i3.ready:
spawn_bars() spawn_bars()
else: else:
river.connect("notify::ready", lambda sender, pspec: spawn_bars()) i3.connect("notify::ready", lambda *_: spawn_bars())
app.run() app.run()

View File

@@ -1,34 +1,32 @@
import psutil 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.overlay import Overlay 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 bar.modules.player import Player from bar.modules.player import Player
from bar.modules.vinyl import VinylButton from bar.modules.vinyl import VinylButton
from bar.modules.quick_menu import QuickMenuOpener
from bar.modules.battery import Battery
from bar.modules.calendar import CalendarService, CalendarPopup
from bar.modules.notmuch import NotmuchWidget
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 fabric.river.widgets import ( from bar.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
RiverWorkspaces, from bar.services.fenster import get_i3_connection
RiverWorkspaceButton,
RiverActiveWindow,
get_river_connection,
)
from fabric.utils import (
invoke_repeater,
)
from fabric.widgets.circularprogressbar import CircularProgressBar from fabric.widgets.circularprogressbar import CircularProgressBar
from bar.services.system_stats import SystemStatsService
from bar.config import VINYL from bar.config import VINYL, BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
class StatusBar(Window): class StatusBar(Window):
def __init__( def __init__(
self, self,
display: int, display: str,
tray: SystemTray | None = None, tray: SystemTray | None = None,
monitor: int = 1, monitor: int = 1,
river_service=None,
): ):
super().__init__( super().__init__(
name="bar", name="bar",
@@ -40,22 +38,32 @@ class StatusBar(Window):
all_visible=False, all_visible=False,
monitor=monitor, monitor=monitor,
) )
if river_service:
self.river = river_service
else:
self.river = get_river_connection()
self.workspaces = RiverWorkspaces( self.workspaces = FensterWorkspaces(
display, output=display,
name="workspaces", name="workspaces",
spacing=4, spacing=4,
buttons_factory=lambda ws_id: RiverWorkspaceButton(id=ws_id, label=None),
river_service=self.river,
) )
self.date_time = DateTime(name="date-time", formatters="%d %b - %H:%M") # 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")
self.date_time = Button(
name="date-time-button",
child=datetime_widget,
on_clicked=self.toggle_calendar,
style="background: transparent; border: none; padding: 0; margin: 0; box-shadow: none;"
)
# 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 = RiverActiveWindow( self.active_window = FensterActiveWindow(
name="active-window", name="active-window",
max_length=50, max_length=50,
style="color: #ffffff; font-size: 14px; font-weight: bold;", style="color: #ffffff; font-size: 14px; font-weight: bold;",
@@ -80,6 +88,20 @@ class StatusBar(Window):
if VINYL["enable"]: if VINYL["enable"]:
self.vinyl = VinylButton() 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
if BATTERY["enable"]:
self.battery = Battery()
self.notmuch = None
if NOTMUCH["enable"]:
self.notmuch = NotmuchWidget()
self.status_container = Box( self.status_container = Box(
name="widgets-container", name="widgets-container",
spacing=4, spacing=4,
@@ -89,15 +111,24 @@ class StatusBar(Window):
end_container_children = [] end_container_children = []
if self.vinyl:
end_container_children.append(self.vinyl)
end_container_children.append(self.status_container) end_container_children.append(self.status_container)
if self.system_tray: if self.system_tray:
end_container_children.append(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)
# 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 = []
if WINDOW_TITLE["enable"]:
center_children.append(self.active_window)
self.children = CenterBox( self.children = CenterBox(
name="bar-inner", name="bar-inner",
start_children=Box( start_children=Box(
@@ -105,7 +136,7 @@ class StatusBar(Window):
spacing=6, spacing=6,
orientation="h", orientation="h",
children=[ children=[
Label(name="nixos-label", markup=""), Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
self.workspaces, self.workspaces,
], ],
), ),
@@ -113,7 +144,7 @@ class StatusBar(Window):
name="center-container", name="center-container",
spacing=4, spacing=4,
orientation="h", orientation="h",
children=[self.active_window], children=center_children,
), ),
end_children=Box( end_children=Box(
name="end-container", name="end-container",
@@ -123,11 +154,44 @@ class StatusBar(Window):
), ),
) )
invoke_repeater(1000, self.update_progress_bars) # Create system stats service with signal-based updates
self.system_stats_service = SystemStatsService(update_interval=3000)
self.system_stats_service.connect("stats-changed", self.update_progress_bars)
# Set the bar height
self.set_size_request(-1, BAR_HEIGHT)
self.show_all() self.show_all()
def update_progress_bars(self): def __del__(self):
self.ram_progress_bar.value = psutil.virtual_memory().percent / 100 """Cleanup when bar is destroyed"""
self.cpu_progress_bar.value = psutil.cpu_percent() / 100 if hasattr(self, 'calendar_service'):
return True self.calendar_service.stop_monitoring()
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.ram_progress_bar.value = memory_percent
def toggle_calendar(self, button=None):
"""Toggle the calendar popup when datetime is clicked"""
from loguru import logger
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)

51
bar/modules/battery.py Normal file
View File

@@ -0,0 +1,51 @@
from gi.repository import GLib
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.image import Image
from bar.services.battery import BatteryService
class Battery(Box):
def __init__(self, **kwargs):
super().__init__(name="battery-widget", orientation="h", spacing=4, **kwargs)
self.bat_icon = Image(
name="bat-icon", icon_name="battery-full-symbolic", icon_size=16
)
self.bat_label = Label(name="bat-label", label="100%")
# Create battery service with signal-based updates
self.battery_service = BatteryService(update_interval=10000) # Check every 10 seconds
self.battery_service.connect("battery-changed", self.update_battery)
self.children = [self.bat_icon, self.bat_label]
self.show_all()
# Initialize with current battery status
initial_percent = self.battery_service.percent
initial_charging = self.battery_service.charging
GLib.idle_add(self.update_battery, None, initial_percent, initial_charging)
def _icon_lookup(self, bat, charging):
# Round to nearest 10 for level-based icons
level = max(10, min(100, round(bat / 10) * 10))
if charging:
return f"battery-level-{level}-charging-symbolic"
else:
return f"battery-level-{level}-symbolic"
def update_battery(self, service, percent, charging):
"""Update battery display when battery status changes"""
icon_name = self._icon_lookup(percent, charging)
self.bat_icon.set_property("icon-name", icon_name)
self.bat_label.set_text(f"{int(percent)}%")
if percent < 20 and not charging:
self.bat_label.add_style_class("battery-low")
self.bat_icon.add_style_class("battery-low")
else:
self.bat_label.remove_style_class("battery-low")
self.bat_icon.remove_style_class("battery-low")

405
bar/modules/calendar.py Normal file
View File

@@ -0,0 +1,405 @@
import json
import os
import subprocess
import shutil
from datetime import datetime, date
# 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', '')
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 bar.config import CALENDAR
# Try to import khal as a Python library
try:
from khal.cli.main import main_khal
from khal.settings import get_config
from khal.khalendar import CalendarCollection
KHAL_AVAILABLE = True
logger.info("[Calendar] Using khal as Python library")
except ImportError:
KHAL_AVAILABLE = False
logger.info("[Calendar] khal Python library not available, falling back to subprocess")
class CalendarService:
def __init__(self, update_interval=300000): # 5 minutes default
self.events = []
self.callbacks = []
self._update_interval = update_interval
self._timer_id = None
# Initial load
self.update_events()
# Start periodic updates
self.start_monitoring()
def connect(self, signal_name, callback):
"""Simple callback system to replace signals"""
if signal_name == "events-changed":
self.callbacks.append(callback)
def emit_events_changed(self, events):
"""Emit events changed to all callbacks"""
for callback in self.callbacks:
callback(self, events)
def start_monitoring(self):
"""Start periodic event updates"""
if self._timer_id is None:
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"
)
def stop_monitoring(self):
"""Stop periodic event updates"""
if self._timer_id is not None:
from gi.repository import GLib
GLib.source_remove(self._timer_id)
self._timer_id = None
logger.info("[Calendar] Stopped periodic updates")
def _periodic_update(self):
"""Periodic update callback"""
logger.info("[Calendar] Performing periodic events update")
self.update_events()
return True # Keep the timer running
def get_cached_events(self):
"""Get cached events without triggering update"""
return self.events
def update_events_python_api(self):
"""Fetch today's events using khal Python API"""
try:
# Get khal configuration
config = get_config()
# Create calendar collection
collection = CalendarCollection.from_calendars(
calendars=config['calendars'],
dbpath=config['sqlite']['path'],
locale=config['locale'],
color=config['default']['print_new'],
unicode_symbols=config['default']['unicode_symbols'],
default_calendar=config['default']['default_calendar'],
readonly=True
)
# Get today's events
today = date.today()
events = collection.get_events_on(today)
# Format events to match our expected structure
formatted_events = []
for event in events:
formatted_event = {
'title': str(event.summary),
'start': event.start.strftime('%m-%d %H:%M') if hasattr(event.start, 'strftime') else '',
'end': event.end.strftime('%m-%d %H:%M') if hasattr(event.end, 'strftime') else '',
'location': str(event.location) if event.location else ''
}
formatted_events.append(formatted_event)
# Sort by start time
formatted_events.sort(key=lambda e: e.get('start', ''))
self.events = formatted_events
logger.info(f"[Calendar] Found {len(self.events)} events using Python API")
self.emit_events_changed(self.events)
except Exception as e:
logger.error(f"[Calendar] Error using khal Python API: {e}")
# Fall back to subprocess method
self.update_events_subprocess()
def update_events_subprocess(self):
"""Fetch today's events using khal subprocess (fallback)"""
# Get khal path from config
khal_path = CALENDAR.get("khal_path", "khal")
# Check if khal is available
if not shutil.which(khal_path):
logger.warning(f"[Calendar] khal not found at '{khal_path}'. Please install khal or configure the correct path.")
self.events = []
self.emit_events_changed(self.events)
return
try:
cmd = [
khal_path,
"list",
"--json",
"title",
"--json",
"start",
"--json",
"end",
"--json",
"location",
"today",
]
logger.info(f"[Calendar] Running command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
logger.info(f"[Calendar] Command stdout: {result.stdout[:200]}...")
logger.info(f"[Calendar] Command stderr: {result.stderr[:200]}...")
if result.stdout.strip():
lines = result.stdout.strip().split("\n")
all_events = []
for line in lines:
if line.strip():
try:
events = json.loads(line)
all_events.extend(events)
except json.JSONDecodeError:
continue
self.events = all_events
logger.info(f"[Calendar] Found {len(self.events)} events using subprocess")
self.emit_events_changed(self.events)
else:
self.events = []
self.emit_events_changed(self.events)
except subprocess.CalledProcessError as e:
logger.error(f"[Calendar] Failed to fetch events: {e}")
self.events = []
self.emit_events_changed(self.events)
except Exception as e:
logger.error(f"[Calendar] Error processing events: {e}")
self.events = []
self.emit_events_changed(self.events)
def update_events(self):
"""Fetch today's events from khal"""
# Check if calendar is enabled
if not CALENDAR.get("enable", True):
logger.info("[Calendar] Calendar is disabled in config")
self.events = []
self.emit_events_changed(self.events)
return
# Try Python API first, fall back to subprocess
if KHAL_AVAILABLE:
logger.info("[Calendar] Using khal Python API")
self.update_events_python_api()
else:
logger.info("[Calendar] Using khal 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

@@ -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

178
bar/modules/notmuch.py Normal file
View File

@@ -0,0 +1,178 @@
import os
import subprocess
import shutil
# 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', '')
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 loguru import logger
from bar.config import NOTMUCH
class NotmuchService:
def __init__(self, update_interval=60000): # 1 minute default
self.unread_count = 0
self.callbacks = []
self._update_interval = update_interval
self._timer_id = None
# Initial load
self.update_unread_count()
# Start periodic updates
self.start_monitoring()
def connect(self, signal_name, callback):
"""Simple callback system to replace signals"""
if signal_name == "unread-changed":
self.callbacks.append(callback)
def emit_unread_changed(self, count):
"""Emit unread changed to all callbacks"""
for callback in self.callbacks:
callback(self, count)
def start_monitoring(self):
"""Start periodic unread count updates"""
if self._timer_id is None:
from fabric.utils import invoke_repeater
self._timer_id = invoke_repeater(
self._update_interval, self._periodic_update
)
logger.info(
f"[Notmuch] Started periodic updates every {self._update_interval/1000} seconds"
)
def stop_monitoring(self):
"""Stop periodic unread count updates"""
if self._timer_id is not None:
from gi.repository import GLib
GLib.source_remove(self._timer_id)
self._timer_id = None
logger.info("[Notmuch] Stopped periodic updates")
def _periodic_update(self):
"""Periodic update callback"""
logger.info("[Notmuch] Performing periodic unread count update")
self.update_unread_count()
return True # Keep the timer running
def get_cached_count(self):
"""Get cached unread count without triggering update"""
return self.unread_count
def update_unread_count(self):
"""Fetch unread email count from notmuch"""
# Check if notmuch is enabled
if not NOTMUCH.get("enable", True):
logger.info("[Notmuch] Notmuch is disabled in config")
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
return
# Get notmuch path from config
notmuch_path = NOTMUCH.get("notmuch_path", "notmuch")
# Check if notmuch is available
if not shutil.which(notmuch_path):
logger.warning(f"[Notmuch] notmuch not found at '{notmuch_path}'. Please install notmuch or configure the correct path.")
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
return
try:
# Get unread email count
cmd = [notmuch_path, "count", "tag:unread"]
logger.info(f"[Notmuch] Running command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
logger.info(f"[Notmuch] Command stdout: '{result.stdout.strip()}'")
logger.info(f"[Notmuch] Command stderr: '{result.stderr.strip()}'")
if result.stdout.strip():
self.unread_count = int(result.stdout.strip())
logger.info(f"[Notmuch] Found {self.unread_count} unread emails")
self.emit_unread_changed(self.unread_count)
else:
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
except subprocess.CalledProcessError as e:
logger.error(f"[Notmuch] Failed to fetch unread count: {e}")
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
except ValueError as e:
logger.error(f"[Notmuch] Error parsing unread count: {e}")
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
except Exception as e:
logger.error(f"[Notmuch] Error getting unread count: {e}")
self.unread_count = 0
self.emit_unread_changed(self.unread_count)
class NotmuchWidget(Button):
def __init__(self, **kwargs):
# Create the widget content
self.icon = Image(icon_name="mail-unread-symbolic", icon_size=16)
self.label = Label("0", name="unread-count")
# Container for icon and label
container = Box(
orientation="h",
spacing=4,
children=[self.icon, self.label]
)
super().__init__(
name="notmuch-widget",
child=container,
on_clicked=self.open_email_client,
**kwargs,
)
# Initialize the service
self.service = NotmuchService()
self.service.connect("unread-changed", self.update_display)
logger.info("[Notmuch] Notmuch widget initialized")
# Initial update
self.update_display(self.service, self.service.unread_count)
def open_email_client(self, button=None):
"""Open notmuch in emacsclient"""
emacsclient_command = NOTMUCH.get("emacsclient_command", "emacsclient")
try:
# Open emacsclient with notmuch function
cmd = [emacsclient_command, "-c", "-e", "(notmuch)"]
logger.info(f"[Notmuch] Running emacsclient command: {' '.join(cmd)}")
subprocess.Popen(cmd, start_new_session=True)
logger.info(f"[Notmuch] Successfully started emacsclient process")
except Exception as e:
logger.error(f"[Notmuch] Failed to open notmuch in emacsclient '{emacsclient_command}': {e}")
def update_display(self, service, count):
"""Update the widget display with unread count"""
# Only show count if there are unread emails
if count > 0:
self.label.set_text(str(count))
self.label.set_visible(True)
self.icon.set_from_icon_name("mail-unread-symbolic", 16)
self.set_style_classes(["notmuch-widget", "has-unread"])
else:
self.label.set_text("")
self.label.set_visible(False)
self.icon.set_from_icon_name("mail-read-symbolic", 16)
self.set_style_classes(["notmuch-widget", "no-unread"])
logger.info(f"[Notmuch] Updated display: {count} unread emails")

View File

@@ -13,10 +13,29 @@ from fabric.widgets.stack import Stack
from ..widgets.circle_image import CircleImage from ..widgets.circle_image import CircleImage
import bar.modules.icons as icons import bar.modules.icons as icons
from bar.services.mpris import MprisPlayerManager, MprisPlayer from bar.services.mpris import MprisPlayerManager, MprisPlayer
from fabric import Fabricator
# from bar.modules.cavalcade import SpectrumRender # from bar.modules.cavalcade import SpectrumRender
def get_player_progress(fabricator, mpris_player):
"""Get player progress for Fabricator"""
if not mpris_player:
return (0, 0, 0.0)
try:
current = mpris_player.position
except Exception:
current = 0
try:
total = int(mpris_player.length or 0)
except Exception:
total = 0
progress = current / total if total > 0 else 0.0
return (current, total, progress)
def get_player_icon_markup_by_name(player_name): def get_player_icon_markup_by_name(player_name):
if player_name: if player_name:
pn = player_name.lower() pn = player_name.lower()

283
bar/modules/quick_menu.py Normal file
View File

@@ -0,0 +1,283 @@
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

374
bar/modules/stylix.py Normal file
View File

@@ -0,0 +1,374 @@
from bar.config import STYLIX
import tempfile
import os
def generate_stylix_css():
"""Generate CSS using Stylix colors if enabled"""
if not STYLIX.get("enable", False):
return None
colors = STYLIX.get("colors", {})
fonts = STYLIX.get("fonts", {})
# Default colors if Stylix is not properly configured
default_colors = {
"base00": "1e1e2e", # background
"base01": "313244", # lighter background
"base02": "45475a", # selection background
"base03": "585b70", # comments
"base04": "bac2de", # dark foreground
"base05": "cdd6f4", # foreground
"base06": "f5e0dc", # light foreground
"base07": "b4befe", # light background
"base08": "f38ba8", # red
"base09": "fab387", # orange
"base0A": "f9e2af", # yellow
"base0B": "a6e3a1", # green
"base0C": "94e2d5", # cyan
"base0D": "89b4fa", # blue
"base0E": "cba6f7", # purple
"base0F": "f2cdcd", # brown
}
# Use Stylix colors or fallback to defaults
for key in default_colors:
if key not in colors:
colors[key] = default_colors[key]
# Default font
font_family = fonts.get("sansSerif", "sans-serif")
font_sizes = fonts.get("sizes", {})
# Use desktop font size for the bar, fallback to applications, then default
font_size = font_sizes.get("desktop", font_sizes.get("applications", 14))
# Calculate relative font sizes
small_font = max(int(font_size * 0.85), 10) # Minimum 10px
large_font = int(font_size * 1.1)
# Debug logging
from loguru import logger
logger.info(f"[Stylix] Using font sizes - Base: {font_size}px, Small: {small_font}px, Large: {large_font}px")
# Generate GTK CSS with Stylix colors
css_content = f"""/* Stylix-generated theme */
/* Apply Stylix font */
* {{
font-family: "{font_family}", sans-serif;
font-size: {font_size}px;
}}
/* Bar styling */
#bar-inner {{
padding: 4px;
border-bottom: solid 2px;
border-color: #{colors["base02"]};
background-color: #{colors["base00"]};
}}
#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"]};
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"]};
}}
/* 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;
}}
/* Generic popup styling */
.popup-window,
#calendar-popup,
#quick-menu {{
background-color: #{colors["base00"]};
border: solid 2px #{colors["base02"]};
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
animation: slide-down 200ms ease-out;
/* Add subtle inner glow for better depth perception */
outline: 1px solid rgba(255, 255, 255, 0.05);
outline-offset: -1px;
}}
@keyframes slide-down {{
from {{
opacity: 0;
margin-top: -20px;
}}
to {{
opacity: 1;
margin-top: 10px;
}}
}}
#calendar-title {{
color: #{colors["base05"]};
font-weight: bold;
font-size: {large_font}px;
margin-bottom: 8px;
}}
#events-box {{
background-color: #{colors["base00"]};
border: solid 1px #{colors["base02"]};
border-radius: 8px;
padding: 16px;
}}
#no-events {{
color: #{colors["base03"]};
}}
/* Calendar event items */
.event-item {{
border-radius: 6px;
padding: 8px 12px;
margin: 4px 0px;
transition: background-color 0.15s ease;
}}
#event-content {{
margin-left: 8px;
}}
.event-item.upcoming {{
background-color: #{colors["base01"]};
}}
.event-item.past {{
background-color: #{colors["base01"]};
opacity: 0.6;
}}
.event-title {{
font-weight: bold;
font-size: {font_size}px;
}}
.event-title.upcoming {{
color: #{colors["base05"]};
}}
.event-title.past {{
color: #{colors["base04"]};
}}
.event-time {{
font-size: {small_font}px;
}}
.event-time.upcoming {{
color: #{colors["base04"]};
}}
.event-time.past {{
color: #{colors["base03"]};
}}
.event-location {{
font-size: {small_font}px;
}}
.event-location.upcoming {{
color: #{colors["base03"]};
}}
.event-location.past {{
color: #{colors["base03"]};
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"]};
border-radius: 100px;
padding: 0px 4px;
transition: padding 0.05s steps(8);
}}
#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"]};
padding: 0px 16px;
border-radius: 100px;
}}
#workspaces>button.urgent {{
background-color: #{colors["base08"]};
}}
#workspaces>button>label {{
font-size: 0px;
}}
/* Quick Menu styling */
#quick-menu-container {{
background-color: #{colors["base00"]};
border-radius: 8px;
}}
#quick-menu-button {{
background: transparent;
border: none;
padding: 4px;
margin: 0;
box-shadow: none;
color: #{colors["base05"]};
}}
#quick-menu-button:hover {{
background-color: #{colors["base01"]};
border-radius: 8px;
}}
.quick-menu-item {{
border-radius: 6px;
transition: background-color 0.15s ease;
}}
.quick-menu-item:hover {{
background-color: #{colors["base01"]};
}}
.section-title {{
color: #{colors["base04"]};
font-weight: bold;
font-size: {small_font}px;
}}
/* Vinyl button styling */
#vinyl-button {{
background-color: transparent;
color: #{colors["base05"]};
border: none;
padding: 4px;
margin: 0px;
border-radius: 8px;
}}
#vinyl-button.active {{
background-color: #{colors["base0B"]};
color: #{colors["base00"]};
}}
#vinyl-icon {{
color: inherit;
}}
/* Toggle switch styling for quick menu */
.toggle-active {{
background-color: #{colors["base0B"]};
}}
.toggle-inactive {{
background-color: #{colors["base02"]};
}}
"""
# Write to temporary file
temp_fd, temp_path = tempfile.mkstemp(suffix=".css", prefix="stylix_")
try:
with os.fdopen(temp_fd, "w") as f:
f.write(css_content)
return temp_path
except Exception:
os.close(temp_fd)
return None
def get_stylix_css_path():
"""Get the path to the Stylix CSS file"""
return generate_stylix_css()

View File

@@ -1,12 +1,10 @@
from fabric.widgets.box import Box from fabric.widgets.button import Button
from fabric.widgets.label import Label from fabric.widgets.image import Image
from fabric.widgets.eventbox import EventBox
from fabric.widgets.overlay import Overlay
from fabric.core.service import Property from fabric.core.service import Property
import subprocess import subprocess
class VinylButton(Box): class VinylButton(Button):
@Property(bool, "read-write", default_value=False) @Property(bool, "read-write", default_value=False)
def active(self) -> bool: def active(self) -> bool:
return self._active return self._active
@@ -35,35 +33,26 @@ class VinylButton(Box):
], ],
**kwargs, **kwargs,
): ):
super().__init__(**kwargs)
# Initialize properties # Initialize properties
self._active = False self._active = False
self._active_command = active_command self._active_command = active_command
self._inactive_command = inactive_command self._inactive_command = inactive_command
# Set up the icon # Set up the icon using GTK icon
self.icon = Label( self.icon = Image(
label="", # CD icon icon_name="folder-music-symbolic",
icon_size=16,
name="vinyl-icon", name="vinyl-icon",
style="",
) )
# Set up event box to handle clicks # Initialize the Button with the icon as child
self.event_box = EventBox( super().__init__(
events="button-press",
child=Overlay(
child=self.icon,
),
name="vinyl-button", name="vinyl-button",
child=self.icon,
on_clicked=self._on_clicked,
**kwargs,
) )
# Connect click event
self.event_box.connect("button-press-event", self._on_clicked)
# Add to parent box
self.add(self.event_box)
# Initialize appearance # Initialize appearance
self._update_appearance() self._update_appearance()
@@ -74,12 +63,10 @@ class VinylButton(Box):
else: else:
self.remove_style_class("active") self.remove_style_class("active")
def _on_clicked(self, _, event): def _on_clicked(self, button=None):
"""Handle button click event""" """Handle button click event"""
if event.button == 1: # Left click
# Toggle active state # Toggle active state
self.active = not self.active self.active = not self.active
return True
def _execute_active_command(self): def _execute_active_command(self):
"""Execute shell command when button is activated""" """Execute shell command when button is activated"""

View File

@@ -1,10 +1,10 @@
import operator from fabric.i3 import I3, I3MessageType
from fabric.widgets.wayland import WaylandWindow as Window from fabric.widgets.wayland import WaylandWindow as Window
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.entry import Entry from fabric.widgets.entry import Entry
from fabric.utils import idle_add
from gi.repository import Gdk from gi.repository import Gdk
from bar.services.fenster import get_i3_connection
class FuzzyWindowFinder(Window): class FuzzyWindowFinder(Window):
@@ -21,7 +21,9 @@ class FuzzyWindowFinder(Window):
visible=False, visible=False,
) )
self._all_windows = ["Test", "Uwu", "Tidal"] self._i3 = get_i3_connection()
self._all_windows = []
self._refresh_windows()
self.viewport = Box(name="viewport", spacing=4, orientation="v") self.viewport = Box(name="viewport", spacing=4, orientation="v")
@@ -46,30 +48,81 @@ class FuzzyWindowFinder(Window):
self.add(self.picker_box) self.add(self.picker_box)
self.arrange_viewport("") self.arrange_viewport("")
def _refresh_windows(self):
"""Refresh the window list via GET_TREE"""
self._all_windows = []
tree_reply = I3.send_command("", I3MessageType.GET_TREE)
if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)):
return
tree = tree_reply.reply
# Traverse: root → outputs → workspaces → containers
for output_node in tree.get("nodes", []):
for ws_node in output_node.get("nodes", []):
ws_num = ws_node.get("num", 0)
for con in ws_node.get("nodes", []):
if con.get("type") == "con":
self._all_windows.append({
"id": con.get("id"),
"app_id": con.get("app_id", ""),
"title": con.get("name", ""),
"workspace": ws_num,
})
for con in ws_node.get("floating_nodes", []):
if con.get("type") == "con":
self._all_windows.append({
"id": con.get("id"),
"app_id": con.get("app_id", ""),
"title": con.get("name", ""),
"workspace": ws_num,
})
def show(self):
"""Override show to refresh windows before displaying"""
self._refresh_windows()
self.arrange_viewport(self.search_entry.get_text())
super().show()
def notify_text(self, entry, *_): def notify_text(self, entry, *_):
text = entry.get_text() text = entry.get_text()
self.arrange_viewport(text) # Update list on typing self.arrange_viewport(text)
print(text)
def on_search_entry_key_press(self, widget, event): def on_search_entry_key_press(self, widget, event):
# if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
# self.move_selection_2d(event.keyval)
# return True
print(event.keyval)
if event.keyval in [Gdk.KEY_Escape, 103]: if event.keyval in [Gdk.KEY_Escape, 103]:
self.hide() self.hide()
return True return True
return False return False
def on_search_entry_activate(self, text): def on_search_entry_activate(self, text):
print(f"activate {text}") """Focus the first matching window"""
filtered = self._filter_windows(text)
if filtered:
window_id = filtered[0].get("id")
if window_id is not None:
I3.send_command(f"[con_id={window_id}] focus")
self.hide()
def _filter_windows(self, query: str) -> list:
"""Filter windows based on query matching title or app_id"""
if not query:
return self._all_windows
query_lower = query.lower()
return [
w for w in self._all_windows
if query_lower in w.get("title", "").lower()
or query_lower in w.get("app_id", "").lower()
]
def arrange_viewport(self, query: str = ""): def arrange_viewport(self, query: str = ""):
self.viewport.children = [] # Clear previous entries self.viewport.children = [] # Clear previous entries
filtered = [w for w in self._all_windows if query.lower() in w.lower()] filtered = self._filter_windows(query)
for window in filtered: for window in filtered:
title = window.get("title", "")
app_id = window.get("app_id", "")
ws_num = window.get("workspace", 0)
display_text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}"
self.viewport.add( self.viewport.add(
Box(name="slot-box", orientation="h", children=[Label(label=window)]) Box(name="slot-box", orientation="h", children=[Label(label=display_text)])
) )

72
bar/services/battery.py Normal file
View File

@@ -0,0 +1,72 @@
import psutil
from fabric.core.service import Service, Signal
from fabric.utils import invoke_repeater
class BatteryService(Service):
@Signal
def battery_changed(self, percent: float, charging: bool) -> None:
"""Signal emitted when battery status changes"""
pass
def __init__(self, update_interval=10000, **kwargs): # Check every 10 seconds
super().__init__(**kwargs)
self._percent = 0.0
self._charging = False
self._update_interval = update_interval
self._timer_id = None
# Start periodic updates
self.start_monitoring()
def start_monitoring(self):
"""Start monitoring battery status"""
if self._timer_id is None:
# Get initial values
self._update_battery()
# Set up periodic updates
self._timer_id = invoke_repeater(self._update_interval, self._update_battery)
def stop_monitoring(self):
"""Stop monitoring battery status"""
if self._timer_id is not None:
from gi.repository import GLib
GLib.source_remove(self._timer_id)
self._timer_id = None
def _update_battery(self):
"""Update battery status and emit signal if changed"""
try:
# Use the same pattern as the example
bat_sen = psutil.sensors_battery()
if not bat_sen:
# No battery sensor available (desktop systems)
new_percent = 100.0 # Assume plugged in
new_charging = True
else:
new_percent = bat_sen.percent
new_charging = bat_sen.power_plugged
# Only emit signal if values changed
percent_changed = abs(new_percent - self._percent) > 0.5
charging_changed = new_charging != self._charging
if percent_changed or charging_changed:
self._percent = new_percent
self._charging = new_charging
self.battery_changed(new_percent, new_charging)
except Exception as e:
print(f"Error updating battery status: {e}")
return True # Keep the timer running
@property
def percent(self):
"""Get current battery percentage"""
return self._percent
@property
def charging(self):
"""Get current charging status"""
return self._charging

29
bar/services/fenster.py Normal file
View File

@@ -0,0 +1,29 @@
"""
Fenster/Sway IPC connection helper.
Provides a singleton I3 connection configured for Fenster's SWAYSOCK.
"""
import os
from fabric.i3 import I3
_connection: I3 | None = None
def get_i3_connection() -> I3:
"""Get the singleton I3 connection, configured for Fenster."""
global _connection
if _connection is None:
swaysock = os.environ.get("SWAYSOCK")
if swaysock:
I3.SOCKET_PATH = swaysock
elif not I3.SOCKET_PATH:
runtime_dir = os.environ.get(
"XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"
)
fallback = os.path.join(runtime_dir, "fenster.sock")
if os.path.exists(fallback):
I3.SOCKET_PATH = fallback
_connection = I3()
return _connection

View File

@@ -0,0 +1,66 @@
import psutil
from fabric.core.service import Service, Signal
from fabric.utils import invoke_repeater
class SystemStatsService(Service):
@Signal
def stats_changed(self, cpu_percent: float, memory_percent: float) -> None:
"""Signal emitted when system stats change"""
pass
def __init__(self, update_interval=3000, **kwargs):
super().__init__(**kwargs)
self._cpu_percent = 0.0
self._memory_percent = 0.0
self._update_interval = update_interval
self._timer_id = None
# Start periodic updates
self.start_monitoring()
def start_monitoring(self):
"""Start monitoring system stats"""
if self._timer_id is None:
# Get initial values
self._update_stats()
# Set up periodic updates
self._timer_id = invoke_repeater(self._update_interval, self._update_stats)
def stop_monitoring(self):
"""Stop monitoring system stats"""
if self._timer_id is not None:
from gi.repository import GLib
GLib.source_remove(self._timer_id)
self._timer_id = None
def _update_stats(self):
"""Update system stats and emit signal if changed"""
try:
new_cpu = psutil.cpu_percent()
new_memory = psutil.virtual_memory().percent
# Only emit signal if values changed significantly (reduce noise)
cpu_changed = abs(new_cpu - self._cpu_percent) > 1.0
memory_changed = abs(new_memory - self._memory_percent) > 1.0
if cpu_changed or memory_changed:
self._cpu_percent = new_cpu
self._memory_percent = new_memory
self.stats_changed(new_cpu / 100, new_memory / 100)
except Exception as e:
print(f"Error updating system stats: {e}")
return True # Keep the timer running
@property
def cpu_percent(self):
"""Get current CPU percentage"""
return self._cpu_percent / 100
@property
def memory_percent(self):
"""Get current memory percentage"""
return self._memory_percent / 100

View File

@@ -3,7 +3,6 @@
border-bottom: solid 2px; border-bottom: solid 2px;
border-color: var(--border-color); border-color: var(--border-color);
background-color: var(--window-bg); background-color: var(--window-bg);
min-height: 28px;
} }
#center-container { #center-container {
@@ -15,6 +14,27 @@
font-weight: bold; font-weight: bold;
} }
#battery-widget {
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 4px;
}
#bat-icon {
color: var(--blue);
margin-right: 2px;
}
#bat-label {
color: var(--foreground);
font-size: 14px;
}
#bat-label.battery-low {
color: var(--red);
font-weight: bold;
}
#cpu-progress-bar, #cpu-progress-bar,
#ram-progress-bar, #ram-progress-bar,
#volume-progress-bar { #volume-progress-bar {

98
bar/styles/calendar.css Normal file
View File

@@ -0,0 +1,98 @@
/* 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 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 */
.event-item {
border-radius: 6px;
padding: 8px 12px;
margin: 4px 0px;
background-color: var(--module-bg);
border: none; /* Remove outline */
transition: background-color 0.15s ease;
}
#event-content {
margin-left: 8px;
}
.event-title {
font-weight: bold;
color: var(--foreground);
}
.event-time {
color: var(--dark-fg);
}
.event-location {
color: var(--light-grey);
}
/* Current time indicator */
.current-time-indicator {
margin: 8px 0px;
padding: 4px 0px;
}
#current-time-label {
color: var(--blue);
font-weight: bold;
}
#current-time-line {
color: var(--blue);
font-weight: bold;
}

View File

@@ -20,8 +20,9 @@
--window-bg: alpha(var(--background), 0.9); --window-bg: alpha(var(--background), 0.9);
--module-bg: alpha(var(--mid-bg), 0.8); --module-bg: alpha(var(--mid-bg), 0.8);
--border-color: var(--light-bg); --border-color: var(--light-bg);
--ws-active: var(--pink); --ws-active: var(--blue);
--ws-inactive: var(--blue); --ws-visible: var(--violet);
--ws-inactive: var(--light-grey);
--ws-empty: var(--dark-grey); --ws-empty: var(--dark-grey);
--ws-hover: var(--turquoise); --ws-hover: var(--turquoise);
--ws-urgent: var(--red); --ws-urgent: var(--red);

View File

@@ -4,6 +4,8 @@
@import url("./vinyl.css"); @import url("./vinyl.css");
@import url("./bar.css"); @import url("./bar.css");
@import url("./finder.css"); @import url("./finder.css");
@import url("./calendar.css");
@import url("./notmuch.css");
/* unset so we can style everything from the ground up. */ /* unset so we can style everything from the ground up. */

35
bar/styles/notmuch.css Normal file
View File

@@ -0,0 +1,35 @@
/* Notmuch email widget styling */
#notmuch-widget {
background-color: var(--module-bg);
padding: 4px 8px;
border-radius: 12px;
transition: background-color 0.2s ease;
}
#notmuch-widget:hover {
background-color: var(--light-bg);
}
#notmuch-widget.has-unread {
background-color: var(--blue);
}
#notmuch-widget.has-unread:hover {
background-color: var(--turquoise);
}
#notmuch-widget.no-unread {
background-color: var(--module-bg);
}
#unread-count {
color: var(--foreground);
font-size: 14px;
font-weight: bold;
min-width: 16px;
}
#notmuch-widget.has-unread #unread-count {
color: var(--background);
}

View File

@@ -1,41 +1,29 @@
/* Vinyl button styling */ /* Vinyl button styling */
#vinyl-button { #vinyl-button {
padding: 0px 8px; background-color: var(--module-bg);
transition: padding 0.05s steps(8); padding: 4px 8px;
background-color: rgba(180, 180, 180, 0.2); border-radius: 12px;
border-radius: 4px; transition: background-color 0.2s ease;
transition: all 0.2s ease; }
#vinyl-button:hover {
background-color: var(--light-bg);
} }
/* Active state styling */ /* Active state styling */
.active #vinyl-button { #vinyl-button.active {
background-color: rgba(108, 158, 175, 0.7); background-color: var(--pink);
padding: 0px 32px; }
#vinyl-button.active:hover {
background-color: var(--turquoise);
} }
/* Icon styling */ /* Icon styling */
#vinyl-icon { #vinyl-icon {
color: #555555; color: var(--foreground);
min-width: 36px;
} }
/* Label styling */ #vinyl-button.active #vinyl-icon {
#vinyl-label { color: var(--background);
color: #333333;
}
/* Active state changes for icon and label */
.active #vinyl-icon,
.active #vinyl-label {
color: var(--pink);
padding: 0px 32px;
}
/* Hover effect */
#vinyl-button:hover {
background-color: rgba(180, 180, 180, 0.4);
}
.active #vinyl-button:hover {
background-color: rgba(108, 158, 175, 0.9);
} }

View File

@@ -6,8 +6,8 @@
#workspaces>button { #workspaces>button {
padding: 0px 8px; padding: 0px 8px;
transition: padding 0.05s steps(8); transition: padding 0.05s steps(8), background-color 0.15s ease;
background-color: var(--foreground); background-color: var(--ws-inactive);
border-radius: 100px; border-radius: 100px;
} }
@@ -15,11 +15,24 @@
font-size: 0px; font-size: 0px;
} }
#workspaces button.hover { #workspaces>button:hover {
background-color: var(--ws-hover); background-color: var(--ws-hover);
} }
#workspaces button.urgent { #workspaces>button.empty {
background-color: var(--ws-empty);
}
#workspaces>button.visible {
background-color: var(--ws-visible);
}
#workspaces>button.active {
padding: 0px 32px;
background-color: var(--ws-active);
}
#workspaces>button.urgent {
background-color: var(--ws-urgent); background-color: var(--ws-urgent);
color: var(--foreground); color: var(--foreground);
font-weight: bold; font-weight: bold;
@@ -31,12 +44,3 @@
50% { opacity: 0.5; } 50% { opacity: 0.5; }
100% { opacity: 1.0; } 100% { opacity: 1.0; }
} }
#workspaces>button.empty {
background-color: var(--ws-empty);
}
#workspaces>button.active {
padding: 0px 32px;
background-color: var(--ws-active);
}

0
bar/widgets/battery.py Normal file
View File

228
bar/widgets/fenster.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Fenster widgets for workspace and window management via sway IPC.
"""
from gi.repository import GLib
from fabric.i3 import I3, I3Event, I3MessageType
from fabric.utils.helpers import bulk_connect
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from bar.services.fenster import get_i3_connection
class FensterWorkspaceButton(Button):
"""Button representing a single workspace"""
def __init__(
self,
workspace_num: int,
i3: I3 | None = None,
label: str | None = None,
**kwargs,
):
self._workspace_num = workspace_num
self._i3 = i3 or get_i3_connection()
display_label = label if label is not None else str(workspace_num)
super().__init__(
name=f"workspace-button-{workspace_num}",
child=Label(label=display_label),
on_clicked=self._on_clicked,
**kwargs,
)
self.add_style_class("workspace-button")
@property
def workspace_num(self) -> int:
return self._workspace_num
def _on_clicked(self, *args):
self._i3.send_command(f"workspace number {self._workspace_num}")
def _toggle_class(self, name: str, on: bool):
if on:
self.add_style_class(name)
else:
self.remove_style_class(name)
def set_active(self, active: bool):
self._toggle_class("active", active)
def set_visible_other(self, visible: bool):
self._toggle_class("visible", visible)
def set_empty(self, empty: bool):
self._toggle_class("empty", empty)
def set_urgent(self, urgent: bool):
self._toggle_class("urgent", urgent)
class FensterWorkspaces(Box):
"""Container widget showing a fixed set of workspace bubbles (1..N)."""
def __init__(
self,
output: str | None = None,
i3: I3 | None = None,
buttons_factory=None,
workspace_count: int = 9,
**kwargs,
):
super().__init__(
name=kwargs.pop("name", "workspaces"),
spacing=kwargs.pop("spacing", 4),
orientation="h",
**kwargs,
)
self._output = output
self._workspace_count = workspace_count
self._i3 = i3 or get_i3_connection()
self._buttons_factory = buttons_factory or self._default_button_factory
self._buttons: dict[int, FensterWorkspaceButton] = {}
self._refresh_pending = False
# Pre-create one button per workspace slot so position N always means workspace N.
for n in range(1, workspace_count + 1):
button = self._buttons_factory(n)
self._buttons[n] = button
self.add(button)
bulk_connect(
self._i3,
{
"event::workspace::focus": self._on_event,
"event::workspace::init": self._on_event,
"event::workspace::empty": self._on_event,
"event::workspace::urgent": self._on_event,
"event::workspace::move": self._on_event,
"event::window::focus": self._on_event,
"event::window::new": self._on_event,
"event::window::close": self._on_event,
},
)
if self._i3.ready:
self._schedule_refresh()
else:
self._i3.connect("notify::ready", lambda *_: self._schedule_refresh())
def _default_button_factory(self, workspace_num: int) -> FensterWorkspaceButton:
return FensterWorkspaceButton(workspace_num=workspace_num, i3=self._i3)
def _on_event(self, _, event: I3Event):
self._schedule_refresh()
def _schedule_refresh(self):
# Defer to the next idle tick — fenster's internal state is not always
# updated synchronously when an event fires, so querying GET_WORKSPACES
# immediately can return the pre-event view.
if self._refresh_pending:
return
self._refresh_pending = True
GLib.idle_add(self._refresh_idle)
def _refresh_idle(self):
self._refresh_pending = False
self._refresh_workspaces()
return False
def _refresh_workspaces(self):
reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
if reply.is_ok and isinstance(reply.reply, list):
self._update_workspaces(reply.reply)
def _update_workspaces(self, workspaces: list):
ws_by_num = {
ws["num"]: ws for ws in workspaces if ws.get("num") is not None
}
for n, button in self._buttons.items():
ws = ws_by_num.get(n)
if ws is None:
button.set_active(False)
button.set_visible_other(False)
button.set_urgent(False)
button.set_empty(True)
continue
focused = bool(ws.get("focused"))
visible = bool(ws.get("visible"))
urgent = bool(ws.get("urgent"))
window_count = ws.get("window_count", 0)
button.set_active(focused)
# Visible on its output but not the focused one → shown on another monitor.
button.set_visible_other(visible and not focused)
button.set_urgent(urgent)
button.set_empty(window_count == 0)
self.show_all()
class FensterActiveWindow(Label):
"""Label showing the title of the focused window"""
def __init__(
self,
i3: I3 | None = None,
max_length: int = 50,
**kwargs,
):
super().__init__(
name=kwargs.pop("name", "active-window"),
label="",
**kwargs,
)
self._i3 = i3 or get_i3_connection()
self._max_length = max_length
bulk_connect(
self._i3,
{
"event::window::focus": self._on_window_event,
"event::window::title": self._on_window_event,
"event::window::close": self._on_window_close,
},
)
if self._i3.ready:
self._initialize()
else:
self._i3.connect("notify::ready", lambda *_: self._initialize())
def _initialize(self):
tree_reply = I3.send_command("", I3MessageType.GET_TREE)
if tree_reply.is_ok and isinstance(tree_reply.reply, dict):
focused = self._find_focused(tree_reply.reply)
if focused:
self._set_title(focused.get("name", ""))
return
self.set_label("")
def _find_focused(self, node: dict) -> dict | None:
if node.get("focused") and node.get("type") == "con":
return node
for child in node.get("nodes", []) + node.get("floating_nodes", []):
result = self._find_focused(child)
if result:
return result
return None
def _on_window_event(self, _, event: I3Event):
container = event.data.get("container", {})
self._set_title(container.get("name", ""))
def _on_window_close(self, _, event: I3Event):
self._initialize()
def _set_title(self, title: str):
if len(title) > self._max_length:
title = title[: self._max_length - 3] + "..."
self.set_label(title)

43
example-stylix-dev.yaml Normal file
View File

@@ -0,0 +1,43 @@
height: 42
dev: true
window_title:
enable: false
vinyl:
enable: false
battery:
enable: true
calendar:
enable: true
khal_path: "khal" # or full path like "/home/user/.nix-profile/bin/khal"
notmuch:
enable: true
notmuch_path: "notmuch" # or full path like "/home/user/.nix-profile/bin/notmuch"
emacsclient_command: "emacsclient" # or full path like "/home/user/.nix-profile/bin/emacsclient"
stylix:
enable: true
colors:
base00: "1e1e2e" # background
base01: "313244" # lighter background
base02: "45475a" # selection background
base03: "585b70" # comments
base04: "bac2de" # dark foreground
base05: "cdd6f4" # foreground
base06: "f5e0dc" # light foreground
base07: "b4befe" # light background
base08: "f38ba8" # red
base09: "fab387" # orange
base0A: "f9e2af" # yellow
base0B: "a6e3a1" # green
base0C: "94e2d5" # cyan
base0D: "89b4fa" # blue
base0E: "cba6f7" # purple
base0F: "f2cdcd" # brown
fonts:
sansSerif: "Inter"
serif: "Times New Roman"
monospace: "JetBrains Mono"
sizes:
desktop: 16
applications: 14
terminal: 16
popups: 14

View File

@@ -1,2 +1,7 @@
bar_height: 42
window_title:
enable: false
vinyl: vinyl:
enabled: true enable: true
battery:
enable: true

10
flake.lock generated
View File

@@ -6,15 +6,15 @@
"utils": "utils" "utils": "utils"
}, },
"locked": { "locked": {
"lastModified": 1747045720, "lastModified": 1770146720,
"narHash": "sha256-2Z0F4hnluJZunwRfx80EQXpjGLhunV2wrseT42nzh7M=", "narHash": "sha256-YVlwsUz4SLj8qYAb21ernT3lDB/piU1V6hTW/UjikWA=",
"owner": "Makesesama", "owner": "Fabric-Development",
"repo": "fabric", "repo": "fabric",
"rev": "dae50c763e8bf2b4e5807b49b9e62425e0725cfa", "rev": "fd2aabbd7e1859aa7c11c626a6c36a937aca736a",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "Makesesama", "owner": "Fabric-Development",
"repo": "fabric", "repo": "fabric",
"type": "github" "type": "github"
} }

View File

@@ -5,7 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs/24.11"; nixpkgs.url = "github:NixOS/nixpkgs/24.11";
unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
utils.url = "github:numtide/flake-utils"; utils.url = "github:numtide/flake-utils";
fabric.url = "github:Makesesama/fabric"; fabric.url = "github:Fabric-Development/fabric";
home-manager.url = "github:nix-community/home-manager"; home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs"; home-manager.inputs.nixpkgs.follows = "nixpkgs";
}; };
@@ -37,6 +37,9 @@
makku = pkgs.writeShellScriptBin "makku" '' makku = pkgs.writeShellScriptBin "makku" ''
dbus-send --session --print-reply --dest=org.Fabric.fabric.bar /org/Fabric/fabric org.Fabric.fabric.Evaluate string:"finder.show()" > /dev/null 2>&1 dbus-send --session --print-reply --dest=org.Fabric.fabric.bar /org/Fabric/fabric org.Fabric.fabric.Evaluate string:"finder.show()" > /dev/null 2>&1
''; '';
notmuch-refresh = pkgs.writeShellScriptBin "notmuch-refresh" ''
dbus-send --session --print-reply --dest=org.Fabric.fabric.bar /org/Fabric/fabric org.Fabric.fabric.Evaluate string:"notmuch_widget.service.update_unread_count() if notmuch_widget else None" > /dev/null 2>&1
'';
}; };
apps.default = { apps.default = {
type = "app"; type = "app";
@@ -45,7 +48,8 @@
} }
) )
// { // {
homeManagerModules.makku-bar = homeManagerModules = {
makku-bar =
{ {
config, config,
lib, lib,
@@ -76,10 +80,81 @@
default = false; default = false;
}; };
}; };
battery = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
};
};
height = lib.mkOption {
type = lib.types.int;
default = 40;
description = "Height of the status bar in pixels";
};
logLevel = lib.mkOption {
type = lib.types.enum [ "TRACE" "DEBUG" "INFO" "SUCCESS" "WARNING" "ERROR" "CRITICAL" ];
default = "WARNING";
description = "Log level for the status bar (loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL)";
};
window_title = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to show the window title in the center of the bar";
};
};
stylix = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { enable = false; };
description = "Stylix configuration passed from the stylix module";
};
calendar = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the calendar widget";
};
khal_path = lib.mkOption {
type = lib.types.str;
default = "khal";
description = "Path to the khal binary";
};
};
notmuch = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the notmuch email widget";
};
notmuch_path = lib.mkOption {
type = lib.types.str;
default = "notmuch";
description = "Path to the notmuch binary";
};
emacsclient_command = lib.mkOption {
type = lib.types.str;
default = "emacsclient";
description = "Path to the emacsclient binary";
};
};
}; };
}; };
default = { default = {
vinyl.enable = false; vinyl.enable = false;
battery.enable = false;
height = 40;
logLevel = "WARNING";
window_title.enable = true;
stylix.enable = false;
calendar = {
enable = true;
khal_path = "khal";
};
notmuch = {
enable = true;
notmuch_path = "notmuch";
emacsclient_command = "emacsclient";
};
}; };
}; };
}; };
@@ -106,5 +181,7 @@
}; };
}; };
}; };
stylix-makku-bar = import ./nix/stylix/hm.nix;
};
}; };
} }

View File

@@ -12,6 +12,9 @@
wrapGAppsHook3, wrapGAppsHook3,
playerctl, playerctl,
webp-pixbuf-loader, webp-pixbuf-loader,
notmuch,
khal,
emacs,
... ...
}: }:
@@ -38,6 +41,8 @@ python3Packages.buildPythonApplication {
gdk-pixbuf gdk-pixbuf
playerctl playerctl
webp-pixbuf-loader webp-pixbuf-loader
notmuch
khal
]; ];
dependencies = with python3Packages; [ dependencies = with python3Packages; [
@@ -60,13 +65,19 @@ python3Packages.buildPythonApplication {
cp scripts/launcher.py $out/bin/bar cp scripts/launcher.py $out/bin/bar
chmod +x $out/bin/bar chmod +x $out/bin/bar
runHook postInstall runHook postInstall
''; '';
preFixup = '' preFixup = ''
makeWrapperArgs+=("''${gappsWrapperArgs[@]}") makeWrapperArgs+=("''${gappsWrapperArgs[@]}")
makeWrapperArgs+=(--prefix PATH : ${lib.makeBinPath [ khal notmuch emacs ]})
''; '';
passthru = {
inherit khal notmuch emacs;
};
meta = { meta = {
changelog = ""; changelog = "";
description = '' description = ''

44
nix/stylix/hm.nix Normal file
View File

@@ -0,0 +1,44 @@
{ config, lib, ... }:
let
cfg = config.stylix.targets.makku-bar;
in
{
options.stylix.targets.makku-bar.enable =
config.lib.stylix.mkEnableTarget "Makku Bar" true;
config = lib.mkIf (config.stylix.enable && cfg.enable) {
services.makku-bar.settings.stylix = {
enable = true;
colors = {
base00 = config.lib.stylix.colors.base00; # background
base01 = config.lib.stylix.colors.base01; # lighter background
base02 = config.lib.stylix.colors.base02; # selection background
base03 = config.lib.stylix.colors.base03; # comments
base04 = config.lib.stylix.colors.base04; # dark foreground
base05 = config.lib.stylix.colors.base05; # foreground
base06 = config.lib.stylix.colors.base06; # light foreground
base07 = config.lib.stylix.colors.base07; # light background
base08 = config.lib.stylix.colors.base08; # red
base09 = config.lib.stylix.colors.base09; # orange
base0A = config.lib.stylix.colors.base0A; # yellow
base0B = config.lib.stylix.colors.base0B; # green
base0C = config.lib.stylix.colors.base0C; # cyan
base0D = config.lib.stylix.colors.base0D; # blue
base0E = config.lib.stylix.colors.base0E; # purple
base0F = config.lib.stylix.colors.base0F; # brown
};
fonts = {
serif = config.stylix.fonts.serif.name;
sansSerif = config.stylix.fonts.sansSerif.name;
monospace = config.stylix.fonts.monospace.name;
sizes = {
desktop = config.stylix.fonts.sizes.desktop;
applications = config.stylix.fonts.sizes.applications;
terminal = config.stylix.fonts.sizes.terminal;
popups = config.stylix.fonts.sizes.popups;
};
};
};
};
}

6
nix/stylix/meta.nix Normal file
View File

@@ -0,0 +1,6 @@
{ lib }:
{
name = "Makku Bar";
homepage = "https://github.com/Makesesama/makku-bar";
maintainers = [ ];
}