Compare commits
27 Commits
aee5be7f39
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da7e97f19 | |||
| cfdcf0c039 | |||
| 4adace6c4c | |||
| cc3bac5ce7 | |||
| 81e1a1fc1f | |||
| d14a0c9678 | |||
| 47e104465e | |||
| 4a271ac4d8 | |||
| 5e8f9e44bb | |||
| 047a85925a | |||
| 4c8b9020b0 | |||
| e7a1a2c0ee | |||
| cb3b7bf392 | |||
| f83b7ce874 | |||
| ec7e2cd6fd | |||
| 6f4e397f6c | |||
| aad8121fa4 | |||
| 709695fd22 | |||
| 1d98a541de | |||
| af978f5baf | |||
| 6343c91fca | |||
| 0cd58f4a1f | |||
| afcf8d51fe | |||
| 7bdf23001f | |||
| 60757ee336 | |||
| c10709df26 | |||
| fb206667c4 |
15
Makefile
15
Makefile
@@ -1,2 +1,15 @@
|
||||
run:
|
||||
python -m bar.main --config ./example-stylix-dev.yaml
|
||||
python -m sims.main --config ./example-stylix-dev.yaml
|
||||
|
||||
# Talk to the running sims daemon over DBus.
|
||||
# Usage: make cli list
|
||||
# make cli finder
|
||||
# make cli screenrec stop
|
||||
# make cli ARGS="list --json" # for flags (make eats leading dashes)
|
||||
ifeq (cli,$(firstword $(MAKECMDGOALS)))
|
||||
CLI_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
||||
$(eval $(CLI_ARGS):;@:)
|
||||
endif
|
||||
|
||||
cli:
|
||||
@python -m sims.cli $(if $(ARGS),$(ARGS),$(CLI_ARGS))
|
||||
|
||||
91
bar/main.py
91
bar/main.py
@@ -1,91 +0,0 @@
|
||||
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.i3 import I3, I3MessageType
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.utils import (
|
||||
get_relative_path,
|
||||
)
|
||||
from .modules.bar import StatusBar
|
||||
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)
|
||||
i3 = get_i3_connection()
|
||||
|
||||
dummy = Window(visible=False)
|
||||
finder = FuzzyWindowFinder()
|
||||
|
||||
bar_windows = []
|
||||
notmuch_widget = None
|
||||
|
||||
app = Application("bar", dummy, finder)
|
||||
|
||||
# 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():
|
||||
global notmuch_widget
|
||||
logger.info("[Bar] Spawning bars")
|
||||
outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
|
||||
|
||||
if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)):
|
||||
logger.warning("[Bar] Failed to get outputs — skipping bar spawn")
|
||||
return
|
||||
|
||||
outputs = [o for o in outputs_reply.reply if o.get("active")]
|
||||
|
||||
if not outputs:
|
||||
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)
|
||||
if i == 0 and bar.notmuch:
|
||||
notmuch_widget = bar.notmuch
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if i3.ready:
|
||||
spawn_bars()
|
||||
else:
|
||||
i3.connect("notify::ready", lambda *_: spawn_bars())
|
||||
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -1,128 +0,0 @@
|
||||
from fabric.i3 import I3, I3MessageType
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.entry import Entry
|
||||
from gi.repository import Gdk
|
||||
from bar.services.fenster import get_i3_connection
|
||||
|
||||
|
||||
class FuzzyWindowFinder(Window):
|
||||
def __init__(
|
||||
self,
|
||||
monitor: int = 0,
|
||||
):
|
||||
super().__init__(
|
||||
name="finder",
|
||||
anchor="center",
|
||||
monitor=monitor,
|
||||
keyboard_mode="on-demand",
|
||||
type="popup",
|
||||
visible=False,
|
||||
)
|
||||
|
||||
self._i3 = get_i3_connection()
|
||||
self._all_windows = []
|
||||
self._refresh_windows()
|
||||
|
||||
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
||||
|
||||
self.search_entry = Entry(
|
||||
name="search-entry",
|
||||
placeholder="Search Windows...",
|
||||
h_expand=True,
|
||||
editable=True,
|
||||
notify_text=self.notify_text,
|
||||
on_activate=lambda entry, *_: self.on_search_entry_activate(
|
||||
entry.get_text()
|
||||
),
|
||||
on_key_press_event=self.on_search_entry_key_press,
|
||||
)
|
||||
self.picker_box = Box(
|
||||
name="picker-box",
|
||||
spacing=4,
|
||||
orientation="v",
|
||||
children=[self.search_entry, self.viewport],
|
||||
)
|
||||
|
||||
self.add(self.picker_box)
|
||||
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, *_):
|
||||
text = entry.get_text()
|
||||
self.arrange_viewport(text)
|
||||
|
||||
def on_search_entry_key_press(self, widget, event):
|
||||
if event.keyval in [Gdk.KEY_Escape, 103]:
|
||||
self.hide()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_search_entry_activate(self, 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 = ""):
|
||||
self.viewport.children = [] # Clear previous entries
|
||||
|
||||
filtered = self._filter_windows(query)
|
||||
|
||||
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(
|
||||
Box(name="slot-box", orientation="h", children=[Label(label=display_text)])
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,29 +0,0 @@
|
||||
#picker-box {
|
||||
padding: 12px;
|
||||
background-color: rgba(40, 40, 40, 0.95); /* darker for contrast */
|
||||
border-radius: 8px;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
#viewport {
|
||||
padding: 8px;
|
||||
background-color: rgba(30, 30, 30, 0.9); /* dark background for contrast */
|
||||
border-radius: 6px;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
color: white; /* ensure contrast */
|
||||
}
|
||||
|
||||
#viewport > * {
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
#viewport:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15); /* hover feedback */
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
height: 42
|
||||
dev: true
|
||||
window_title:
|
||||
enable: false
|
||||
enable: true
|
||||
vinyl:
|
||||
enable: false
|
||||
enable: true
|
||||
battery:
|
||||
enable: true
|
||||
calendar:
|
||||
@@ -13,6 +13,18 @@ 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"
|
||||
screenrec:
|
||||
enable: true
|
||||
output_dir: "~/Videos/wl-screenrec"
|
||||
notifications:
|
||||
enable: true
|
||||
anchor: "top center"
|
||||
margin: "8px"
|
||||
width: 360
|
||||
timeout_ms: 10000
|
||||
history_size: 50
|
||||
image_max_px: 128
|
||||
center_width: 380
|
||||
stylix:
|
||||
enable: true
|
||||
colors:
|
||||
|
||||
146
flake.nix
146
flake.nix
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "Fabric Bar Example";
|
||||
description = "sims status bar (companion to fenster WM).";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/24.11";
|
||||
@@ -32,24 +32,19 @@
|
||||
{
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
devShells.default = pkgs.callPackage ./nix/shell.nix { inherit pkgs; };
|
||||
packages = {
|
||||
packages = rec {
|
||||
default = pkgs.callPackage ./nix/derivation.nix { inherit (pkgs) lib python3Packages; };
|
||||
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
|
||||
'';
|
||||
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
|
||||
'';
|
||||
sims = default;
|
||||
};
|
||||
apps.default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/bar";
|
||||
program = "${self.packages.${system}.default}/bin/sims";
|
||||
};
|
||||
}
|
||||
)
|
||||
// {
|
||||
homeManagerModules = {
|
||||
makku-bar =
|
||||
sims =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
@@ -57,18 +52,18 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.makku-bar;
|
||||
cfg = config.services.sims;
|
||||
|
||||
settingsFormat = pkgs.formats.yaml { };
|
||||
in
|
||||
{
|
||||
options.services.makku-bar = {
|
||||
enable = lib.mkEnableOption "makku-bar status bar";
|
||||
options.services.sims = {
|
||||
enable = lib.mkEnableOption "sims status bar";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.default;
|
||||
description = "The makku-bar package to use.";
|
||||
description = "The sims package to use.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
@@ -136,6 +131,98 @@
|
||||
default = "emacsclient";
|
||||
description = "Path to the emacsclient binary";
|
||||
};
|
||||
debt_query = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "tag:unread and date:..1w";
|
||||
description = "notmuch query whose count drives the mail-debt severity color on the bar widget";
|
||||
};
|
||||
debt_warn_at = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 1;
|
||||
description = "Debt count at which the widget switches to the warn (orange) color";
|
||||
};
|
||||
debt_alarm_at = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 6;
|
||||
description = "Debt count at which the widget switches to the alarm (red) color";
|
||||
};
|
||||
saved_searches = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Display label shown in the search launcher";
|
||||
};
|
||||
query = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "notmuch query to run when this saved search is activated";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = [ ];
|
||||
description = "Saved searches shown in the notmuch search launcher when the entry is empty";
|
||||
};
|
||||
};
|
||||
screenrec = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to enable the screenrec widget and menu";
|
||||
};
|
||||
output_dir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "~/Videos/wl-screenrec";
|
||||
description = "Directory to save recordings into";
|
||||
};
|
||||
};
|
||||
power = {
|
||||
lock_command = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "waylock" ];
|
||||
description = "argv for the Lock action in the power menu";
|
||||
};
|
||||
};
|
||||
notifications = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to enable the notification toast service. Owns org.freedesktop.Notifications, so other notification daemons (mako, dunst, swaync) must be disabled.";
|
||||
};
|
||||
anchor = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "top center";
|
||||
description = "Layer-shell anchor for the toast stack";
|
||||
};
|
||||
margin = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "8px";
|
||||
description = "Layer-shell margin for the toast stack";
|
||||
};
|
||||
width = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 360;
|
||||
description = "Width of each notification toast in pixels";
|
||||
};
|
||||
timeout_ms = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 10000;
|
||||
description = "Auto-close timeout for notifications in milliseconds";
|
||||
};
|
||||
history_size = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 50;
|
||||
description = "How many past notifications the in-memory center keeps";
|
||||
};
|
||||
image_max_px = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 128;
|
||||
description = "Max edge in pixels for stored notification thumbnails";
|
||||
};
|
||||
center_width = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 380;
|
||||
description = "Width of the notification center side rail in pixels";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -154,24 +241,45 @@
|
||||
enable = true;
|
||||
notmuch_path = "notmuch";
|
||||
emacsclient_command = "emacsclient";
|
||||
debt_query = "tag:unread and date:..1w";
|
||||
debt_warn_at = 1;
|
||||
debt_alarm_at = 6;
|
||||
saved_searches = [ ];
|
||||
};
|
||||
screenrec = {
|
||||
enable = false;
|
||||
output_dir = "~/Videos/wl-screenrec";
|
||||
};
|
||||
power = {
|
||||
lock_command = [ "waylock" ];
|
||||
};
|
||||
notifications = {
|
||||
enable = false;
|
||||
anchor = "top center";
|
||||
margin = "8px";
|
||||
width = 360;
|
||||
timeout_ms = 10000;
|
||||
history_size = 50;
|
||||
image_max_px = 128;
|
||||
center_width = 380;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.services.makku-bar.enable {
|
||||
systemd.user.services.makku-bar =
|
||||
config = lib.mkIf config.services.sims.enable {
|
||||
systemd.user.services.sims =
|
||||
let
|
||||
configFile = settingsFormat.generate "config.yaml" cfg.settings;
|
||||
in
|
||||
{
|
||||
Unit = {
|
||||
Description = "Makku Status Bar";
|
||||
Description = "sims status bar";
|
||||
After = [ "graphical-session.target" ];
|
||||
};
|
||||
|
||||
Service = {
|
||||
ExecStart = "${config.services.makku-bar.package}/bin/bar --config ${configFile}";
|
||||
ExecStart = "${config.services.sims.package}/bin/sims --config ${configFile}";
|
||||
Restart = "on-failure";
|
||||
};
|
||||
|
||||
@@ -181,7 +289,7 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
stylix-makku-bar = import ./nix/stylix/hm.nix;
|
||||
stylix-sims = import ./nix/stylix/hm.nix;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
webp-pixbuf-loader,
|
||||
notmuch,
|
||||
khal,
|
||||
emacs,
|
||||
dbus,
|
||||
...
|
||||
}:
|
||||
|
||||
python3Packages.buildPythonApplication {
|
||||
pname = "fabric-nix-example";
|
||||
pname = "sims";
|
||||
version = "0.0.1";
|
||||
pyproject = true;
|
||||
|
||||
@@ -58,12 +58,14 @@ python3Packages.buildPythonApplication {
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/${python3Packages.python.sitePackages}
|
||||
cp -r bar $out/${python3Packages.python.sitePackages}/
|
||||
cp -r sims $out/${python3Packages.python.sitePackages}/
|
||||
|
||||
# If you have any scripts to install
|
||||
mkdir -p $out/bin
|
||||
cp scripts/launcher.py $out/bin/bar
|
||||
chmod +x $out/bin/bar
|
||||
cp scripts/launcher.py $out/bin/sims
|
||||
chmod +x $out/bin/sims
|
||||
cp scripts/cli_launcher.py $out/bin/sims-cli
|
||||
chmod +x $out/bin/sims-cli
|
||||
|
||||
|
||||
runHook postInstall
|
||||
@@ -71,17 +73,23 @@ python3Packages.buildPythonApplication {
|
||||
|
||||
preFixup = ''
|
||||
makeWrapperArgs+=("''${gappsWrapperArgs[@]}")
|
||||
makeWrapperArgs+=(--prefix PATH : ${lib.makeBinPath [ khal notmuch emacs ]})
|
||||
makeWrapperArgs+=(--prefix PATH : ${
|
||||
lib.makeBinPath [
|
||||
khal
|
||||
notmuch
|
||||
dbus
|
||||
]
|
||||
})
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit khal notmuch emacs;
|
||||
inherit khal notmuch;
|
||||
};
|
||||
|
||||
meta = {
|
||||
changelog = "";
|
||||
description = ''
|
||||
Fabrix Bar Example
|
||||
sims status bar (companion to fenster WM).
|
||||
'';
|
||||
homepage = "https://github.com/wholikeel/fabric";
|
||||
license = lib.licenses.agpl3Only;
|
||||
|
||||
@@ -22,6 +22,7 @@ pkgs.mkShell {
|
||||
gobject-introspection
|
||||
libdbusmenu-gtk3
|
||||
gdk-pixbuf
|
||||
librsvg
|
||||
gnome-bluetooth
|
||||
cinnamon-desktop
|
||||
wayland-scanner
|
||||
@@ -46,4 +47,8 @@ pkgs.mkShell {
|
||||
]
|
||||
))
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export GDK_PIXBUF_MODULE_FILE=${pkgs.librsvg}/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
cfg = config.stylix.targets.makku-bar;
|
||||
cfg = config.stylix.targets.sims;
|
||||
in
|
||||
{
|
||||
options.stylix.targets.makku-bar.enable =
|
||||
config.lib.stylix.mkEnableTarget "Makku Bar" true;
|
||||
options.stylix.targets.sims.enable =
|
||||
config.lib.stylix.mkEnableTarget "sims" true;
|
||||
|
||||
config = lib.mkIf (config.stylix.enable && cfg.enable) {
|
||||
services.makku-bar.settings.stylix = {
|
||||
services.sims.settings.stylix = {
|
||||
enable = true;
|
||||
colors = {
|
||||
base00 = config.lib.stylix.colors.base00; # background
|
||||
|
||||
@@ -3,14 +3,14 @@ requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "fabric-nix-example"
|
||||
name = "sims"
|
||||
version = "0.0.1"
|
||||
requires-python = ">= 3.11"
|
||||
authors = [
|
||||
]
|
||||
maintainers = [
|
||||
]
|
||||
description = "Fabric using Nix example."
|
||||
description = "sims status bar (companion to fenster WM)."
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
|
||||
20
scripts/cli_launcher.py
Normal file
20
scripts/cli_launcher.py
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
site_packages_dir = os.path.join(
|
||||
script_dir,
|
||||
os.pardir,
|
||||
"lib",
|
||||
f"python{sys.version_info.major}.{sys.version_info.minor}",
|
||||
"site-packages",
|
||||
)
|
||||
|
||||
if site_packages_dir not in sys.path:
|
||||
sys.path.insert(0, site_packages_dir)
|
||||
|
||||
from sims.cli import main
|
||||
|
||||
sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__))
|
||||
sys.exit(main())
|
||||
2
scripts/launcher.py
Normal file → Executable file
2
scripts/launcher.py
Normal file → Executable file
@@ -15,7 +15,7 @@ if site_packages_dir not in sys.path:
|
||||
sys.path.insert(0, site_packages_dir)
|
||||
|
||||
|
||||
from bar.main import *
|
||||
from sims.main import *
|
||||
|
||||
sys.argv[0] = os.path.join(script_dir, os.path.basename(__file__))
|
||||
sys.exit(main())
|
||||
|
||||
171
sims/cli.py
Normal file
171
sims/cli.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""sims-cli — talk to the running sims status bar over DBus."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
DEST = "org.Fabric.fabric.sims"
|
||||
OBJ = "/org/Fabric/fabric"
|
||||
IFACE = "org.Fabric.fabric"
|
||||
|
||||
|
||||
def _dbus_send(*args: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["dbus-send", "--session", "--print-reply", f"--dest={DEST}", OBJ, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(proc.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def invoke_action(action: str) -> None:
|
||||
_dbus_send(f"{IFACE}.InvokeAction", f"string:{action}", "array:string:")
|
||||
|
||||
|
||||
_ACTION_RE = re.compile(r'dict entry\(\s*string "([^"]+)"')
|
||||
|
||||
|
||||
def list_actions() -> list[str]:
|
||||
out = _dbus_send(
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
f"string:{IFACE}",
|
||||
"string:Actions",
|
||||
)
|
||||
return _ACTION_RE.findall(out)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
name: str
|
||||
help: str
|
||||
run: Callable[[argparse.Namespace], None]
|
||||
|
||||
|
||||
def _action(name: str) -> Callable[[argparse.Namespace], None]:
|
||||
return lambda _ns: invoke_action(name)
|
||||
|
||||
|
||||
COMMANDS: list[Command] = [
|
||||
Command("finder", "open window finder", _action("open-finder")),
|
||||
Command("apps", "open application launcher", _action("open-app-launcher")),
|
||||
Command("clipboard", "open clipboard history picker", _action("open-clipboard-menu")),
|
||||
Command("power", "open power menu", _action("open-power-menu")),
|
||||
Command("screenshot", "open screenshot menu", _action("open-screenshot-menu")),
|
||||
Command("notmuch-refresh", "refresh unread mail count", _action("refresh-notmuch")),
|
||||
]
|
||||
|
||||
|
||||
def _cmd_screenrec(ns: argparse.Namespace) -> None:
|
||||
mapping = {
|
||||
"menu": "open-screenrec-menu",
|
||||
"start-monitor": "screenrec-start-monitor",
|
||||
"start-region": "screenrec-start-region",
|
||||
"stop": "screenrec-stop",
|
||||
}
|
||||
invoke_action(mapping[ns.screenrec_cmd])
|
||||
|
||||
|
||||
def _cmd_mail(ns: argparse.Namespace) -> None:
|
||||
mapping = {
|
||||
"search": "open-notmuch-search",
|
||||
"refresh": "refresh-notmuch",
|
||||
}
|
||||
invoke_action(mapping[ns.mail_cmd])
|
||||
|
||||
|
||||
def _cmd_corners(ns: argparse.Namespace) -> None:
|
||||
mapping = {
|
||||
"rounded": "set-bar-corners-rounded",
|
||||
"flat": "set-bar-corners-flat",
|
||||
"toggle": "toggle-bar-corners",
|
||||
}
|
||||
invoke_action(mapping[ns.corners_cmd])
|
||||
|
||||
|
||||
def _cmd_list(ns: argparse.Namespace) -> None:
|
||||
actions = list_actions()
|
||||
if ns.json:
|
||||
json.dump(actions, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
for a in actions:
|
||||
print(a)
|
||||
|
||||
|
||||
def _cmd_invoke(ns: argparse.Namespace) -> None:
|
||||
invoke_action(ns.action)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="sims-cli",
|
||||
description="Talk to the running sims status bar over DBus.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True, metavar="COMMAND")
|
||||
|
||||
for cmd in COMMANDS:
|
||||
p = sub.add_parser(cmd.name, help=cmd.help)
|
||||
p.set_defaults(func=cmd.run)
|
||||
|
||||
rec = sub.add_parser("screenrec", help="screen recording controls")
|
||||
rec_sub = rec.add_subparsers(dest="screenrec_cmd", required=True, metavar="ACTION")
|
||||
for sub_name, sub_help in [
|
||||
("menu", "open screenrec menu (auto-detects state)"),
|
||||
("start-monitor", "start recording the focused monitor"),
|
||||
("start-region", "start recording a slurp-selected region"),
|
||||
("stop", "stop active recording"),
|
||||
]:
|
||||
rec_sub.add_parser(sub_name, help=sub_help)
|
||||
rec.set_defaults(func=_cmd_screenrec)
|
||||
|
||||
mail = sub.add_parser("mail", help="notmuch mail controls")
|
||||
mail_sub = mail.add_subparsers(dest="mail_cmd", required=True, metavar="ACTION")
|
||||
for sub_name, sub_help in [
|
||||
("search", "open the live notmuch search launcher"),
|
||||
("refresh", "refresh the bar's unread/debt counts"),
|
||||
]:
|
||||
mail_sub.add_parser(sub_name, help=sub_help)
|
||||
mail.set_defaults(func=_cmd_mail)
|
||||
|
||||
corners = sub.add_parser("corners", help="bar bottom-corner rounding")
|
||||
corners_sub = corners.add_subparsers(
|
||||
dest="corners_cmd", required=True, metavar="STATE"
|
||||
)
|
||||
for sub_name, sub_help in [
|
||||
("rounded", "round the bar's bottom corners"),
|
||||
("flat", "remove rounding (current default look)"),
|
||||
("toggle", "flip the current rounding state"),
|
||||
]:
|
||||
corners_sub.add_parser(sub_name, help=sub_help)
|
||||
corners.set_defaults(func=_cmd_corners)
|
||||
|
||||
lst = sub.add_parser("list", help="list registered actions")
|
||||
lst.add_argument("--json", action="store_true", help="emit JSON array")
|
||||
lst.set_defaults(func=_cmd_list)
|
||||
|
||||
inv = sub.add_parser(
|
||||
"invoke",
|
||||
help="invoke a raw action by name (escape hatch for actions without a dedicated subcommand)",
|
||||
)
|
||||
inv.add_argument("action", help="dbus action name, e.g. open-finder")
|
||||
inv.set_defaults(func=_cmd_invoke)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser()
|
||||
ns = parser.parse_args()
|
||||
ns.func(ns)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,7 +4,7 @@ from platformdirs import user_config_dir
|
||||
import argparse
|
||||
|
||||
|
||||
APP_NAME = "makku_bar"
|
||||
APP_NAME = "sims"
|
||||
|
||||
XDG_CONFIG_HOME = user_config_dir(appname=APP_NAME)
|
||||
XDG_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, "config.yaml")
|
||||
@@ -32,7 +32,7 @@ def load_config(config_path=XDG_CONFIG_FILE):
|
||||
|
||||
|
||||
def load_args():
|
||||
parser = argparse.ArgumentParser(description="makku_bar")
|
||||
parser = argparse.ArgumentParser(description="sims")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
@@ -54,7 +54,25 @@ 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"})
|
||||
NOTMUCH = app_config.get("notmuch", {"enable": True, "notmuch_path": "notmuch", "emacsclient_command": "emacsclient", "saved_searches": []})
|
||||
NOTMUCH.setdefault("saved_searches", [])
|
||||
SCREENREC = app_config.get("screenrec", {
|
||||
"enable": False,
|
||||
"output_dir": "~/Videos/wl-screenrec",
|
||||
})
|
||||
POWER = app_config.get("power", {
|
||||
"lock_command": ["waylock"],
|
||||
})
|
||||
NOTIFICATIONS = app_config.get("notifications", {
|
||||
"enable": False,
|
||||
"anchor": "top center",
|
||||
"margin": "8px",
|
||||
"width": 360,
|
||||
"timeout_ms": 10_000,
|
||||
"history_size": 50,
|
||||
"image_max_px": 128,
|
||||
"center_width": 380,
|
||||
})
|
||||
BAR_HEIGHT = app_config.get("height", 40)
|
||||
LOG_LEVEL = app_config.get("logLevel", "WARNING")
|
||||
DEV = app_config.get("dev", False)
|
||||
244
sims/main.py
Normal file
244
sims/main.py
Normal file
@@ -0,0 +1,244 @@
|
||||
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 sims.services.i3 import I3, I3MessageType
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.utils import (
|
||||
get_relative_path,
|
||||
)
|
||||
from .modules.bar import StatusBar
|
||||
from .modules.window_fuzzy import FuzzyWindowFinder
|
||||
from .modules.launcher.apps import AppLauncher
|
||||
from .modules.launcher.clipboard import ClipboardMenu
|
||||
from .modules.launcher.notmuch_search import NotmuchSearchMenu
|
||||
from .modules.launcher.power import PowerMenu
|
||||
from .modules.launcher.screenrec import ScreenrecMenu
|
||||
from .modules.launcher.screenshot import ScreenshotMenu
|
||||
from .modules.calendar import CalendarService
|
||||
from .modules.control_center import ControlCenter
|
||||
from .modules.notifications import NotificationToasts
|
||||
from .modules.stylix import get_stylix_css_path
|
||||
from .modules.vinyl import VinylButton
|
||||
from .config import CALENDAR, NOTIFICATIONS, POWER, SCREENREC, STYLIX, VINYL
|
||||
from .services.fenster import get_i3_connection
|
||||
from .services.notification_history import NotificationHistoryService
|
||||
from .services.screenrec import ScreenrecService
|
||||
|
||||
from fabric.notifications import Notifications
|
||||
|
||||
|
||||
tray = SystemTray(name="system-tray", spacing=4)
|
||||
get_i3_connection()
|
||||
|
||||
dummy = Window(visible=False)
|
||||
finder = FuzzyWindowFinder()
|
||||
app_launcher = AppLauncher()
|
||||
clipboard_menu = ClipboardMenu()
|
||||
power_menu = PowerMenu(lock_command=POWER.get("lock_command", ["waylock"]))
|
||||
screenshot_menu = ScreenshotMenu()
|
||||
notmuch_search_menu = NotmuchSearchMenu()
|
||||
|
||||
screenrec_service: ScreenrecService | None = None
|
||||
screenrec_menu = None
|
||||
if SCREENREC.get("enable", False):
|
||||
screenrec_service = ScreenrecService(
|
||||
output_dir=SCREENREC.get("output_dir", "~/Videos/wl-screenrec")
|
||||
)
|
||||
screenrec_menu = ScreenrecMenu(screenrec_service)
|
||||
|
||||
notifications_service: Notifications | None = None
|
||||
notification_history: NotificationHistoryService | None = None
|
||||
notification_toasts: NotificationToasts | None = None
|
||||
if NOTIFICATIONS.get("enable", False):
|
||||
notifications_service = Notifications()
|
||||
notification_history = NotificationHistoryService(
|
||||
notifications_service,
|
||||
history_size=NOTIFICATIONS.get("history_size", 50),
|
||||
image_max_px=NOTIFICATIONS.get("image_max_px", 128),
|
||||
)
|
||||
notification_toasts = NotificationToasts(
|
||||
notifications_service,
|
||||
monitor=0,
|
||||
anchor=NOTIFICATIONS.get("anchor", "top center"),
|
||||
margin=NOTIFICATIONS.get("margin", "8px"),
|
||||
width=NOTIFICATIONS.get("width", 360),
|
||||
timeout_ms=NOTIFICATIONS.get("timeout_ms", 10_000),
|
||||
)
|
||||
|
||||
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,
|
||||
width=NOTIFICATIONS.get("center_width", 380),
|
||||
)
|
||||
|
||||
bar_windows = []
|
||||
notmuch_widget = None
|
||||
|
||||
_app_windows = [dummy, finder, app_launcher, clipboard_menu, power_menu, screenshot_menu, notmuch_search_menu]
|
||||
if screenrec_menu is not None:
|
||||
_app_windows.append(screenrec_menu)
|
||||
if notification_toasts is not None:
|
||||
_app_windows.append(notification_toasts)
|
||||
if control_center is not None:
|
||||
_app_windows.append(control_center)
|
||||
app = Application("sims", *_app_windows)
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_finder():
|
||||
finder.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_app_launcher():
|
||||
app_launcher.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_clipboard_menu():
|
||||
clipboard_menu.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_power_menu():
|
||||
power_menu.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_screenshot_menu():
|
||||
screenshot_menu.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def refresh_notmuch():
|
||||
if notmuch_widget is not None:
|
||||
notmuch_widget.service.update_counts()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_notmuch_search():
|
||||
notmuch_search_menu.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def open_screenrec_menu():
|
||||
if screenrec_menu is not None:
|
||||
screenrec_menu.show()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def screenrec_start_monitor():
|
||||
if screenrec_service is not None:
|
||||
screenrec_service.start_monitor("videos")
|
||||
|
||||
|
||||
@Application.action()
|
||||
def screenrec_start_region():
|
||||
if screenrec_service is not None:
|
||||
screenrec_service.start_region("videos")
|
||||
|
||||
|
||||
@Application.action()
|
||||
def screenrec_stop():
|
||||
if screenrec_service is not None:
|
||||
screenrec_service.stop()
|
||||
|
||||
|
||||
@Application.action()
|
||||
def toggle_control_center():
|
||||
if control_center is not None:
|
||||
control_center.toggle()
|
||||
|
||||
|
||||
def _set_all_bars_rounded(rounded: bool):
|
||||
for bar in bar_windows:
|
||||
bar.set_corners_rounded(rounded)
|
||||
|
||||
|
||||
@Application.action()
|
||||
def set_bar_corners_rounded():
|
||||
_set_all_bars_rounded(True)
|
||||
|
||||
|
||||
@Application.action()
|
||||
def set_bar_corners_flat():
|
||||
_set_all_bars_rounded(False)
|
||||
|
||||
|
||||
@Application.action()
|
||||
def toggle_bar_corners():
|
||||
new_state = not any(bar.corners_rounded for bar in bar_windows)
|
||||
_set_all_bars_rounded(new_state)
|
||||
|
||||
# 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")
|
||||
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
||||
app.set_stylesheet_from_file(stylix_css_path, append=True)
|
||||
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():
|
||||
global notmuch_widget
|
||||
logger.info("[Bar] Spawning bars")
|
||||
outputs_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
|
||||
|
||||
if not (outputs_reply.is_ok and isinstance(outputs_reply.reply, list)):
|
||||
logger.warning("[Bar] Failed to get outputs — skipping bar spawn")
|
||||
return
|
||||
|
||||
outputs = [o for o in outputs_reply.reply if o.get("active")]
|
||||
|
||||
if not outputs:
|
||||
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,
|
||||
screenrec_service=screenrec_service if i == 0 else None,
|
||||
control_center=control_center if i == 0 else None,
|
||||
)
|
||||
bar_windows.append(bar)
|
||||
if i == 0 and bar.notmuch:
|
||||
notmuch_widget = bar.notmuch
|
||||
|
||||
|
||||
def main():
|
||||
spawn_bars()
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +1,24 @@
|
||||
import psutil
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from bar.modules.player import Player
|
||||
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 sims.modules.player import Player, PlayerSmall
|
||||
from sims.modules.battery import Battery
|
||||
from sims.modules.control_center import ControlCenter
|
||||
from sims.modules.notmuch import NotmuchWidget
|
||||
from sims.modules.screenrec import ScreenrecWidget
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from fabric.system_tray.widgets import SystemTray
|
||||
from bar.widgets.fenster import FensterWorkspaces, FensterWorkspaceButton, FensterActiveWindow
|
||||
from bar.services.fenster import get_i3_connection
|
||||
from sims.widgets.fenster import FensterWorkspaces, FensterActiveWindow
|
||||
from sims.services.screenrec import ScreenrecService
|
||||
from sims.services.smart_corners import get_smart_corners_service
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from bar.services.system_stats import SystemStatsService
|
||||
from sims.services.system_stats import SystemStatsService
|
||||
|
||||
from bar.config import VINYL, BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
|
||||
from sims.config import BATTERY, BAR_HEIGHT, WINDOW_TITLE, NOTMUCH
|
||||
|
||||
|
||||
class StatusBar(Window):
|
||||
@@ -27,9 +27,11 @@ class StatusBar(Window):
|
||||
display: str,
|
||||
tray: SystemTray | None = None,
|
||||
monitor: int = 1,
|
||||
screenrec_service: ScreenrecService | None = None,
|
||||
control_center: ControlCenter | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
name="bar",
|
||||
name="sims",
|
||||
layer="top",
|
||||
anchor="left top right",
|
||||
margin="0px 0px -2px 0px",
|
||||
@@ -38,29 +40,25 @@ class StatusBar(Window):
|
||||
all_visible=False,
|
||||
monitor=monitor,
|
||||
)
|
||||
self.output = display
|
||||
self._corners_rounded = False
|
||||
self._right_flat = False
|
||||
|
||||
self.workspaces = FensterWorkspaces(
|
||||
output=display,
|
||||
name="workspaces",
|
||||
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")
|
||||
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;"
|
||||
on_clicked=self._on_date_time_clicked,
|
||||
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.active_window = FensterActiveWindow(
|
||||
@@ -84,15 +82,7 @@ class StatusBar(Window):
|
||||
overlays=[self.cpu_progress_bar, self.progress_label],
|
||||
)
|
||||
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.player_small = PlayerSmall()
|
||||
|
||||
self.battery = None
|
||||
if BATTERY["enable"]:
|
||||
@@ -102,6 +92,10 @@ class StatusBar(Window):
|
||||
if NOTMUCH["enable"]:
|
||||
self.notmuch = NotmuchWidget()
|
||||
|
||||
self.screenrec = None
|
||||
if screenrec_service is not None:
|
||||
self.screenrec = ScreenrecWidget(screenrec_service)
|
||||
|
||||
self.status_container = Box(
|
||||
name="widgets-container",
|
||||
spacing=4,
|
||||
@@ -121,16 +115,17 @@ class StatusBar(Window):
|
||||
if self.notmuch:
|
||||
end_container_children.append(self.notmuch)
|
||||
|
||||
# Add quick menu button next to time
|
||||
end_container_children.append(self.quick_menu)
|
||||
if self.screenrec:
|
||||
end_container_children.append(self.screenrec)
|
||||
|
||||
end_container_children.append(self.date_time)
|
||||
|
||||
center_children = []
|
||||
if WINDOW_TITLE["enable"]:
|
||||
center_children.append(self.active_window)
|
||||
|
||||
self.children = CenterBox(
|
||||
name="bar-inner",
|
||||
self.inner = CenterBox(
|
||||
name="sims-inner",
|
||||
start_children=Box(
|
||||
name="start-container",
|
||||
spacing=6,
|
||||
@@ -138,6 +133,7 @@ class StatusBar(Window):
|
||||
children=[
|
||||
Image(name="nixos-label", icon_name="nix-snowflake-white", icon_size=20),
|
||||
self.workspaces,
|
||||
self.player_small,
|
||||
],
|
||||
),
|
||||
center_children=Box(
|
||||
@@ -153,45 +149,53 @@ class StatusBar(Window):
|
||||
children=end_container_children,
|
||||
),
|
||||
)
|
||||
self.children = self.inner
|
||||
|
||||
# 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)
|
||||
|
||||
smart_corners = get_smart_corners_service()
|
||||
smart_corners.connect("state-changed", self._on_smart_corners_changed)
|
||||
self.set_corners_rounded(not smart_corners.get(display))
|
||||
|
||||
if self.control_center is not None:
|
||||
self.control_center.add_visibility_listener(self.set_right_flat)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup when bar is destroyed"""
|
||||
if hasattr(self, 'calendar_service'):
|
||||
self.calendar_service.stop_monitoring()
|
||||
@property
|
||||
def corners_rounded(self) -> bool:
|
||||
return self._corners_rounded
|
||||
|
||||
def set_corners_rounded(self, rounded: bool) -> None:
|
||||
if rounded == self._corners_rounded:
|
||||
return
|
||||
if rounded:
|
||||
self.inner.add_style_class("rounded-bottom")
|
||||
else:
|
||||
self.inner.remove_style_class("rounded-bottom")
|
||||
self._corners_rounded = rounded
|
||||
|
||||
def _on_smart_corners_changed(self, _service, output: str, active: bool):
|
||||
if output != self.output:
|
||||
return
|
||||
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):
|
||||
"""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)
|
||||
def _on_date_time_clicked(self, _button=None):
|
||||
if self.control_center is not None:
|
||||
self.control_center.toggle()
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
from sims.services.battery import BatteryService
|
||||
|
||||
|
||||
class Battery(Box):
|
||||
@@ -32,7 +32,9 @@ class Battery(Box):
|
||||
level = max(10, min(100, round(bat / 10) * 10))
|
||||
|
||||
if charging:
|
||||
return f"battery-level-{level}-charging-symbolic"
|
||||
# Adwaita ships battery-level-100-charged-symbolic, not -charging.
|
||||
suffix = "charged" if level == 100 else "charging"
|
||||
return f"battery-level-{level}-{suffix}-symbolic"
|
||||
else:
|
||||
return f"battery-level-{level}-symbolic"
|
||||
|
||||
@@ -2,17 +2,12 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
from datetime import datetime, date
|
||||
from datetime import 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
|
||||
from sims.config import CALENDAR
|
||||
|
||||
# Try to import khal as a Python library
|
||||
try:
|
||||
@@ -203,203 +198,3 @@ class CalendarService:
|
||||
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)
|
||||
352
sims/modules/control_center.py
Normal file
352
sims/modules/control_center.py
Normal 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
|
||||
10
sims/modules/launcher/__init__.py
Normal file
10
sims/modules/launcher/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .base import FuzzyMenu, LauncherProvider, StaticAction, StaticActionProvider
|
||||
from .windows import WindowProvider
|
||||
|
||||
__all__ = [
|
||||
"FuzzyMenu",
|
||||
"LauncherProvider",
|
||||
"StaticAction",
|
||||
"StaticActionProvider",
|
||||
"WindowProvider",
|
||||
]
|
||||
87
sims/modules/launcher/apps.py
Normal file
87
sims/modules/launcher/apps.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from fabric.utils.helpers import DesktopApp, get_desktop_applications
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .base import FuzzyMenu
|
||||
|
||||
|
||||
ICON_SIZE = 32
|
||||
|
||||
_FIELD_CODE_RE = re.compile(r"^%[fFuUickdDnNvm]$")
|
||||
|
||||
|
||||
class AppProvider:
|
||||
def items(self) -> list[DesktopApp]:
|
||||
return get_desktop_applications()
|
||||
|
||||
def filter(self, items: list[DesktopApp], query: str) -> list[DesktopApp]:
|
||||
if not query:
|
||||
return items
|
||||
q = query.lower()
|
||||
return [a for a in items if _matches(a, q)]
|
||||
|
||||
def render(self, item: DesktopApp) -> Gtk.Widget:
|
||||
children: list[Gtk.Widget] = []
|
||||
|
||||
pixbuf = item.get_icon_pixbuf(size=ICON_SIZE)
|
||||
if pixbuf is not None:
|
||||
children.append(Image(pixbuf=pixbuf, name="app-icon"))
|
||||
|
||||
primary = item.display_name or item.name or ""
|
||||
text_box = Box(name="app-text", orientation="v", spacing=0)
|
||||
text_box.add(Label(label=primary, name="app-name", h_align="start"))
|
||||
if item.generic_name and item.generic_name != primary:
|
||||
text_box.add(
|
||||
Label(label=item.generic_name, name="app-generic", h_align="start")
|
||||
)
|
||||
children.append(text_box)
|
||||
|
||||
return Box(name="slot-box", orientation="h", spacing=10, children=children)
|
||||
|
||||
def activate(self, item: DesktopApp) -> None:
|
||||
# Launch in a transient systemd --user scope so the app gets its own
|
||||
# cgroup instead of inheriting sims.service's. start_new_session alone
|
||||
# only changes POSIX session/pgid; systemd tracks units by cgroup and
|
||||
# would kill children with sims on stop (default KillMode=control-group).
|
||||
if item.command_line:
|
||||
argv = [
|
||||
t for t in shlex.split(item.command_line) if not _FIELD_CODE_RE.match(t)
|
||||
]
|
||||
if argv:
|
||||
subprocess.Popen(
|
||||
[
|
||||
"systemd-run",
|
||||
"--quiet",
|
||||
"--user",
|
||||
"--scope",
|
||||
"--collect",
|
||||
"--",
|
||||
*argv,
|
||||
],
|
||||
start_new_session=True,
|
||||
)
|
||||
return
|
||||
item.launch()
|
||||
|
||||
|
||||
def _matches(app: DesktopApp, q: str) -> bool:
|
||||
for field in (app.name, app.display_name, app.generic_name, app.executable):
|
||||
if field and q in field.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def AppLauncher(monitor: int = 0) -> FuzzyMenu:
|
||||
return FuzzyMenu(
|
||||
provider=AppProvider(),
|
||||
monitor=monitor,
|
||||
placeholder="Search Apps...",
|
||||
window_name="app-launcher",
|
||||
max_results=8,
|
||||
)
|
||||
205
sims/modules/launcher/base.py
Normal file
205
sims/modules/launcher/base.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from gi.repository import Gdk, Gtk
|
||||
|
||||
from sims.services.fenster import focused_output_index
|
||||
|
||||
|
||||
class LauncherProvider(Protocol):
|
||||
def items(self) -> list[Any]: ...
|
||||
def filter(self, items: list[Any], query: str) -> list[Any]: ...
|
||||
def render(self, item: Any) -> Gtk.Widget: ...
|
||||
def activate(self, item: Any) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class StaticAction:
|
||||
label: str
|
||||
handler: Callable[[], None]
|
||||
|
||||
|
||||
class StaticActionProvider:
|
||||
"""Provider for menus whose items are a fixed list of (label, handler) pairs.
|
||||
|
||||
Pass either StaticAction instances or (label, handler) tuples; tuples are
|
||||
coerced. items_factory lets the list re-evaluate on each open (e.g. for
|
||||
state-dependent menus) — otherwise the list is captured at construction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
actions: list[StaticAction | tuple[str, Callable[[], None]]] | None = None,
|
||||
items_factory: Callable[[], list[StaticAction | tuple[str, Callable[[], None]]]] | None = None,
|
||||
):
|
||||
if (actions is None) == (items_factory is None):
|
||||
raise ValueError("pass exactly one of actions or items_factory")
|
||||
self._static = [_coerce(a) for a in actions] if actions is not None else None
|
||||
self._factory = items_factory
|
||||
|
||||
def items(self) -> list[StaticAction]:
|
||||
if self._factory is not None:
|
||||
return [_coerce(a) for a in self._factory()]
|
||||
return list(self._static or [])
|
||||
|
||||
def filter(self, items: list[StaticAction], query: str) -> list[StaticAction]:
|
||||
if not query:
|
||||
return items
|
||||
q = query.lower()
|
||||
return [i for i in items if q in i.label.lower()]
|
||||
|
||||
def render(self, item: StaticAction) -> Gtk.Widget:
|
||||
return Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
children=[Label(label=item.label, h_align="start")],
|
||||
)
|
||||
|
||||
def activate(self, item: StaticAction) -> None:
|
||||
item.handler()
|
||||
|
||||
|
||||
def _coerce(a: StaticAction | tuple[str, Callable[[], None]]) -> StaticAction:
|
||||
if isinstance(a, StaticAction):
|
||||
return a
|
||||
label, handler = a
|
||||
return StaticAction(label=label, handler=handler)
|
||||
|
||||
|
||||
class FuzzyMenu(Window):
|
||||
def __init__(
|
||||
self,
|
||||
provider: LauncherProvider,
|
||||
monitor: int = 0,
|
||||
placeholder: str = "Search...",
|
||||
window_name: str = "finder",
|
||||
max_results: int | None = None,
|
||||
):
|
||||
self._max_results = max_results
|
||||
super().__init__(
|
||||
name=window_name,
|
||||
anchor="center",
|
||||
monitor=monitor,
|
||||
keyboard_mode="exclusive",
|
||||
type="popup",
|
||||
visible=False,
|
||||
)
|
||||
self._provider = provider
|
||||
self._items: list[Any] = []
|
||||
self._filtered: list[Any] = []
|
||||
self._selected_index: int = 0
|
||||
self._scroll_offset: int = 0
|
||||
|
||||
self.viewport = Box(name="viewport", spacing=4, orientation="v")
|
||||
|
||||
self.search_entry = Entry(
|
||||
name="search-entry",
|
||||
placeholder=placeholder,
|
||||
h_expand=True,
|
||||
editable=True,
|
||||
notify_text=self._on_text_changed,
|
||||
)
|
||||
self.picker_box = Box(
|
||||
name="picker-box",
|
||||
spacing=4,
|
||||
orientation="v",
|
||||
children=[self.search_entry, self.viewport],
|
||||
)
|
||||
|
||||
self.add(self.picker_box)
|
||||
self.connect("key-press-event", self._on_key_press)
|
||||
self._refresh_viewport("")
|
||||
|
||||
def show(self):
|
||||
self._items = self._provider.items()
|
||||
self.search_entry.set_text("")
|
||||
self._selected_index = 0
|
||||
self._scroll_offset = 0
|
||||
self._refresh_viewport("")
|
||||
self.monitor = focused_output_index()
|
||||
super().show()
|
||||
self.search_entry.grab_focus()
|
||||
|
||||
def _on_text_changed(self, entry, *_):
|
||||
self._selected_index = 0
|
||||
self._scroll_offset = 0
|
||||
self._refresh_viewport(entry.get_text())
|
||||
|
||||
def _on_key_press(self, _widget, event):
|
||||
ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK)
|
||||
keyval = event.keyval
|
||||
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
self.hide()
|
||||
return True
|
||||
if ctrl and keyval in (Gdk.KEY_g, Gdk.KEY_G):
|
||||
self.hide()
|
||||
return True
|
||||
if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||
self._activate_selected()
|
||||
return True
|
||||
if keyval == Gdk.KEY_Down or (ctrl and keyval in (Gdk.KEY_n, Gdk.KEY_N)):
|
||||
self._move_selection(1)
|
||||
return True
|
||||
if keyval == Gdk.KEY_Up or (ctrl and keyval in (Gdk.KEY_p, Gdk.KEY_P)):
|
||||
self._move_selection(-1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _window_size(self) -> int:
|
||||
return self._max_results if self._max_results is not None else len(self._filtered)
|
||||
|
||||
def _move_selection(self, delta: int):
|
||||
if not self._filtered:
|
||||
return
|
||||
new_index = self._selected_index + delta
|
||||
new_index = max(0, min(new_index, len(self._filtered) - 1))
|
||||
if new_index == self._selected_index:
|
||||
return
|
||||
self._selected_index = new_index
|
||||
window = self._window_size()
|
||||
if window <= 0:
|
||||
self._scroll_offset = 0
|
||||
elif self._selected_index < self._scroll_offset:
|
||||
self._scroll_offset = self._selected_index
|
||||
elif self._selected_index >= self._scroll_offset + window:
|
||||
self._scroll_offset = self._selected_index - window + 1
|
||||
self._render_visible()
|
||||
|
||||
def _refresh_viewport(self, query: str):
|
||||
self._filtered = self._provider.filter(self._items, query)
|
||||
if self._selected_index >= len(self._filtered):
|
||||
self._selected_index = 0
|
||||
self._scroll_offset = 0
|
||||
self._render_visible()
|
||||
|
||||
def _render_visible(self):
|
||||
window = self._window_size()
|
||||
if window <= 0:
|
||||
visible: list[Any] = []
|
||||
else:
|
||||
max_offset = max(0, len(self._filtered) - window)
|
||||
self._scroll_offset = min(self._scroll_offset, max_offset)
|
||||
visible = self._filtered[self._scroll_offset : self._scroll_offset + window]
|
||||
self.viewport.children = []
|
||||
for item in visible:
|
||||
self.viewport.add(self._provider.render(item))
|
||||
self._update_selection_highlight()
|
||||
|
||||
def _update_selection_highlight(self):
|
||||
visible_index = self._selected_index - self._scroll_offset
|
||||
for i, child in enumerate(self.viewport.get_children()):
|
||||
ctx = child.get_style_context()
|
||||
if i == visible_index:
|
||||
ctx.add_class("selected")
|
||||
else:
|
||||
ctx.remove_class("selected")
|
||||
|
||||
def _activate_selected(self):
|
||||
if self._filtered and 0 <= self._selected_index < len(self._filtered):
|
||||
self._provider.activate(self._filtered[self._selected_index])
|
||||
self.hide()
|
||||
75
sims/modules/launcher/clipboard.py
Normal file
75
sims/modules/launcher/clipboard.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gtk
|
||||
|
||||
from .base import FuzzyMenu
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClipEntry:
|
||||
raw: str # full "<id>\t<preview>" line as emitted by cliphist
|
||||
preview: str
|
||||
|
||||
|
||||
class ClipboardProvider:
|
||||
def items(self) -> list[ClipEntry]:
|
||||
proc = subprocess.run(
|
||||
["cliphist", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
entries: list[ClipEntry] = []
|
||||
for line in proc.stdout.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
_id, sep, preview = line.partition("\t")
|
||||
if not sep:
|
||||
continue
|
||||
entries.append(ClipEntry(raw=line, preview=preview))
|
||||
return entries
|
||||
|
||||
def filter(self, items: list[ClipEntry], query: str) -> list[ClipEntry]:
|
||||
if not query:
|
||||
return items
|
||||
q = query.lower()
|
||||
return [e for e in items if q in e.preview.lower()]
|
||||
|
||||
def render(self, item: ClipEntry) -> Gtk.Widget:
|
||||
return Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
children=[
|
||||
Label(
|
||||
label=item.preview,
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
max_chars_width=120,
|
||||
name="clip-preview",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def activate(self, item: ClipEntry) -> None:
|
||||
decoded = subprocess.run(
|
||||
["cliphist", "decode"],
|
||||
input=item.raw.encode("utf-8"),
|
||||
capture_output=True,
|
||||
)
|
||||
if decoded.returncode != 0:
|
||||
return
|
||||
subprocess.run(["wl-copy"], input=decoded.stdout)
|
||||
|
||||
|
||||
def ClipboardMenu(monitor: int = 0) -> FuzzyMenu:
|
||||
return FuzzyMenu(
|
||||
provider=ClipboardProvider(),
|
||||
monitor=monitor,
|
||||
placeholder="Clipboard...",
|
||||
window_name="clipboard-menu",
|
||||
max_results=12,
|
||||
)
|
||||
284
sims/modules/launcher/notmuch_search.py
Normal file
284
sims/modules/launcher/notmuch_search.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Live notmuch search launcher.
|
||||
|
||||
A FuzzyMenu variant that runs `notmuch search` per keystroke (debounced),
|
||||
renders thread summaries, and on activation opens the thread in emacs notmuch.
|
||||
A bare-query handoff item is always appended so the user can defer to
|
||||
notmuch-search inside emacs without having a matching result selected.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import GLib, Gtk
|
||||
from loguru import logger
|
||||
|
||||
from sims.config import NOTMUCH
|
||||
|
||||
from .base import FuzzyMenu
|
||||
|
||||
|
||||
DEBOUNCE_MS = 120
|
||||
MIN_QUERY_LEN = 2
|
||||
SEARCH_LIMIT = 30
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotmuchHit:
|
||||
thread: str
|
||||
subject: str
|
||||
authors: str
|
||||
date_relative: str
|
||||
|
||||
@property
|
||||
def query(self) -> str:
|
||||
return f"thread:{self.thread}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SavedSearch:
|
||||
name: str
|
||||
query: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BareQueryHandoff:
|
||||
query: str
|
||||
|
||||
|
||||
def _elisp_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
class NotmuchSearchProvider:
|
||||
def __init__(self):
|
||||
self._notmuch_path = NOTMUCH.get("notmuch_path", "notmuch")
|
||||
self._emacsclient = NOTMUCH.get("emacsclient_command", "emacsclient")
|
||||
raw_saved = NOTMUCH.get("saved_searches", []) or []
|
||||
self._saved: list[SavedSearch] = []
|
||||
for entry in raw_saved:
|
||||
query = (entry.get("query") or "").strip()
|
||||
if not query:
|
||||
continue
|
||||
name = entry.get("name") or query
|
||||
self._saved.append(SavedSearch(name=name, query=query))
|
||||
|
||||
def saved_searches(self) -> list[SavedSearch]:
|
||||
return list(self._saved)
|
||||
|
||||
def search(self, query: str) -> list[NotmuchHit]:
|
||||
cmd = [
|
||||
self._notmuch_path,
|
||||
"search",
|
||||
"--format=json",
|
||||
"--output=summary",
|
||||
f"--limit={SEARCH_LIMIT}",
|
||||
query,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=True, timeout=2
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(
|
||||
f"[NotmuchSearch] search failed for {query!r}: {e.stderr.strip()}"
|
||||
)
|
||||
return []
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
logger.warning(f"[NotmuchSearch] {e}")
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(proc.stdout) or []
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
hits: list[NotmuchHit] = []
|
||||
for r in raw:
|
||||
hits.append(
|
||||
NotmuchHit(
|
||||
thread=str(r.get("thread", "")),
|
||||
subject=r.get("subject") or "(no subject)",
|
||||
authors=r.get("authors") or "",
|
||||
date_relative=r.get("date_relative") or "",
|
||||
)
|
||||
)
|
||||
return hits
|
||||
|
||||
def render_hit(self, hit: NotmuchHit) -> Gtk.Widget:
|
||||
text = Box(name="notmuch-text", orientation="v", spacing=0, h_expand=True)
|
||||
text.add(
|
||||
Label(
|
||||
label=hit.subject,
|
||||
name="notmuch-subject",
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
max_chars_width=80,
|
||||
)
|
||||
)
|
||||
text.add(
|
||||
Label(
|
||||
label=hit.authors,
|
||||
name="notmuch-authors",
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
max_chars_width=80,
|
||||
)
|
||||
)
|
||||
meta = Label(
|
||||
label=hit.date_relative, name="notmuch-date", h_align="end"
|
||||
)
|
||||
return Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[text, meta],
|
||||
)
|
||||
|
||||
def render_saved(self, item: SavedSearch) -> Gtk.Widget:
|
||||
text = Box(name="notmuch-text", orientation="v", spacing=0, h_expand=True)
|
||||
text.add(
|
||||
Label(label=item.name, name="notmuch-saved-name", h_align="start")
|
||||
)
|
||||
text.add(
|
||||
Label(
|
||||
label=item.query,
|
||||
name="notmuch-saved-query",
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
max_chars_width=80,
|
||||
)
|
||||
)
|
||||
return Box(
|
||||
name="slot-box", orientation="h", spacing=10, children=[text]
|
||||
)
|
||||
|
||||
def render_bare(self, item: BareQueryHandoff) -> Gtk.Widget:
|
||||
return Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
children=[
|
||||
Label(
|
||||
label=f"→ Search '{item.query}' in emacs",
|
||||
name="notmuch-bare",
|
||||
h_align="start",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def open_thread(self, hit: NotmuchHit) -> None:
|
||||
self._emacs_eval(f'(notmuch-show "{_elisp_escape(hit.query)}")')
|
||||
|
||||
def open_search(self, query: str) -> None:
|
||||
self._emacs_eval(f'(notmuch-search "{_elisp_escape(query)}")')
|
||||
|
||||
def _emacs_eval(self, sexp: str) -> None:
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[self._emacsclient, "-c", "-e", sexp],
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[NotmuchSearch] failed to launch emacsclient: {e}")
|
||||
|
||||
|
||||
class _MenuProvider:
|
||||
"""LauncherProvider that defers to NotmuchSearchProvider.
|
||||
|
||||
items() returns saved searches (shown when the entry is empty).
|
||||
filter() is identity — NotmuchSearchMenu manages self._items directly
|
||||
on every keystroke (live search instead of in-memory filtering).
|
||||
"""
|
||||
|
||||
def __init__(self, search: NotmuchSearchProvider):
|
||||
self._search = search
|
||||
|
||||
def items(self) -> list[Any]:
|
||||
return list(self._search.saved_searches())
|
||||
|
||||
def filter(self, items: list[Any], query: str) -> list[Any]:
|
||||
return items
|
||||
|
||||
def render(self, item: Any) -> Gtk.Widget:
|
||||
if isinstance(item, NotmuchHit):
|
||||
return self._search.render_hit(item)
|
||||
if isinstance(item, SavedSearch):
|
||||
return self._search.render_saved(item)
|
||||
if isinstance(item, BareQueryHandoff):
|
||||
return self._search.render_bare(item)
|
||||
return Box()
|
||||
|
||||
def activate(self, item: Any) -> None:
|
||||
if isinstance(item, NotmuchHit):
|
||||
self._search.open_thread(item)
|
||||
elif isinstance(item, SavedSearch):
|
||||
self._search.open_search(item.query)
|
||||
elif isinstance(item, BareQueryHandoff):
|
||||
self._search.open_search(item.query)
|
||||
|
||||
|
||||
class NotmuchSearchMenu(FuzzyMenu):
|
||||
def __init__(self, monitor: int = 0):
|
||||
self._search = NotmuchSearchProvider()
|
||||
super().__init__(
|
||||
provider=_MenuProvider(self._search),
|
||||
monitor=monitor,
|
||||
placeholder="notmuch search…",
|
||||
window_name="notmuch-search",
|
||||
max_results=12,
|
||||
)
|
||||
self._debounce_id: int | None = None
|
||||
|
||||
def show(self):
|
||||
self._cancel_debounce()
|
||||
super().show()
|
||||
|
||||
def hide(self):
|
||||
self._cancel_debounce()
|
||||
super().hide()
|
||||
|
||||
def _on_text_changed(self, entry, *_):
|
||||
text = entry.get_text()
|
||||
self._cancel_debounce()
|
||||
self._selected_index = 0
|
||||
self._scroll_offset = 0
|
||||
|
||||
if not text:
|
||||
# Empty query: re-show saved searches via the provider.
|
||||
self._items = self._search.saved_searches()
|
||||
self._refresh_viewport(text)
|
||||
return
|
||||
|
||||
if len(text) < MIN_QUERY_LEN:
|
||||
# Too short to query notmuch; offer just the bare-query handoff.
|
||||
self._items = [BareQueryHandoff(query=text)]
|
||||
self._refresh_viewport(text)
|
||||
return
|
||||
|
||||
# Show the bare-query handoff immediately while we wait for the search
|
||||
# — gives the user a way to commit before the debounce fires.
|
||||
self._items = [BareQueryHandoff(query=text)]
|
||||
self._refresh_viewport(text)
|
||||
self._debounce_id = GLib.timeout_add(
|
||||
DEBOUNCE_MS, self._on_debounce_fire, text
|
||||
)
|
||||
|
||||
def _cancel_debounce(self):
|
||||
if self._debounce_id is not None:
|
||||
GLib.source_remove(self._debounce_id)
|
||||
self._debounce_id = None
|
||||
|
||||
def _on_debounce_fire(self, text: str):
|
||||
self._debounce_id = None
|
||||
if self.search_entry.get_text() != text:
|
||||
return False
|
||||
hits = self._search.search(text)
|
||||
items: list[Any] = list(hits)
|
||||
items.append(BareQueryHandoff(query=text))
|
||||
self._items = items
|
||||
self._selected_index = 0
|
||||
self._scroll_offset = 0
|
||||
self._refresh_viewport(text)
|
||||
return False
|
||||
25
sims/modules/launcher/power.py
Normal file
25
sims/modules/launcher/power.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import subprocess
|
||||
|
||||
from .base import FuzzyMenu, StaticActionProvider
|
||||
|
||||
|
||||
def _spawn(argv: list[str]) -> None:
|
||||
subprocess.Popen(argv, start_new_session=True)
|
||||
|
||||
|
||||
def PowerMenu(monitor: int = 0, lock_command: list[str] | None = None) -> FuzzyMenu:
|
||||
lock = lock_command or ["waylock"]
|
||||
provider = StaticActionProvider(
|
||||
actions=[
|
||||
("⏻ Poweroff", lambda: _spawn(["systemctl", "poweroff"])),
|
||||
("🔁 Reboot", lambda: _spawn(["systemctl", "reboot"])),
|
||||
("⏾ Suspend", lambda: _spawn(["systemctl", "suspend"])),
|
||||
("Lock", lambda: _spawn(lock)),
|
||||
]
|
||||
)
|
||||
return FuzzyMenu(
|
||||
provider=provider,
|
||||
monitor=monitor,
|
||||
placeholder="Power Menu...",
|
||||
window_name="power-menu",
|
||||
)
|
||||
30
sims/modules/launcher/screenrec.py
Normal file
30
sims/modules/launcher/screenrec.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sims.services.screenrec import ScreenrecService
|
||||
|
||||
from .base import FuzzyMenu, StaticActionProvider
|
||||
|
||||
|
||||
def _idle_actions(service: ScreenrecService):
|
||||
return [
|
||||
("Monitor → Videos", lambda: service.start_monitor("videos")),
|
||||
("Region → Videos", lambda: service.start_region("videos")),
|
||||
("Monitor → Clipboard", lambda: service.start_monitor("clipboard")),
|
||||
("Region → Clipboard", lambda: service.start_region("clipboard")),
|
||||
]
|
||||
|
||||
|
||||
def ScreenrecProvider(service: ScreenrecService) -> StaticActionProvider:
|
||||
def items():
|
||||
if service.recording:
|
||||
return [("Stop Recording", service.stop)]
|
||||
return _idle_actions(service)
|
||||
|
||||
return StaticActionProvider(items_factory=items)
|
||||
|
||||
|
||||
def ScreenrecMenu(service: ScreenrecService, monitor: int = 0) -> FuzzyMenu:
|
||||
return FuzzyMenu(
|
||||
provider=ScreenrecProvider(service),
|
||||
monitor=monitor,
|
||||
placeholder="Screen Recording...",
|
||||
window_name="screenrec-menu",
|
||||
)
|
||||
23
sims/modules/launcher/screenshot.py
Normal file
23
sims/modules/launcher/screenshot.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import subprocess
|
||||
|
||||
from .base import FuzzyMenu, StaticActionProvider
|
||||
|
||||
|
||||
def _spawn(argv: list[str]) -> None:
|
||||
subprocess.Popen(argv, start_new_session=True)
|
||||
|
||||
|
||||
def ScreenshotMenu(monitor: int = 0) -> FuzzyMenu:
|
||||
provider = StaticActionProvider(
|
||||
actions=[
|
||||
("Normal", lambda: _spawn(["grimnorm"])),
|
||||
("To Clipboard", lambda: _spawn(["grim2clip"])),
|
||||
("To Imv", lambda: _spawn(["grim2imv"])),
|
||||
]
|
||||
)
|
||||
return FuzzyMenu(
|
||||
provider=provider,
|
||||
monitor=monitor,
|
||||
placeholder="Screenshot...",
|
||||
window_name="screenshot-menu",
|
||||
)
|
||||
60
sims/modules/launcher/windows.py
Normal file
60
sims/modules/launcher/windows.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from sims.services.i3 import I3, I3MessageType
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class WindowProvider:
|
||||
def items(self) -> list[dict]:
|
||||
windows: list[dict] = []
|
||||
tree_reply = I3.send_command("", I3MessageType.GET_TREE)
|
||||
if not (tree_reply.is_ok and isinstance(tree_reply.reply, dict)):
|
||||
return windows
|
||||
|
||||
tree = tree_reply.reply
|
||||
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":
|
||||
windows.append({
|
||||
"id": con.get("id"),
|
||||
"app_id": con.get("app_id") or "",
|
||||
"title": con.get("name") or "",
|
||||
"workspace": ws_num,
|
||||
})
|
||||
for con in ws_node.get("floating_nodes", []):
|
||||
if con.get("type") == "con":
|
||||
windows.append({
|
||||
"id": con.get("id"),
|
||||
"app_id": con.get("app_id") or "",
|
||||
"title": con.get("name") or "",
|
||||
"workspace": ws_num,
|
||||
})
|
||||
return windows
|
||||
|
||||
def filter(self, items: list[dict], query: str) -> list[dict]:
|
||||
if not query:
|
||||
return items
|
||||
q = query.lower()
|
||||
return [
|
||||
w for w in items
|
||||
if q in w.get("title", "").lower()
|
||||
or q in w.get("app_id", "").lower()
|
||||
]
|
||||
|
||||
def render(self, item: dict) -> Gtk.Widget:
|
||||
title = item.get("title", "")
|
||||
app_id = item.get("app_id", "")
|
||||
ws_num = item.get("workspace", 0)
|
||||
text = f"[{ws_num}] {app_id}: {title}" if app_id else f"[{ws_num}] {title}"
|
||||
return Box(
|
||||
name="slot-box",
|
||||
orientation="h",
|
||||
children=[Label(label=text)],
|
||||
)
|
||||
|
||||
def activate(self, item: dict) -> None:
|
||||
window_id = item.get("id")
|
||||
if window_id is not None:
|
||||
I3.send_command(f"[con_id={window_id}] focus")
|
||||
54
sims/modules/notifications.py
Normal file
54
sims/modules/notifications.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import cast
|
||||
|
||||
from fabric.notifications import Notification, Notifications
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from loguru import logger
|
||||
|
||||
from sims.widgets.notification import NotificationWidget
|
||||
|
||||
|
||||
class NotificationToasts(Window):
|
||||
def __init__(
|
||||
self,
|
||||
service: Notifications,
|
||||
monitor: int = 0,
|
||||
anchor: str = "top center",
|
||||
margin: str = "8px",
|
||||
width: int = 360,
|
||||
timeout_ms: int = 10_000,
|
||||
):
|
||||
super().__init__(
|
||||
name="notification-toasts",
|
||||
anchor=anchor,
|
||||
monitor=monitor,
|
||||
margin=margin,
|
||||
exclusivity="none",
|
||||
visible=True,
|
||||
all_visible=True,
|
||||
)
|
||||
self._width = width
|
||||
self._timeout_ms = timeout_ms
|
||||
|
||||
self._stack = Box(
|
||||
size=2, # so the compositor doesn't optimize the empty surface away
|
||||
spacing=4,
|
||||
orientation="v",
|
||||
)
|
||||
self.add(self._stack)
|
||||
|
||||
self._service = service
|
||||
self._service.connect("notification-added", self._on_added)
|
||||
|
||||
def _on_added(self, service: Notifications, nid: int):
|
||||
notification = cast(Notification, service.get_notification_from_id(nid))
|
||||
if notification is None:
|
||||
logger.warning(f"[Notifications] no notification for id {nid}")
|
||||
return
|
||||
self._stack.add(
|
||||
NotificationWidget(
|
||||
notification,
|
||||
width=self._width,
|
||||
timeout_ms=self._timeout_ms,
|
||||
)
|
||||
)
|
||||
@@ -9,30 +9,36 @@ 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
|
||||
from sims.config import NOTMUCH
|
||||
|
||||
|
||||
DEFAULT_DEBT_QUERY = "tag:unread and date:..1w"
|
||||
DEFAULT_DEBT_WARN_AT = 1
|
||||
DEFAULT_DEBT_ALARM_AT = 6
|
||||
|
||||
|
||||
class NotmuchService:
|
||||
def __init__(self, update_interval=60000): # 1 minute default
|
||||
self.unread_count = 0
|
||||
self.debt_count = 0
|
||||
self.callbacks = []
|
||||
self._update_interval = update_interval
|
||||
self._timer_id = None
|
||||
|
||||
# Initial load
|
||||
self.update_unread_count()
|
||||
self.update_counts()
|
||||
# Start periodic updates
|
||||
self.start_monitoring()
|
||||
|
||||
def connect(self, signal_name, callback):
|
||||
"""Simple callback system to replace signals"""
|
||||
if signal_name == "unread-changed":
|
||||
if signal_name == "counts-changed":
|
||||
self.callbacks.append(callback)
|
||||
|
||||
def emit_unread_changed(self, count):
|
||||
"""Emit unread changed to all callbacks"""
|
||||
def emit_counts_changed(self):
|
||||
"""Emit counts changed to all callbacks"""
|
||||
for callback in self.callbacks:
|
||||
callback(self, count)
|
||||
callback(self, self.unread_count, self.debt_count)
|
||||
|
||||
def start_monitoring(self):
|
||||
"""Start periodic unread count updates"""
|
||||
@@ -57,21 +63,38 @@ class NotmuchService:
|
||||
|
||||
def _periodic_update(self):
|
||||
"""Periodic update callback"""
|
||||
logger.info("[Notmuch] Performing periodic unread count update")
|
||||
self.update_unread_count()
|
||||
logger.info("[Notmuch] Performing periodic count update")
|
||||
self.update_counts()
|
||||
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"""
|
||||
def get_cached_debt_count(self):
|
||||
"""Get cached debt count without triggering update"""
|
||||
return self.debt_count
|
||||
|
||||
def _run_count(self, notmuch_path, query):
|
||||
cmd = [notmuch_path, "count", query]
|
||||
logger.info(f"[Notmuch] Running command: {' '.join(cmd)}")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
out = result.stdout.strip()
|
||||
return int(out) if out else 0
|
||||
|
||||
def update_counts(self):
|
||||
"""Fetch unread + debt counts 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)
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
return
|
||||
|
||||
# Get notmuch path from config
|
||||
@@ -81,42 +104,34 @@ class NotmuchService:
|
||||
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)
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
return
|
||||
|
||||
debt_query = NOTMUCH.get("debt_query", DEFAULT_DEBT_QUERY)
|
||||
|
||||
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,
|
||||
self.unread_count = self._run_count(notmuch_path, "tag:unread")
|
||||
self.debt_count = self._run_count(notmuch_path, debt_query)
|
||||
logger.info(
|
||||
f"[Notmuch] {self.unread_count} unread, {self.debt_count} aging (debt query: {debt_query!r})"
|
||||
)
|
||||
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)
|
||||
|
||||
self.emit_counts_changed()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"[Notmuch] Failed to fetch unread count: {e}")
|
||||
logger.error(f"[Notmuch] Failed to fetch counts: {e}")
|
||||
self.unread_count = 0
|
||||
self.emit_unread_changed(self.unread_count)
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
except ValueError as e:
|
||||
logger.error(f"[Notmuch] Error parsing unread count: {e}")
|
||||
logger.error(f"[Notmuch] Error parsing count: {e}")
|
||||
self.unread_count = 0
|
||||
self.emit_unread_changed(self.unread_count)
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
except Exception as e:
|
||||
logger.error(f"[Notmuch] Error getting unread count: {e}")
|
||||
logger.error(f"[Notmuch] Error getting counts: {e}")
|
||||
self.unread_count = 0
|
||||
self.emit_unread_changed(self.unread_count)
|
||||
self.debt_count = 0
|
||||
self.emit_counts_changed()
|
||||
|
||||
|
||||
class NotmuchWidget(Button):
|
||||
@@ -141,12 +156,14 @@ class NotmuchWidget(Button):
|
||||
|
||||
# Initialize the service
|
||||
self.service = NotmuchService()
|
||||
self.service.connect("unread-changed", self.update_display)
|
||||
self.service.connect("counts-changed", self.update_display)
|
||||
|
||||
logger.info("[Notmuch] Notmuch widget initialized")
|
||||
|
||||
# Initial update
|
||||
self.update_display(self.service, self.service.unread_count)
|
||||
self.update_display(
|
||||
self.service, self.service.unread_count, self.service.debt_count
|
||||
)
|
||||
|
||||
def open_email_client(self, button=None):
|
||||
"""Open notmuch in emacsclient"""
|
||||
@@ -161,18 +178,31 @@ class NotmuchWidget(Button):
|
||||
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))
|
||||
def update_display(self, service, unread, debt):
|
||||
"""Update the widget display with unread + debt counts"""
|
||||
warn_at = NOTMUCH.get("debt_warn_at", DEFAULT_DEBT_WARN_AT)
|
||||
alarm_at = NOTMUCH.get("debt_alarm_at", DEFAULT_DEBT_ALARM_AT)
|
||||
|
||||
classes = ["notmuch-widget"]
|
||||
if unread > 0:
|
||||
self.label.set_text(str(unread))
|
||||
self.label.set_visible(True)
|
||||
self.icon.set_from_icon_name("mail-unread-symbolic", 16)
|
||||
self.set_style_classes(["notmuch-widget", "has-unread"])
|
||||
self.icon.set_property("icon-name", "mail-unread-symbolic")
|
||||
classes.append("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"])
|
||||
self.icon.set_property("icon-name", "mail-read-symbolic")
|
||||
classes.append("no-unread")
|
||||
|
||||
logger.info(f"[Notmuch] Updated display: {count} unread emails")
|
||||
if debt >= alarm_at:
|
||||
classes.append("debt-alarm")
|
||||
elif debt >= warn_at:
|
||||
classes.append("debt-warn")
|
||||
|
||||
self.set_style_classes(classes)
|
||||
self.set_tooltip_text(f"{unread} unread · {debt} aging")
|
||||
|
||||
logger.info(
|
||||
f"[Notmuch] Updated display: {unread} unread, {debt} aging — classes={classes}"
|
||||
)
|
||||
@@ -1,21 +1,23 @@
|
||||
import contextlib
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import tempfile
|
||||
from gi.repository import Gtk, GLib, Gio, Gdk
|
||||
from gi.repository import Gtk, GLib, Gio, Gdk, Pango
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.circularprogressbar import CircularProgressBar
|
||||
from fabric.widgets.overlay import Overlay
|
||||
from fabric.widgets.stack import Stack
|
||||
from ..widgets.circle_image import CircleImage
|
||||
import bar.modules.icons as icons
|
||||
from bar.services.mpris import MprisPlayerManager, MprisPlayer
|
||||
import sims.modules.icons as icons
|
||||
from sims.services.mpris import MprisPlayerManager, MprisPlayer
|
||||
from fabric import Fabricator
|
||||
|
||||
# from bar.modules.cavalcade import SpectrumRender
|
||||
# from sims.modules.cavalcade import SpectrumRender
|
||||
|
||||
|
||||
def get_player_progress(fabricator, mpris_player):
|
||||
@@ -522,30 +524,52 @@ class Player(Box):
|
||||
return False
|
||||
|
||||
|
||||
class PlayerSmall(CenterBox):
|
||||
class PlayerSmall(Box):
|
||||
PLAY_ICON = "media-playback-start-symbolic"
|
||||
PAUSE_ICON = "media-playback-pause-symbolic"
|
||||
PREV_ICON = "media-skip-backward-symbolic"
|
||||
NEXT_ICON = "media-skip-forward-symbolic"
|
||||
FALLBACK_ICON = "audio-x-generic-symbolic"
|
||||
COVER_SIZE = 22
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="player-small", orientation="h", h_align="fill", v_align="center"
|
||||
name="player-small", orientation="h", v_align="center"
|
||||
)
|
||||
self._show_artist = False # toggle flag
|
||||
self._display_options = ["cavalcade", "title", "artist"]
|
||||
self._display_options = ["title", "artist"]
|
||||
self._display_index = 0
|
||||
self._current_display = "cavalcade"
|
||||
self._current_display = "title"
|
||||
self._spin_timer_id = None
|
||||
self._spin_step = 2 # deg per tick → ~9s per rotation at 50ms
|
||||
self._spin_interval_ms = 50
|
||||
|
||||
self.cover = CircleImage(
|
||||
name="compact-mpris-cover",
|
||||
size=self.COVER_SIZE,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.fallback_icon = Image(
|
||||
name="compact-mpris-fallback",
|
||||
icon_name=self.FALLBACK_ICON,
|
||||
icon_size=16,
|
||||
)
|
||||
self.cover_stack = Stack(
|
||||
name="compact-mpris-cover-stack",
|
||||
transition_type="crossfade",
|
||||
transition_duration=200,
|
||||
children=[self.fallback_icon, self.cover],
|
||||
)
|
||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||
|
||||
self.mpris_icon = Button(
|
||||
name="compact-mpris-icon",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
child=Label(name="compact-mpris-icon-label", markup=icons.disc),
|
||||
child=self.cover_stack,
|
||||
)
|
||||
# Remove scroll events; instead, add button press events.
|
||||
self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
self.mpris_icon.connect("button-press-event", self._on_icon_button_press)
|
||||
# Prevent the child from propagating events.
|
||||
child = self.mpris_icon.get_child()
|
||||
child.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
child.connect("button-press-event", lambda widget, event: True)
|
||||
# Add hover effect
|
||||
add_hover_cursor(self.mpris_icon)
|
||||
|
||||
self.mpris_label = Label(
|
||||
@@ -555,36 +579,36 @@ class PlayerSmall(CenterBox):
|
||||
max_chars_width=26,
|
||||
h_align="center",
|
||||
)
|
||||
self._width_tween_id = None
|
||||
self._current_label_width_px = None
|
||||
self._width_tween_duration_ms = 220
|
||||
|
||||
self.play_image = Image(
|
||||
name="compact-mpris-button-icon",
|
||||
icon_name=self.PLAY_ICON,
|
||||
icon_size=16,
|
||||
)
|
||||
self.mpris_button = Button(
|
||||
name="compact-mpris-button",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
child=Label(name="compact-mpris-button-label", markup=icons.play),
|
||||
child=self.play_image,
|
||||
)
|
||||
self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
|
||||
self.mpris_button.connect(
|
||||
"button-press-event", self._on_play_pause_button_press
|
||||
)
|
||||
# Add hover effect
|
||||
add_hover_cursor(self.mpris_button)
|
||||
|
||||
# self.cavalcade = SpectrumRender()
|
||||
# self.cavalcade_box = self.cavalcade.get_spectrum_box()
|
||||
|
||||
self.center_stack = Stack(
|
||||
name="compact-mpris",
|
||||
transition_type="crossfade",
|
||||
transition_duration=100,
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
children=[
|
||||
# self.cavalcade_box,
|
||||
self.mpris_label,
|
||||
],
|
||||
children=[self.mpris_label],
|
||||
)
|
||||
# self.center_stack.set_visible_child(self.cavalcade_box) # default to cavalcade
|
||||
|
||||
# Create additional compact view.
|
||||
self.mpris_small = CenterBox(
|
||||
name="compact-mpris",
|
||||
orientation="h",
|
||||
@@ -593,7 +617,7 @@ class PlayerSmall(CenterBox):
|
||||
v_align="center",
|
||||
v_expand=False,
|
||||
start_children=self.mpris_icon,
|
||||
center_children=self.center_stack, # Changed to center_stack to handle stack switching
|
||||
center_children=self.center_stack,
|
||||
end_children=self.mpris_button,
|
||||
)
|
||||
|
||||
@@ -601,157 +625,307 @@ class PlayerSmall(CenterBox):
|
||||
|
||||
self.mpris_manager = MprisPlayerManager()
|
||||
self.mpris_player = None
|
||||
# Almacenar el índice del reproductor actual
|
||||
self.current_index = 0
|
||||
self._players = {}
|
||||
self._player_handlers = {}
|
||||
self._last_status = {}
|
||||
|
||||
players = self.mpris_manager.players
|
||||
if players:
|
||||
mp = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = mp
|
||||
self._apply_mpris_properties()
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
for p in self.mpris_manager.players:
|
||||
self._track_player(p)
|
||||
self._select_initial_player()
|
||||
self._apply_mpris_properties()
|
||||
|
||||
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
|
||||
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
|
||||
self.mpris_button.connect("clicked", self._on_play_pause_clicked)
|
||||
|
||||
def _char_width_px(self):
|
||||
metrics = self.mpris_label.get_pango_context().get_metrics(None, None)
|
||||
return max(1.0, metrics.get_approximate_char_width() / Pango.SCALE)
|
||||
|
||||
def _measure_text_width(self, text):
|
||||
if not text:
|
||||
return 0
|
||||
layout = self.mpris_label.create_pango_layout(text)
|
||||
text_width_px, _ = layout.get_pixel_size()
|
||||
max_px = int(self._char_width_px() * 26)
|
||||
return min(text_width_px, max_px)
|
||||
|
||||
def _apply_label_width(self, width_px):
|
||||
if width_px <= 0:
|
||||
self.mpris_label.set_size_request(0, -1)
|
||||
if self.mpris_label.get_margin_start() != 0:
|
||||
self.mpris_label.set_margin_start(0)
|
||||
self.mpris_label.set_margin_end(0)
|
||||
if self.mpris_label.get_visible():
|
||||
self.mpris_label.set_visible(False)
|
||||
self._current_label_width_px = 0
|
||||
return
|
||||
|
||||
if not self.mpris_label.get_visible():
|
||||
self.mpris_label.set_visible(True)
|
||||
chars = max(1, int(round(width_px / self._char_width_px())))
|
||||
if chars != self.mpris_label.get_max_width_chars():
|
||||
self.mpris_label.set_max_width_chars(chars)
|
||||
self.mpris_label.set_size_request(width_px, -1)
|
||||
margin = min(6, width_px // 2)
|
||||
if self.mpris_label.get_margin_start() != margin:
|
||||
self.mpris_label.set_margin_start(margin)
|
||||
self.mpris_label.set_margin_end(margin)
|
||||
self._current_label_width_px = width_px
|
||||
|
||||
def _set_label_text(self, text):
|
||||
target_text = text or ""
|
||||
target_px = self._measure_text_width(target_text)
|
||||
|
||||
if self._width_tween_id is not None:
|
||||
GLib.source_remove(self._width_tween_id)
|
||||
self._width_tween_id = None
|
||||
|
||||
# First call — snap to target without animation.
|
||||
if self._current_label_width_px is None:
|
||||
self.mpris_label.set_text(target_text)
|
||||
self._apply_label_width(target_px)
|
||||
return
|
||||
|
||||
start_px = self._current_label_width_px
|
||||
|
||||
# Growing from collapsed: set new text first so it's ready to reveal.
|
||||
# Cross-fading between two non-empty texts: also swap text immediately.
|
||||
# Shrinking to empty: keep old text visible while it shrinks, clear at end.
|
||||
if target_px > 0:
|
||||
self.mpris_label.set_text(target_text)
|
||||
|
||||
if start_px == target_px:
|
||||
self._apply_label_width(target_px)
|
||||
return
|
||||
|
||||
duration_ms = self._width_tween_duration_ms
|
||||
start_time = GLib.get_monotonic_time()
|
||||
|
||||
def tick():
|
||||
elapsed_ms = (GLib.get_monotonic_time() - start_time) / 1000.0
|
||||
progress = min(1.0, elapsed_ms / duration_ms)
|
||||
t = 1 - (1 - progress) ** 3 # ease-out cubic
|
||||
cur_px = int(start_px + (target_px - start_px) * t)
|
||||
self._apply_label_width(cur_px)
|
||||
if progress >= 1.0:
|
||||
self._apply_label_width(target_px)
|
||||
if target_px == 0:
|
||||
self.mpris_label.set_text("")
|
||||
self._width_tween_id = None
|
||||
return False
|
||||
return True
|
||||
|
||||
self._width_tween_id = GLib.timeout_add(16, tick)
|
||||
|
||||
def _set_cover_from_path(self, image_path):
|
||||
if image_path and os.path.isfile(image_path):
|
||||
try:
|
||||
self.cover.set_image_from_file(image_path)
|
||||
self.cover_stack.set_visible_child(self.cover)
|
||||
self._update_spin()
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||
self._update_spin()
|
||||
return False
|
||||
|
||||
def _start_spin(self):
|
||||
if self._spin_timer_id is None:
|
||||
self._spin_timer_id = GLib.timeout_add(
|
||||
self._spin_interval_ms, self._advance_spin
|
||||
)
|
||||
|
||||
def _stop_spin(self):
|
||||
if self._spin_timer_id is not None:
|
||||
GLib.source_remove(self._spin_timer_id)
|
||||
self._spin_timer_id = None
|
||||
|
||||
def _advance_spin(self):
|
||||
self.cover.angle = (self.cover.angle + self._spin_step) % 360
|
||||
return True
|
||||
|
||||
def _update_spin(self):
|
||||
showing_cover = self.cover_stack.get_visible_child() is self.cover
|
||||
is_playing = (
|
||||
self.mpris_player is not None
|
||||
and self.mpris_player.playback_status == "playing"
|
||||
)
|
||||
if showing_cover and is_playing:
|
||||
self._start_spin()
|
||||
else:
|
||||
self._stop_spin()
|
||||
|
||||
def _download_artwork(self, arturl):
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(arturl)
|
||||
suffix = os.path.splitext(parsed.path)[1] or ".png"
|
||||
with urllib.request.urlopen(arturl) as response:
|
||||
data = response.read()
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
local_path = tmp.name
|
||||
except Exception:
|
||||
local_path = None
|
||||
GLib.idle_add(self._set_cover_from_path, local_path)
|
||||
return None
|
||||
|
||||
def _update_cover(self, mp):
|
||||
if not mp or not mp.arturl:
|
||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||
return
|
||||
parsed = urllib.parse.urlparse(mp.arturl)
|
||||
if parsed.scheme == "file":
|
||||
self._set_cover_from_path(urllib.parse.unquote(parsed.path))
|
||||
elif parsed.scheme in ("http", "https"):
|
||||
GLib.Thread.new("compact-artwork", self._download_artwork, mp.arturl)
|
||||
else:
|
||||
self._set_cover_from_path(mp.arturl)
|
||||
|
||||
def _apply_mpris_properties(self):
|
||||
if not self.mpris_player:
|
||||
self.mpris_label.set_text("Nothing Playing")
|
||||
self.mpris_button.get_child().set_markup(icons.stop)
|
||||
self.mpris_icon.get_child().set_markup(icons.disc)
|
||||
if self._current_display != "cavalcade":
|
||||
self.center_stack.set_visible_child(
|
||||
self.mpris_label
|
||||
) # if was title or artist, keep showing label
|
||||
# else:
|
||||
# self.center_stack.set_visible_child(
|
||||
# self.cavalcade_box
|
||||
# ) # default to cavalcade if no player
|
||||
self._set_label_text("")
|
||||
self.play_image.set_property("icon-name", self.PLAY_ICON)
|
||||
self.cover_stack.set_visible_child(self.fallback_icon)
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
self._update_spin()
|
||||
return
|
||||
|
||||
mp = self.mpris_player
|
||||
|
||||
# Choose icon based on player name.
|
||||
player_name = (
|
||||
mp.player_name.lower()
|
||||
if hasattr(mp, "player_name") and mp.player_name
|
||||
else ""
|
||||
)
|
||||
icon_markup = get_player_icon_markup_by_name(player_name)
|
||||
self.mpris_icon.get_child().set_markup(icon_markup)
|
||||
self._update_cover(mp)
|
||||
self.update_play_pause_icon()
|
||||
|
||||
if self._current_display == "title":
|
||||
text = mp.title if mp.title and mp.title.strip() else "Nothing Playing"
|
||||
self.mpris_label.set_text(text)
|
||||
text = mp.title.strip() if mp.title and mp.title.strip() else ""
|
||||
else: # "artist"
|
||||
text = mp.artist.strip() if mp.artist and mp.artist.strip() else ""
|
||||
self._set_label_text(text)
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
elif self._current_display == "artist":
|
||||
text = mp.artist if mp.artist else "Nothing Playing"
|
||||
self.mpris_label.set_text(text)
|
||||
self.center_stack.set_visible_child(self.mpris_label)
|
||||
# else: # default cavalcade
|
||||
# self.center_stack.set_visible_child(self.cavalcade_box)
|
||||
|
||||
def _on_icon_button_press(self, widget, event):
|
||||
from gi.repository import Gdk
|
||||
if event.type != Gdk.EventType.BUTTON_PRESS:
|
||||
return True
|
||||
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
players = self.mpris_manager.players
|
||||
if event.button == 2:
|
||||
if not self.mpris_player:
|
||||
return True
|
||||
self._display_index = (self._display_index + 1) % len(self._display_options)
|
||||
self._current_display = self._display_options[self._display_index]
|
||||
self._apply_mpris_properties()
|
||||
return True
|
||||
|
||||
players = list(self._players.values())
|
||||
if not players:
|
||||
return True
|
||||
|
||||
if event.button == 2: # Middle-click: cycle display
|
||||
self._display_index = (self._display_index + 1) % len(
|
||||
self._display_options
|
||||
)
|
||||
self._current_display = self._display_options[self._display_index]
|
||||
self._apply_mpris_properties() # Re-apply to update label/cavalcade
|
||||
idx = players.index(self.mpris_player) if self.mpris_player in players else -1
|
||||
if event.button == 1:
|
||||
idx = (idx + 1) % len(players)
|
||||
elif event.button == 3:
|
||||
idx = (idx - 1) % len(players)
|
||||
else:
|
||||
return True
|
||||
|
||||
# Cambiar de reproductor según el botón presionado.
|
||||
if event.button == 1: # Left-click: next player
|
||||
self.current_index = (self.current_index + 1) % len(players)
|
||||
elif event.button == 3: # Right-click: previous player
|
||||
self.current_index = (self.current_index - 1) % len(players)
|
||||
if self.current_index < 0:
|
||||
self.current_index = len(players) - 1
|
||||
|
||||
mp_new = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = mp_new
|
||||
# Conectar el evento "changed" para que se actualice
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
self.mpris_player = players[idx]
|
||||
self._apply_mpris_properties()
|
||||
return True # Se consume el evento
|
||||
return True
|
||||
|
||||
def _on_play_pause_button_press(self, widget, event):
|
||||
if event.type == Gdk.EventType.BUTTON_PRESS:
|
||||
if event.button == 1: # Click izquierdo -> track anterior
|
||||
if self.mpris_player:
|
||||
self.mpris_player.previous()
|
||||
self.mpris_button.get_child().set_markup(icons.prev)
|
||||
GLib.timeout_add(500, self._restore_play_pause_icon)
|
||||
elif event.button == 3: # Click derecho -> siguiente track
|
||||
if self.mpris_player:
|
||||
self.mpris_player.next()
|
||||
self.mpris_button.get_child().set_markup(icons.next)
|
||||
GLib.timeout_add(500, self._restore_play_pause_icon)
|
||||
elif event.button == 2: # Click medio -> play/pausa
|
||||
if self.mpris_player:
|
||||
if event.type != Gdk.EventType.BUTTON_PRESS or not self.mpris_player:
|
||||
return True
|
||||
if event.button == 1:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
return True
|
||||
elif event.button == 3:
|
||||
self.mpris_player.next()
|
||||
self.play_image.set_property("icon-name", self.NEXT_ICON)
|
||||
GLib.timeout_add(500, self._restore_play_pause_icon)
|
||||
elif event.button == 2:
|
||||
self.mpris_player.previous()
|
||||
self.play_image.set_property("icon-name", self.PREV_ICON)
|
||||
GLib.timeout_add(500, self._restore_play_pause_icon)
|
||||
return True
|
||||
|
||||
def _restore_play_pause_icon(self):
|
||||
self.update_play_pause_icon()
|
||||
return False
|
||||
|
||||
def _on_icon_clicked(
|
||||
self, widget
|
||||
): # No longer used, logic moved to _on_icon_button_press
|
||||
pass
|
||||
|
||||
def update_play_pause_icon(self):
|
||||
if self.mpris_player and self.mpris_player.playback_status == "playing":
|
||||
self.mpris_button.get_child().set_markup(icons.pause)
|
||||
else:
|
||||
self.mpris_button.get_child().set_markup(icons.play)
|
||||
playing = (
|
||||
self.mpris_player
|
||||
and self.mpris_player.playback_status == "playing"
|
||||
)
|
||||
self.play_image.set_property(
|
||||
"icon-name", self.PAUSE_ICON if playing else self.PLAY_ICON
|
||||
)
|
||||
self._update_spin()
|
||||
|
||||
def _on_play_pause_clicked(self, button):
|
||||
if self.mpris_player:
|
||||
self.mpris_player.play_pause()
|
||||
self.update_play_pause_icon()
|
||||
def _track_player(self, playerctl_player):
|
||||
mp = MprisPlayer(playerctl_player)
|
||||
name = mp.player_name
|
||||
handler_id = mp.connect("changed", self._on_any_player_changed)
|
||||
self._players[name] = mp
|
||||
self._player_handlers[name] = handler_id
|
||||
self._last_status[name] = mp.playback_status
|
||||
|
||||
def _on_mpris_changed(self, *args):
|
||||
# Update properties when the player's state changes.
|
||||
def _untrack_player(self, name):
|
||||
mp = self._players.pop(name, None)
|
||||
handler_id = self._player_handlers.pop(name, None)
|
||||
self._last_status.pop(name, None)
|
||||
if mp and handler_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
mp.disconnect(handler_id)
|
||||
|
||||
def _select_initial_player(self):
|
||||
for mp in self._players.values():
|
||||
if mp.playback_status == "playing":
|
||||
self.mpris_player = mp
|
||||
return
|
||||
if self._players:
|
||||
self.mpris_player = next(iter(self._players.values()))
|
||||
|
||||
def _on_any_player_changed(self, player):
|
||||
name = player.player_name
|
||||
prev_status = self._last_status.get(name)
|
||||
cur_status = player.playback_status
|
||||
self._last_status[name] = cur_status
|
||||
|
||||
if player is self.mpris_player:
|
||||
self._apply_mpris_properties()
|
||||
return
|
||||
|
||||
# Auto-follow: only on a fresh transition into "playing", and only
|
||||
# if the active player isn't already playing (so a manual selection
|
||||
# of a paused player isn't overridden by the player it was already
|
||||
# competing with).
|
||||
if cur_status == "playing" and prev_status != "playing":
|
||||
active_playing = (
|
||||
self.mpris_player
|
||||
and self.mpris_player.playback_status == "playing"
|
||||
)
|
||||
if not active_playing:
|
||||
self.mpris_player = player
|
||||
self._apply_mpris_properties()
|
||||
|
||||
def on_player_appeared(self, manager, player):
|
||||
# When a new player appears, use it if no player is active.
|
||||
self._track_player(player)
|
||||
if not self.mpris_player:
|
||||
mp = MprisPlayer(player)
|
||||
self.mpris_player = mp
|
||||
name = player.get_property("player-name")
|
||||
self.mpris_player = self._players.get(name)
|
||||
self._apply_mpris_properties()
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
|
||||
def on_player_vanished(self, manager, player_name):
|
||||
players = self.mpris_manager.players
|
||||
if (
|
||||
players
|
||||
and self.mpris_player
|
||||
and self.mpris_player.player_name == player_name
|
||||
):
|
||||
if players: # Check if players is not empty after vanishing
|
||||
self.current_index = self.current_index % len(players)
|
||||
new_player = MprisPlayer(players[self.current_index])
|
||||
self.mpris_player = new_player
|
||||
self.mpris_player.connect("changed", self._on_mpris_changed)
|
||||
else:
|
||||
self.mpris_player = None # No players left
|
||||
elif not players:
|
||||
self.mpris_player = None
|
||||
was_active = (
|
||||
self.mpris_player and self.mpris_player.player_name == player_name
|
||||
)
|
||||
self._untrack_player(player_name)
|
||||
if was_active:
|
||||
replacement = next(
|
||||
(mp for mp in self._players.values() if mp.playback_status == "playing"),
|
||||
None,
|
||||
)
|
||||
if replacement is None and self._players:
|
||||
replacement = next(iter(self._players.values()))
|
||||
self.mpris_player = replacement
|
||||
self._apply_mpris_properties()
|
||||
59
sims/modules/screenrec.py
Normal file
59
sims/modules/screenrec.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import time
|
||||
|
||||
from fabric.utils import invoke_repeater
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import GLib
|
||||
|
||||
from sims.services.screenrec import ScreenrecService
|
||||
|
||||
|
||||
class ScreenrecWidget(Button):
|
||||
def __init__(self, service: ScreenrecService, **kwargs):
|
||||
self._service = service
|
||||
self._timer_id: int | None = None
|
||||
|
||||
self._dot = Label(name="screenrec-dot", label="●")
|
||||
self._elapsed = Label(name="screenrec-elapsed", label="00:00")
|
||||
|
||||
super().__init__(
|
||||
name="screenrec-widget",
|
||||
child=Box(
|
||||
orientation="h",
|
||||
spacing=6,
|
||||
children=[self._dot, self._elapsed],
|
||||
),
|
||||
on_clicked=lambda *_: self._service.stop(),
|
||||
visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.set_no_show_all(True)
|
||||
|
||||
self._service.connect("recording-changed", self._on_recording_changed)
|
||||
if self._service.recording:
|
||||
self._on_recording_changed(self._service, True)
|
||||
|
||||
def _on_recording_changed(self, _service, recording: bool):
|
||||
if recording:
|
||||
self._update_elapsed()
|
||||
self.set_visible(True)
|
||||
if self._timer_id is None:
|
||||
self._timer_id = invoke_repeater(1000, self._update_elapsed)
|
||||
else:
|
||||
self.set_visible(False)
|
||||
if self._timer_id is not None:
|
||||
try:
|
||||
GLib.source_remove(self._timer_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._timer_id = None
|
||||
|
||||
def _update_elapsed(self) -> bool:
|
||||
started = self._service.started_at
|
||||
if started is None:
|
||||
self._elapsed.set_text("00:00")
|
||||
return True
|
||||
secs = int(time.monotonic() - started)
|
||||
self._elapsed.set_text(f"{secs // 60:02d}:{secs % 60:02d}")
|
||||
return True
|
||||
@@ -1,4 +1,4 @@
|
||||
from bar.config import STYLIX
|
||||
from sims.config import STYLIX
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
@@ -60,11 +60,17 @@ def generate_stylix_css():
|
||||
}}
|
||||
|
||||
/* Bar styling */
|
||||
#bar-inner {{
|
||||
#sims-inner {{
|
||||
padding: 4px;
|
||||
border-bottom: solid 2px;
|
||||
border-color: #{colors["base02"]};
|
||||
background-color: #{colors["base00"]};
|
||||
border-radius: 0;
|
||||
transition: border-radius 200ms ease;
|
||||
}}
|
||||
|
||||
#sims-inner.rounded-bottom {{
|
||||
border-radius: 0 0 28px 28px;
|
||||
}}
|
||||
|
||||
#center-container {{
|
||||
@@ -267,9 +273,6 @@ tooltip>* {{
|
||||
|
||||
#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) {{
|
||||
@@ -282,18 +285,58 @@ tooltip>* {{
|
||||
|
||||
#workspaces>button.active {{
|
||||
background-color: #{colors["base0D"]};
|
||||
padding: 0px 16px;
|
||||
border-radius: 100px;
|
||||
}}
|
||||
|
||||
#workspaces>button.urgent {{
|
||||
background-color: #{colors["base08"]};
|
||||
}}
|
||||
|
||||
/* Workspace shown on a different monitor than this bar — dim it. */
|
||||
#workspaces>button.foreign {{
|
||||
opacity: 0.45;
|
||||
}}
|
||||
|
||||
#workspaces>button>label {{
|
||||
font-size: 0px;
|
||||
}}
|
||||
|
||||
/* Compact MPRIS player */
|
||||
#player-small {{
|
||||
background-color: #{colors["base01"]};
|
||||
padding: 6px;
|
||||
border-radius: 100px;
|
||||
}}
|
||||
|
||||
#compact-mpris-icon,
|
||||
#compact-mpris-button {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 4px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}}
|
||||
|
||||
#compact-mpris-button-icon,
|
||||
#compact-mpris-fallback {{
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
#compact-mpris-button:hover #compact-mpris-button-icon {{
|
||||
color: #{colors["base0D"]};
|
||||
}}
|
||||
|
||||
#compact-mpris-fallback {{
|
||||
opacity: 0.7;
|
||||
}}
|
||||
|
||||
#compact-mpris-label {{
|
||||
color: #{colors["base05"]};
|
||||
font-size: {font_size}px;
|
||||
margin: 0 6px;
|
||||
}}
|
||||
|
||||
/* Quick Menu styling */
|
||||
#quick-menu-container {{
|
||||
background-color: #{colors["base00"]};
|
||||
@@ -356,6 +399,64 @@ tooltip>* {{
|
||||
.toggle-inactive {{
|
||||
background-color: #{colors["base02"]};
|
||||
}}
|
||||
|
||||
/* Launcher (FuzzyMenu — finder, app launcher, screenrec menu) */
|
||||
#picker-box {{
|
||||
background-color: #{colors["base00"]};
|
||||
border: solid 1px #{colors["base02"]};
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
#search-entry {{
|
||||
background-color: #{colors["base01"]};
|
||||
color: #{colors["base05"]};
|
||||
border: solid 1px #{colors["base02"]};
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}}
|
||||
|
||||
#viewport {{
|
||||
background-color: #{colors["base00"]};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
#viewport > * {{
|
||||
background-color: #{colors["base01"]};
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 4px;
|
||||
transition: background-color 80ms ease, border-color 80ms ease;
|
||||
}}
|
||||
|
||||
#viewport > *.selected {{
|
||||
background-color: alpha(#{colors["base0D"]}, 0.28);
|
||||
border-left-color: #{colors["base0D"]};
|
||||
color: #{colors["base05"]};
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
#viewport > *.selected label {{
|
||||
color: #{colors["base05"]};
|
||||
}}
|
||||
|
||||
#app-icon {{
|
||||
margin-right: 4px;
|
||||
}}
|
||||
|
||||
#app-name {{
|
||||
color: #{colors["base05"]};
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
#app-generic {{
|
||||
color: #{colors["base04"]};
|
||||
font-size: {small_font}px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# Write to temporary file
|
||||
9
sims/modules/window_fuzzy.py
Normal file
9
sims/modules/window_fuzzy.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from sims.modules.launcher import FuzzyMenu, WindowProvider
|
||||
|
||||
|
||||
def FuzzyWindowFinder(monitor: int = 0) -> FuzzyMenu:
|
||||
return FuzzyMenu(
|
||||
provider=WindowProvider(),
|
||||
monitor=monitor,
|
||||
placeholder="Search Windows...",
|
||||
)
|
||||
55
sims/services/fenster.py
Normal file
55
sims/services/fenster.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Fenster/Sway IPC connection helper.
|
||||
|
||||
Provides a singleton I3 connection configured for Fenster's SWAYSOCK.
|
||||
"""
|
||||
|
||||
import os
|
||||
from sims.services.i3 import I3, I3MessageType
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
def focused_output_index() -> int:
|
||||
"""Index of the currently focused output in active GET_OUTPUTS order.
|
||||
|
||||
Matches the indexing main.spawn_bars uses for `monitor=` so layer-shell
|
||||
windows opened with the same index land on the focused output. Returns 0
|
||||
on any IPC failure or if the focused output cannot be located.
|
||||
"""
|
||||
ws_reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
|
||||
if not (ws_reply.is_ok and isinstance(ws_reply.reply, list)):
|
||||
return 0
|
||||
focused_output = next(
|
||||
(ws.get("output") for ws in ws_reply.reply if ws.get("focused")),
|
||||
None,
|
||||
)
|
||||
if not focused_output:
|
||||
return 0
|
||||
out_reply = I3.send_command("", I3MessageType.GET_OUTPUTS)
|
||||
if not (out_reply.is_ok and isinstance(out_reply.reply, list)):
|
||||
return 0
|
||||
active = [o for o in out_reply.reply if o.get("active")]
|
||||
for i, o in enumerate(active):
|
||||
if o.get("name") == focused_output:
|
||||
return i
|
||||
return 0
|
||||
245
sims/services/i3.py
Normal file
245
sims/services/i3.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Vendored i3/sway IPC client (originally from fabric.i3).
|
||||
|
||||
Maintained in-tree so we can extend `I3MessageType` with fenster-specific
|
||||
event types without monkey-patching upstream fabric. To add a new event:
|
||||
|
||||
1. Add a new `*_EVENT` member to `I3MessageType` with the wire type number
|
||||
(`0x80000000 | <event_id>`).
|
||||
2. Make sure fenster broadcasts it under the matching atom and accepts the
|
||||
subscription string (the auto-derived name = enum-name lowercased without
|
||||
`_event`).
|
||||
3. Subscribers connect to `event::<name>` (or `event::<name>::<change>` for
|
||||
sub-events).
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from loguru import logger
|
||||
from typing import ParamSpec
|
||||
from dataclasses import dataclass
|
||||
from fabric.core.service import Service, Signal, Property
|
||||
from fabric.utils.helpers import exec_shell_command, idle_add
|
||||
from gi.repository import GLib
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
SOCKET_MAGIC = b"i3-ipc"
|
||||
|
||||
|
||||
# exceptions
|
||||
class I3Error(Exception): ...
|
||||
|
||||
|
||||
class I3SocketError(I3Error): ...
|
||||
|
||||
|
||||
class I3SocketNotFoundError(I3SocketError): ...
|
||||
|
||||
|
||||
class I3MessageType(IntEnum):
|
||||
# commands
|
||||
COMMAND = 0
|
||||
GET_WORKSPACES = 1
|
||||
SUBSCRIBE = 2
|
||||
GET_OUTPUTS = 3
|
||||
GET_TREE = 4
|
||||
GET_MARKS = 5
|
||||
GET_BAR_CONFIG = 6
|
||||
GET_VERSION = 7
|
||||
GET_BINDING_MODES = 8
|
||||
GET_CONFIG = 9
|
||||
SEND_TICK = 10
|
||||
SYNC = 11
|
||||
GET_BINDING_STATE = 12
|
||||
# sway only
|
||||
GET_INPUTS = 100
|
||||
GET_SEATS = 101
|
||||
|
||||
# events
|
||||
WORKSPACE_EVENT = 0x80000000
|
||||
OUTPUT_EVENT = 0x80000001
|
||||
MODE_EVENT = 0x80000002
|
||||
WINDOW_EVENT = 0x80000003
|
||||
BARCONFIG_UPDATE_EVENT = 0x80000004
|
||||
BINDING_EVENT = 0x80000005
|
||||
SHUTDOWN_EVENT = 0x80000006
|
||||
TICK_EVENT = 0x80000007
|
||||
# sway only
|
||||
BAR_STATE_UPDATE_EVENT = 0x80000014
|
||||
INPUT_EVENT = 0x80000015
|
||||
# fenster extensions (event id 100+)
|
||||
SMART_CORNERS_EVENT = 0x80000064
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class I3Event:
|
||||
name: str
|
||||
"the name of the received event"
|
||||
data: dict
|
||||
"the json data gotten from event's body"
|
||||
raw_data: bytes
|
||||
"the raw json data"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class I3Reply:
|
||||
command: str
|
||||
"the passed in command"
|
||||
reply: dict | list
|
||||
"the raw reply from i3/sway as a dict or list"
|
||||
is_ok: bool
|
||||
"this indicates if the ran command has returned a success message"
|
||||
|
||||
|
||||
class I3(Service):
|
||||
"""
|
||||
A connection to the i3/Sway's IPC socket.
|
||||
This can be used for sending commands and receiving events.
|
||||
"""
|
||||
|
||||
SOCKET_PATH: str | None = None
|
||||
|
||||
@Property(bool, "readable", "is-ready", default_value=False)
|
||||
def ready(self) -> bool:
|
||||
return self._ready
|
||||
|
||||
@Signal("event", flags="detailed")
|
||||
def event(self, event: object): ...
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self._ready = False
|
||||
self.lookup_socket()
|
||||
|
||||
self.event_socket_thread = GLib.Thread.new(
|
||||
"i3-socket-service",
|
||||
self.event_socket_task, # type: ignore
|
||||
self.SOCKET_PATH,
|
||||
)
|
||||
|
||||
self._ready = True
|
||||
self.notify("ready")
|
||||
|
||||
@staticmethod
|
||||
def lookup_socket() -> str:
|
||||
if I3.SOCKET_PATH:
|
||||
return I3.SOCKET_PATH
|
||||
|
||||
for cmd in ("sway", "i3"):
|
||||
path = exec_shell_command(f"{cmd} --get-socketpath")
|
||||
if not path or not (path := path.strip()) or not os.path.exists(path):
|
||||
continue
|
||||
|
||||
I3.SOCKET_PATH = path
|
||||
|
||||
return I3.SOCKET_PATH
|
||||
|
||||
raise I3SocketNotFoundError(
|
||||
"Couldn't find i3 or Sway socket, is either of them running?"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def pack(message_type: I3MessageType, payload: str = "") -> bytes:
|
||||
payload_bytes = payload.encode()
|
||||
header = struct.pack("<II", len(payload_bytes), message_type.value)
|
||||
return SOCKET_MAGIC + header + payload_bytes
|
||||
|
||||
@staticmethod
|
||||
def unpack(connection: socket.socket) -> tuple[int, str]:
|
||||
header = connection.recv(14)
|
||||
if len(header) != 14:
|
||||
raise I3SocketError("Failed to read IPC header")
|
||||
|
||||
magic, length, message_type = struct.unpack("<6sII", header)
|
||||
if magic != SOCKET_MAGIC:
|
||||
raise I3SocketError(f"Invalid IPC magic string ({magic}). Report this!")
|
||||
|
||||
return message_type, connection.recv(length).decode()
|
||||
|
||||
@staticmethod
|
||||
def send_command(
|
||||
command: str, message_type: I3MessageType = I3MessageType.COMMAND
|
||||
) -> I3Reply:
|
||||
"""
|
||||
Sends a command to the i3/sway socket.
|
||||
|
||||
example usage:
|
||||
```python
|
||||
# next workspace...
|
||||
I3.send_command("workspace next")
|
||||
```
|
||||
:param command: The command to send.
|
||||
:type command: str
|
||||
:param message_type: The type of message to send.
|
||||
:type message_type: I3MessageType, optional
|
||||
:return: A reply object containing the data from i3/sway.
|
||||
:rtype: I3Reply
|
||||
"""
|
||||
reply_data = {}
|
||||
is_ok = False
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(I3.lookup_socket())
|
||||
sock.sendall(I3.pack(message_type, command))
|
||||
|
||||
_, payload = I3.unpack(sock)
|
||||
reply_data = json.loads(payload)
|
||||
|
||||
# results for any GET_* command is considered ok
|
||||
# other commands a success reply is a list of dicts with {"success": True}
|
||||
if (message_type != I3MessageType.COMMAND) or (
|
||||
isinstance(reply_data, list)
|
||||
and reply_data
|
||||
and reply_data[0].get("success")
|
||||
):
|
||||
is_ok = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[I3Service] got error while sending command via socket ({e})"
|
||||
)
|
||||
|
||||
return I3Reply(command=command, reply=reply_data, is_ok=is_ok)
|
||||
|
||||
def event_socket_task(self, socket_addr: str) -> bool:
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(socket_addr)
|
||||
|
||||
# subscribe to all events
|
||||
sock.sendall(
|
||||
self.pack(
|
||||
I3MessageType.SUBSCRIBE,
|
||||
json.dumps(
|
||||
[
|
||||
evnt_name.replace("_event", "")
|
||||
for mt in I3MessageType
|
||||
if (evnt_name := mt.name.lower()).endswith("_event")
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
self.unpack(sock) # success reply
|
||||
|
||||
while True:
|
||||
idle_add(self.handle_raw_event, *self.unpack(sock))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[I3Service] events socket thread ended with an error: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def handle_raw_event(self, message_type: int, payload: str):
|
||||
event_data = json.loads(payload)
|
||||
event_name = I3MessageType(message_type).name.lower().replace("_event", "")
|
||||
|
||||
if "change" in event_data: # subevents
|
||||
event_name = f"{event_name}::{event_data['change']}"
|
||||
|
||||
return self.emit(
|
||||
f"event::{event_name}",
|
||||
I3Event(event_name, event_data, payload.encode()),
|
||||
)
|
||||
@@ -265,7 +265,11 @@ class MprisPlayerManager(Service):
|
||||
|
||||
def on_name_appeard(self, manager, player_name: Playerctl.PlayerName):
|
||||
logger.info(f"[MprisPlayer] {player_name.name} appeared")
|
||||
try:
|
||||
new_player = Playerctl.Player.new_from_name(player_name)
|
||||
except GLib.Error as e:
|
||||
logger.warning(f"[MprisPlayer] could not attach to {player_name.name}: {e}")
|
||||
return
|
||||
manager.manage_player(new_player)
|
||||
self.emit("player-appeared", new_player) # type: ignore
|
||||
|
||||
@@ -275,7 +279,10 @@ class MprisPlayerManager(Service):
|
||||
|
||||
def add_players(self):
|
||||
for player in self._manager.get_property("player-names"): # type: ignore
|
||||
try:
|
||||
self._manager.manage_player(Playerctl.Player.new_from_name(player)) # type: ignore
|
||||
except GLib.Error as e:
|
||||
logger.warning(f"[MprisPlayer] could not attach to {getattr(player, 'name', player)}: {e}")
|
||||
|
||||
@Property(object, "readable")
|
||||
def players(self):
|
||||
106
sims/services/notification_history.py
Normal file
106
sims/services/notification_history.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""In-memory notification history.
|
||||
|
||||
Subscribes to a fabric Notifications service and snapshots each incoming
|
||||
notification before fabric drops it on close. Snapshots scale image pixbufs
|
||||
down to a bounded edge so a flood of high-res previews can't balloon memory.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fabric.core.service import Service, Signal
|
||||
from fabric.notifications import Notification, Notifications
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
|
||||
@dataclass
|
||||
class HistoryEntry:
|
||||
id: int
|
||||
summary: str
|
||||
body: str
|
||||
urgency: int
|
||||
timestamp: float
|
||||
pixbuf: GdkPixbuf.Pixbuf | None
|
||||
|
||||
|
||||
def _scale_pixbuf(
|
||||
pixbuf: GdkPixbuf.Pixbuf | None, max_edge: int
|
||||
) -> GdkPixbuf.Pixbuf | None:
|
||||
if pixbuf is None:
|
||||
return None
|
||||
w, h = pixbuf.get_width(), pixbuf.get_height()
|
||||
if w <= max_edge and h <= max_edge:
|
||||
return pixbuf
|
||||
scale = max_edge / max(w, h)
|
||||
return pixbuf.scale_simple(
|
||||
max(1, int(w * scale)),
|
||||
max(1, int(h * scale)),
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
)
|
||||
|
||||
|
||||
class NotificationHistoryService(Service):
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
notifications: Notifications,
|
||||
history_size: int = 50,
|
||||
image_max_px: int = 128,
|
||||
):
|
||||
super().__init__()
|
||||
self._entries: deque[HistoryEntry] = deque(maxlen=history_size)
|
||||
self._image_max_px = image_max_px
|
||||
self._unseen_count = 0
|
||||
notifications.connect("notification-added", self._on_added)
|
||||
|
||||
def _on_added(self, service: Notifications, nid: int) -> None:
|
||||
notification: Notification | None = service.get_notification_from_id(nid)
|
||||
if notification is None:
|
||||
return
|
||||
self._entries.appendleft(
|
||||
HistoryEntry(
|
||||
id=nid,
|
||||
summary=notification.summary or "",
|
||||
body=notification.body or "",
|
||||
urgency=notification.urgency,
|
||||
timestamp=time.time(),
|
||||
pixbuf=_scale_pixbuf(
|
||||
notification.image_pixbuf, self._image_max_px
|
||||
),
|
||||
)
|
||||
)
|
||||
self._unseen_count += 1
|
||||
self.changed()
|
||||
|
||||
@property
|
||||
def entries(self) -> list[HistoryEntry]:
|
||||
return list(self._entries)
|
||||
|
||||
@property
|
||||
def unseen_count(self) -> int:
|
||||
return self._unseen_count
|
||||
|
||||
def mark_all_seen(self) -> None:
|
||||
if self._unseen_count == 0:
|
||||
return
|
||||
self._unseen_count = 0
|
||||
self.changed()
|
||||
|
||||
def remove(self, entry_id: int) -> None:
|
||||
before = len(self._entries)
|
||||
self._entries = deque(
|
||||
(e for e in self._entries if e.id != entry_id),
|
||||
maxlen=self._entries.maxlen,
|
||||
)
|
||||
if len(self._entries) != before:
|
||||
self.changed()
|
||||
|
||||
def clear(self) -> None:
|
||||
if not self._entries:
|
||||
return
|
||||
self._entries.clear()
|
||||
self._unseen_count = 0
|
||||
self.changed()
|
||||
230
sims/services/screenrec.py
Normal file
230
sims/services/screenrec.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""wl-screenrec process manager.
|
||||
|
||||
Owns the wl-screenrec subprocess so the bar reflects real recording state
|
||||
without polling. Survives bar restarts via orphan adoption.
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fabric.core.service import Service, Signal
|
||||
from sims.services.i3 import I3, I3MessageType
|
||||
from gi.repository import GLib
|
||||
from loguru import logger
|
||||
|
||||
Destination = Literal["videos", "clipboard"]
|
||||
|
||||
|
||||
class ScreenrecService(Service):
|
||||
@Signal
|
||||
def recording_changed(self, recording: bool) -> None: ...
|
||||
|
||||
def __init__(self, output_dir: str = "~/Videos/wl-screenrec"):
|
||||
super().__init__()
|
||||
self._output_dir = os.path.expanduser(output_dir)
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._adopted_pid: int | None = None
|
||||
self._started_at: float | None = None
|
||||
self._output_path: str | None = None
|
||||
self._destination: Destination | None = None
|
||||
self._watch_id: int | None = None
|
||||
|
||||
self._adopt_orphan_if_running()
|
||||
|
||||
@property
|
||||
def recording(self) -> bool:
|
||||
return self._proc is not None or self._adopted_pid is not None
|
||||
|
||||
@property
|
||||
def started_at(self) -> float | None:
|
||||
return self._started_at
|
||||
|
||||
def start_monitor(self, dest: Destination = "videos", output: str | None = None) -> None:
|
||||
if self.recording:
|
||||
logger.warning("[Screenrec] start_monitor: already recording")
|
||||
return
|
||||
path = self._make_output_path(dest)
|
||||
|
||||
# Prefer an explicit output name when the caller passes one, but fenster's
|
||||
# IPC reports synthetic names ("Unknown-XXXX") that wl-screenrec can't
|
||||
# resolve. Fall back to the focused output's geometry, which works
|
||||
# across i3/sway/fenster.
|
||||
if output and not output.startswith("Unknown-"):
|
||||
self._spawn(["wl-screenrec", "-o", output, "-f", path], path, dest)
|
||||
return
|
||||
|
||||
geom = self._focused_output_geometry()
|
||||
if not geom:
|
||||
logger.error("[Screenrec] no focused output found")
|
||||
return
|
||||
self._spawn(["wl-screenrec", "-g", geom, "-f", path], path, dest)
|
||||
|
||||
def start_region(self, dest: Destination = "videos") -> None:
|
||||
if self.recording:
|
||||
logger.warning("[Screenrec] start_region: already recording")
|
||||
return
|
||||
geom = self._slurp_region()
|
||||
if not geom:
|
||||
logger.info("[Screenrec] region selection cancelled")
|
||||
return
|
||||
path = self._make_output_path(dest)
|
||||
self._spawn(["wl-screenrec", "-g", geom, "-f", path], path, dest)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._proc is not None:
|
||||
logger.info("[Screenrec] sending SIGINT to wl-screenrec")
|
||||
try:
|
||||
self._proc.send_signal(signal.SIGINT)
|
||||
except ProcessLookupError:
|
||||
self._on_exit(self._proc.pid, 0)
|
||||
return
|
||||
if self._adopted_pid is not None:
|
||||
logger.info(f"[Screenrec] sending SIGINT to adopted pid {self._adopted_pid}")
|
||||
try:
|
||||
os.kill(self._adopted_pid, signal.SIGINT)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self._poll_adopted_until_gone()
|
||||
|
||||
def _spawn(self, argv: list[str], path: str, dest: Destination) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
logger.info(f"[Screenrec] spawning: {' '.join(argv)}")
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("[Screenrec] wl-screenrec not found on PATH")
|
||||
return
|
||||
self._output_path = path
|
||||
self._destination = dest
|
||||
self._started_at = time.monotonic()
|
||||
self._watch_id = GLib.child_watch_add(
|
||||
GLib.PRIORITY_DEFAULT, self._proc.pid, self._on_exit
|
||||
)
|
||||
self.recording_changed(True)
|
||||
|
||||
def _on_exit(self, pid: int, status: int) -> None:
|
||||
logger.info(f"[Screenrec] wl-screenrec exited (pid={pid} status={status})")
|
||||
path = self._output_path
|
||||
dest = self._destination
|
||||
self._reset_state()
|
||||
if dest == "clipboard" and path and os.path.exists(path):
|
||||
self._copy_to_clipboard(path)
|
||||
self.recording_changed(False)
|
||||
|
||||
def _reset_state(self) -> None:
|
||||
self._proc = None
|
||||
self._adopted_pid = None
|
||||
self._started_at = None
|
||||
self._output_path = None
|
||||
self._destination = None
|
||||
if self._watch_id is not None:
|
||||
try:
|
||||
GLib.source_remove(self._watch_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._watch_id = None
|
||||
|
||||
def _copy_to_clipboard(self, path: str) -> None:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
subprocess.Popen(
|
||||
["wl-copy", "-t", "video/mp4"],
|
||||
stdin=f,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info(f"[Screenrec] copied {path} to clipboard as video/mp4")
|
||||
except FileNotFoundError:
|
||||
logger.error("[Screenrec] wl-copy not found on PATH")
|
||||
|
||||
def _slurp_region(self) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["slurp"], capture_output=True, text=True, check=False
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error("[Screenrec] slurp not found on PATH")
|
||||
return None
|
||||
geom = result.stdout.strip()
|
||||
return geom or None
|
||||
|
||||
def _focused_output(self) -> str | None:
|
||||
reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
|
||||
if not (reply.is_ok and isinstance(reply.reply, list)):
|
||||
return None
|
||||
for ws in reply.reply:
|
||||
if ws.get("focused"):
|
||||
return ws.get("output")
|
||||
return None
|
||||
|
||||
def _focused_output_geometry(self) -> str | None:
|
||||
"""Return the focused output's geometry as 'X,Y WxH' (slurp format).
|
||||
|
||||
Used as a portable fallback when the IPC's output names aren't real
|
||||
wl_output names (e.g. fenster reports 'Unknown-XXXX').
|
||||
"""
|
||||
reply = I3.send_command("", I3MessageType.GET_WORKSPACES)
|
||||
if not (reply.is_ok and isinstance(reply.reply, list)):
|
||||
return None
|
||||
for ws in reply.reply:
|
||||
if ws.get("focused"):
|
||||
rect = ws.get("rect") or {}
|
||||
x = rect.get("x")
|
||||
y = rect.get("y")
|
||||
w = rect.get("width")
|
||||
h = rect.get("height")
|
||||
if None in (x, y, w, h) or w <= 0 or h <= 0:
|
||||
return None
|
||||
return f"{x},{y} {w}x{h}"
|
||||
return None
|
||||
|
||||
def _make_output_path(self, dest: Destination) -> str:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
if dest == "clipboard":
|
||||
tmp = os.path.join(GLib.get_tmp_dir(), f"sims-screenrec-{stamp}.mp4")
|
||||
return tmp
|
||||
return os.path.join(self._output_dir, f"{stamp}.mp4")
|
||||
|
||||
def _adopt_orphan_if_running(self) -> None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-x", "wl-screenrec"], capture_output=True, text=True
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
pid_str = result.stdout.strip().split("\n")[0] if result.stdout.strip() else ""
|
||||
if not pid_str:
|
||||
return
|
||||
try:
|
||||
self._adopted_pid = int(pid_str)
|
||||
except ValueError:
|
||||
return
|
||||
logger.info(f"[Screenrec] adopted orphan wl-screenrec pid={self._adopted_pid}")
|
||||
self._started_at = time.monotonic()
|
||||
self.recording_changed(True)
|
||||
|
||||
def _poll_adopted_until_gone(self) -> None:
|
||||
def check() -> bool:
|
||||
if self._adopted_pid is None:
|
||||
return False
|
||||
try:
|
||||
os.kill(self._adopted_pid, 0)
|
||||
except ProcessLookupError:
|
||||
logger.info("[Screenrec] adopted wl-screenrec finished")
|
||||
self._reset_state()
|
||||
self.recording_changed(False)
|
||||
return False
|
||||
return True
|
||||
|
||||
GLib.timeout_add(250, check)
|
||||
49
sims/services/smart_corners.py
Normal file
49
sims/services/smart_corners.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Smart-corners IPC subscriber.
|
||||
|
||||
Listens to fenster's `:smart_corners` event and emits a per-output signal
|
||||
when the WM's smart-corners state flips. Caches the latest state per output
|
||||
so widgets created after the event can ask for the current value.
|
||||
"""
|
||||
from fabric.core.service import Service, Signal
|
||||
from loguru import logger
|
||||
|
||||
from sims.services.fenster import get_i3_connection
|
||||
|
||||
|
||||
_service: "SmartCornersService | None" = None
|
||||
|
||||
|
||||
class SmartCornersService(Service):
|
||||
@Signal
|
||||
def state_changed(self, output: str, active: bool) -> None:
|
||||
"""Emitted when a fenster output flips smart-corners state."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._state: dict[str, bool] = {}
|
||||
i3 = get_i3_connection()
|
||||
i3.connect("event::smart_corners::active", self._on_event)
|
||||
i3.connect("event::smart_corners::inactive", self._on_event)
|
||||
|
||||
def get(self, output: str) -> bool:
|
||||
"""Latest known state for an output, or False if unseen."""
|
||||
return self._state.get(output, False)
|
||||
|
||||
def _on_event(self, _i3, event):
|
||||
change = event.data.get("change")
|
||||
output = event.data.get("output")
|
||||
if not isinstance(output, str) or change not in ("active", "inactive"):
|
||||
logger.warning(f"[SmartCorners] unexpected event payload: {event.data!r}")
|
||||
return
|
||||
active = change == "active"
|
||||
if self._state.get(output) == active:
|
||||
return
|
||||
self._state[output] = active
|
||||
self.state_changed(output, active)
|
||||
|
||||
|
||||
def get_smart_corners_service() -> SmartCornersService:
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = SmartCornersService()
|
||||
return _service
|
||||
@@ -1,8 +1,14 @@
|
||||
#bar-inner {
|
||||
#sims-inner {
|
||||
padding: 4px;
|
||||
border-bottom: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--window-bg);
|
||||
border-radius: 0;
|
||||
transition: border-radius 200ms ease;
|
||||
}
|
||||
|
||||
#sims-inner.rounded-bottom {
|
||||
border-radius: 0 0 28px 28px;
|
||||
}
|
||||
|
||||
#center-container {
|
||||
@@ -56,10 +62,63 @@
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#player-small {
|
||||
background-color: var(--module-bg);
|
||||
padding: 6px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
#compact-mpris-icon,
|
||||
#compact-mpris-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 4px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#compact-mpris-button-icon {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#compact-mpris-button:hover #compact-mpris-button-icon {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
#compact-mpris-fallback {
|
||||
color: var(--foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#compact-mpris-label {
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#nixos-label {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
#screenrec-widget {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0 6px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#screenrec-dot {
|
||||
color: #ff4444;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#screenrec-elapsed {
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
tooltip {
|
||||
border: solid 2px;
|
||||
border-color: var(--border-color);
|
||||
@@ -16,51 +16,13 @@
|
||||
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 */
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin: 2px 0px;
|
||||
background-color: var(--light-bg);
|
||||
border: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
92
sims/styles/launcher.css
Normal file
92
sims/styles/launcher.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* Shared styles for the fuzzy-menu launcher (window finder, app launcher,
|
||||
screenrec menu, and any future provider built on FuzzyMenu).
|
||||
Element IDs come from sims/modules/launcher/base.py. */
|
||||
|
||||
#picker-box {
|
||||
padding: 12px;
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
border-radius: 8px;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
#viewport {
|
||||
padding: 8px;
|
||||
background-color: rgba(30, 30, 30, 0.9);
|
||||
border-radius: 6px;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#viewport > * {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-left: 3px solid transparent;
|
||||
transition: background-color 80ms ease, border-color 80ms ease;
|
||||
}
|
||||
|
||||
#viewport > *.selected {
|
||||
background-color: rgba(137, 180, 250, 0.28);
|
||||
border-left-color: #89b4fa;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#viewport > *.selected label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Provider-specific tweaks (apps launcher) */
|
||||
#app-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
#app-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#app-generic {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Provider-specific tweaks (notmuch search) */
|
||||
#notmuch-text {
|
||||
/* Let the date column hug the right edge */
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#notmuch-subject {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#notmuch-authors {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#notmuch-date {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#notmuch-saved-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#notmuch-saved-query {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#notmuch-bare {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
@import url("./menu.css");
|
||||
@import url("./vinyl.css");
|
||||
@import url("./bar.css");
|
||||
@import url("./finder.css");
|
||||
@import url("./launcher.css");
|
||||
@import url("./calendar.css");
|
||||
@import url("./notmuch.css");
|
||||
@import url("./notifications.css");
|
||||
|
||||
|
||||
/* unset so we can style everything from the ground up. */
|
||||
179
sims/styles/notifications.css
Normal file
179
sims/styles/notifications.css
Normal file
@@ -0,0 +1,179 @@
|
||||
#notification {
|
||||
padding: 0.8rem;
|
||||
border: solid 1px var(--border-color);
|
||||
border-radius: 1rem;
|
||||
background-color: var(--mid-bg);
|
||||
}
|
||||
|
||||
#notification .summary {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#notification .body {
|
||||
color: var(--mid-fg);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#notification button {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.6rem;
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#notification button:hover {
|
||||
background-color: var(--dark-grey);
|
||||
}
|
||||
|
||||
#notification.urgency-low {
|
||||
border-color: var(--dark-grey);
|
||||
}
|
||||
|
||||
#notification.urgency-low .summary {
|
||||
color: var(--mid-fg);
|
||||
}
|
||||
|
||||
#notification.urgency-critical {
|
||||
border-color: var(--red);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
#notification.urgency-critical .summary {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* Control center side rail */
|
||||
|
||||
#control-center {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#control-center-body {
|
||||
background-color: var(--background);
|
||||
border-left: solid 2px var(--border-color);
|
||||
border-bottom: solid 2px var(--border-color);
|
||||
border-bottom-left-radius: 28px;
|
||||
padding: 50px 12px 12px 12px;
|
||||
}
|
||||
|
||||
#control-center-header {
|
||||
padding: 4px 8px 12px 8px;
|
||||
border-bottom: solid 1px var(--border-color);
|
||||
}
|
||||
|
||||
#control-center-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#control-center-close {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--module-bg);
|
||||
}
|
||||
|
||||
#control-center-close:hover {
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#control-center-sections {
|
||||
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);
|
||||
}
|
||||
|
||||
#control-center-notifications-list {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
#control-center-notifications-empty {
|
||||
color: var(--light-grey);
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
#notification-history-entry {
|
||||
padding: 10px;
|
||||
border: solid 1px var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--light-bg);
|
||||
}
|
||||
|
||||
#notification-history-entry .summary {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#notification-history-entry .body {
|
||||
color: var(--mid-fg);
|
||||
}
|
||||
|
||||
#notification-history-entry .timestamp {
|
||||
color: var(--light-grey);
|
||||
font-size: 12px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
#notification-history-entry.urgency-critical {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
#notification-history-entry.urgency-critical .summary {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
#notification-history-dismiss {
|
||||
padding: 2px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
#notification-history-dismiss:hover {
|
||||
background-color: var(--dark-grey);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,22 @@
|
||||
background-color: var(--module-bg);
|
||||
}
|
||||
|
||||
#notmuch-widget.debt-warn {
|
||||
background-color: var(--orange);
|
||||
}
|
||||
|
||||
#notmuch-widget.debt-warn:hover {
|
||||
background-color: var(--gold);
|
||||
}
|
||||
|
||||
#notmuch-widget.debt-alarm {
|
||||
background-color: var(--red);
|
||||
}
|
||||
|
||||
#notmuch-widget.debt-alarm:hover {
|
||||
background-color: var(--pink);
|
||||
}
|
||||
|
||||
#unread-count {
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
@@ -30,6 +46,8 @@
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
#notmuch-widget.has-unread #unread-count {
|
||||
#notmuch-widget.has-unread #unread-count,
|
||||
#notmuch-widget.debt-warn #unread-count,
|
||||
#notmuch-widget.debt-alarm #unread-count {
|
||||
color: var(--background);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
}
|
||||
|
||||
#workspaces>button {
|
||||
padding: 0px 8px;
|
||||
padding: 8px;
|
||||
transition: padding 0.05s steps(8), background-color 0.15s ease;
|
||||
background-color: var(--ws-inactive);
|
||||
border-radius: 100px;
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
|
||||
#workspaces>button.active {
|
||||
padding: 0px 32px;
|
||||
padding: 8px 32px;
|
||||
background-color: var(--ws-active);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
animation: urgent-blink 1s infinite;
|
||||
}
|
||||
|
||||
#workspaces>button.foreign {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@keyframes urgent-blink {
|
||||
0% { opacity: 1.0; }
|
||||
50% { opacity: 0.5; }
|
||||
28
sims/utils/markup.py
Normal file
28
sims/utils/markup.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import re
|
||||
from html import escape as html_escape
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Pango", "1.0")
|
||||
from gi.repository import GLib, Pango
|
||||
|
||||
# Pango cannot render <img ...> from the freedesktop notification spec; strip it.
|
||||
_IMG_RE = re.compile(r"<img\b[^>]*/?>", re.IGNORECASE)
|
||||
|
||||
|
||||
def render_body_markup(body: str) -> tuple[str, bool]:
|
||||
"""Return ``(text, is_markup)`` for a notification body.
|
||||
|
||||
If the body parses as Pango markup, ``text`` is the cleaned-up markup
|
||||
string and ``is_markup`` is True. Otherwise ``text`` is the XML-escaped
|
||||
plain text and ``is_markup`` is False.
|
||||
"""
|
||||
if not body:
|
||||
return "", False
|
||||
|
||||
candidate = _IMG_RE.sub("", body)
|
||||
try:
|
||||
Pango.parse_markup(candidate, -1, "\0")
|
||||
except GLib.Error:
|
||||
return html_escape(body, quote=False), False
|
||||
return candidate, True
|
||||
@@ -4,12 +4,12 @@ Fenster widgets for workspace and window management via sway IPC.
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
from fabric.i3 import I3, I3Event, I3MessageType
|
||||
from sims.services.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
|
||||
from sims.services.fenster import get_i3_connection
|
||||
|
||||
|
||||
class FensterWorkspaceButton(Button):
|
||||
@@ -41,7 +41,7 @@ class FensterWorkspaceButton(Button):
|
||||
return self._workspace_num
|
||||
|
||||
def _on_clicked(self, *args):
|
||||
self._i3.send_command(f"workspace number {self._workspace_num}")
|
||||
self._i3.send_command(f"workspace {self._workspace_num}")
|
||||
|
||||
def _toggle_class(self, name: str, on: bool):
|
||||
if on:
|
||||
@@ -61,6 +61,9 @@ class FensterWorkspaceButton(Button):
|
||||
def set_urgent(self, urgent: bool):
|
||||
self._toggle_class("urgent", urgent)
|
||||
|
||||
def set_foreign(self, foreign: bool):
|
||||
self._toggle_class("foreign", foreign)
|
||||
|
||||
|
||||
class FensterWorkspaces(Box):
|
||||
"""Container widget showing a fixed set of workspace bubbles (1..N)."""
|
||||
@@ -108,7 +111,8 @@ class FensterWorkspaces(Box):
|
||||
)
|
||||
|
||||
if self._i3.ready:
|
||||
self._schedule_refresh()
|
||||
# Initial state is already current — no need to defer.
|
||||
self._refresh_workspaces()
|
||||
else:
|
||||
self._i3.connect("notify::ready", lambda *_: self._schedule_refresh())
|
||||
|
||||
@@ -149,11 +153,13 @@ class FensterWorkspaces(Box):
|
||||
button.set_visible_other(False)
|
||||
button.set_urgent(False)
|
||||
button.set_empty(True)
|
||||
button.set_foreign(False)
|
||||
continue
|
||||
|
||||
focused = bool(ws.get("focused"))
|
||||
visible = bool(ws.get("visible"))
|
||||
urgent = bool(ws.get("urgent"))
|
||||
ws_output = ws.get("output")
|
||||
window_count = ws.get("window_count", 0)
|
||||
|
||||
button.set_active(focused)
|
||||
@@ -161,6 +167,12 @@ class FensterWorkspaces(Box):
|
||||
button.set_visible_other(visible and not focused)
|
||||
button.set_urgent(urgent)
|
||||
button.set_empty(window_count == 0)
|
||||
# Workspace currently shown on a different output than this bar's.
|
||||
button.set_foreign(
|
||||
self._output is not None
|
||||
and ws_output is not None
|
||||
and ws_output != self._output
|
||||
)
|
||||
|
||||
self.show_all()
|
||||
|
||||
@@ -202,7 +214,7 @@ class FensterActiveWindow(Label):
|
||||
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", ""))
|
||||
self._set_title(focused.get("name") or "")
|
||||
return
|
||||
self.set_label("")
|
||||
|
||||
@@ -217,7 +229,7 @@ class FensterActiveWindow(Label):
|
||||
|
||||
def _on_window_event(self, _, event: I3Event):
|
||||
container = event.data.get("container", {})
|
||||
self._set_title(container.get("name", ""))
|
||||
self._set_title(container.get("name") or "")
|
||||
|
||||
def _on_window_close(self, _, event: I3Event):
|
||||
self._initialize()
|
||||
136
sims/widgets/notification.py
Normal file
136
sims/widgets/notification.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from fabric.notifications import Notification
|
||||
from fabric.utils import invoke_repeater
|
||||
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 gi.repository import GdkPixbuf
|
||||
|
||||
from sims.utils.markup import render_body_markup
|
||||
|
||||
NOTIFICATION_IMAGE_SIZE = 64
|
||||
|
||||
|
||||
class NotificationWidget(Box):
|
||||
def __init__(
|
||||
self,
|
||||
notification: Notification,
|
||||
width: int = 360,
|
||||
timeout_ms: int = 10_000,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
size=(width, -1),
|
||||
name="notification",
|
||||
spacing=8,
|
||||
orientation="v",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._notification = notification
|
||||
|
||||
urgency_class = {0: "urgency-low", 1: "urgency-normal", 2: "urgency-critical"}.get(
|
||||
notification.urgency, "urgency-normal"
|
||||
)
|
||||
self.get_style_context().add_class(urgency_class)
|
||||
|
||||
body_container = Box(spacing=4, orientation="h")
|
||||
|
||||
if image_pixbuf := self._notification.image_pixbuf:
|
||||
body_container.add(
|
||||
Image(
|
||||
pixbuf=image_pixbuf.scale_simple(
|
||||
NOTIFICATION_IMAGE_SIZE,
|
||||
NOTIFICATION_IMAGE_SIZE,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
text_children = []
|
||||
summary = self._notification.summary or ""
|
||||
body = self._notification.body or ""
|
||||
|
||||
text_children.append(
|
||||
Box(
|
||||
orientation="h",
|
||||
children=[
|
||||
Label(label=summary, ellipsization="middle")
|
||||
.build()
|
||||
.add_style_class("summary")
|
||||
.unwrap(),
|
||||
],
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
).build(
|
||||
lambda box, _: box.pack_end(
|
||||
Button(
|
||||
image=Image(icon_name="window-close-symbolic", icon_size=18),
|
||||
v_align="center",
|
||||
h_align="end",
|
||||
on_clicked=lambda *_: self._notification.close(),
|
||||
),
|
||||
False,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if body:
|
||||
body_text, body_is_markup = render_body_markup(body)
|
||||
body_kwargs = {"markup": body_text} if body_is_markup else {"label": body_text}
|
||||
text_children.append(
|
||||
Label(
|
||||
**body_kwargs,
|
||||
line_wrap="word-char",
|
||||
v_align="start",
|
||||
h_align="start",
|
||||
)
|
||||
.build()
|
||||
.add_style_class("body")
|
||||
.unwrap()
|
||||
)
|
||||
|
||||
body_container.add(
|
||||
Box(
|
||||
spacing=4,
|
||||
orientation="v",
|
||||
children=text_children,
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
)
|
||||
|
||||
self.add(body_container)
|
||||
|
||||
if actions := self._notification.actions:
|
||||
self.add(
|
||||
Box(
|
||||
spacing=4,
|
||||
orientation="h",
|
||||
children=[
|
||||
Button(
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
label=action.label,
|
||||
on_clicked=lambda *_, action=action: action.invoke(),
|
||||
)
|
||||
for action in actions
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
self._notification.connect(
|
||||
"closed",
|
||||
lambda *_: (
|
||||
parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore
|
||||
self.destroy(),
|
||||
),
|
||||
)
|
||||
|
||||
invoke_repeater(
|
||||
timeout_ms,
|
||||
lambda: self._notification.close("expired"),
|
||||
initial_call=False,
|
||||
)
|
||||
103
sims/widgets/notification_history_entry.py
Normal file
103
sims/widgets/notification_history_entry.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
from sims.services.notification_history import HistoryEntry
|
||||
from sims.utils.markup import render_body_markup
|
||||
|
||||
|
||||
def _time_ago(ts: float, now: float | None = None) -> str:
|
||||
delta = int((now if now is not None else time.time()) - ts)
|
||||
if delta < 60:
|
||||
return "just now"
|
||||
if delta < 3600:
|
||||
return f"{delta // 60}m ago"
|
||||
if delta < 86400:
|
||||
return f"{delta // 3600}h ago"
|
||||
return f"{delta // 86400}d ago"
|
||||
|
||||
|
||||
class NotificationHistoryEntryWidget(Box):
|
||||
THUMB_SIZE = 40
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: HistoryEntry,
|
||||
on_dismiss: Callable[[int], None],
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
name="notification-history-entry",
|
||||
spacing=8,
|
||||
orientation="h",
|
||||
**kwargs,
|
||||
)
|
||||
urgency_class = {0: "urgency-low", 1: "urgency-normal", 2: "urgency-critical"}.get(
|
||||
entry.urgency, "urgency-normal"
|
||||
)
|
||||
self.get_style_context().add_class(urgency_class)
|
||||
|
||||
if entry.pixbuf is not None:
|
||||
self.add(Image(pixbuf=entry.pixbuf, h_align="start", v_align="start"))
|
||||
|
||||
text_children: list = []
|
||||
|
||||
header = Box(orientation="h", h_expand=True)
|
||||
header.pack_start(
|
||||
Label(
|
||||
label=entry.summary,
|
||||
ellipsization="end",
|
||||
h_align="start",
|
||||
)
|
||||
.build()
|
||||
.add_style_class("summary")
|
||||
.unwrap(),
|
||||
True,
|
||||
True,
|
||||
0,
|
||||
)
|
||||
header.pack_end(
|
||||
Label(label=_time_ago(entry.timestamp), h_align="end")
|
||||
.build()
|
||||
.add_style_class("timestamp")
|
||||
.unwrap(),
|
||||
False,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
text_children.append(header)
|
||||
|
||||
if entry.body:
|
||||
body_text, body_is_markup = render_body_markup(entry.body)
|
||||
body_kwargs = {"markup": body_text} if body_is_markup else {"label": body_text}
|
||||
text_children.append(
|
||||
Label(
|
||||
**body_kwargs,
|
||||
line_wrap="word-char",
|
||||
h_align="start",
|
||||
v_align="start",
|
||||
)
|
||||
.build()
|
||||
.add_style_class("body")
|
||||
.unwrap()
|
||||
)
|
||||
|
||||
text_box = Box(orientation="v", spacing=2, children=text_children, h_expand=True)
|
||||
self.add(text_box)
|
||||
|
||||
self.pack_end(
|
||||
Button(
|
||||
name="notification-history-dismiss",
|
||||
image=Image(icon_name="window-close-symbolic", icon_size=14),
|
||||
v_align="start",
|
||||
h_align="end",
|
||||
on_clicked=lambda *_: on_dismiss(entry.id),
|
||||
),
|
||||
False,
|
||||
False,
|
||||
0,
|
||||
)
|
||||
Reference in New Issue
Block a user