Files
sims/sims/modules/org.py
2026-05-10 01:55:21 +02:00

284 lines
8.7 KiB
Python

"""Org-agenda bar widget + dropdown popup.
Bar slot is a small button showing an icon + count of open items. Clicking
toggles a layer-shell dropdown listing items grouped by state. Clicking an
item opens emacsclient at the headline's line.
"""
from __future__ import annotations
import os
import subprocess
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 loguru import logger
from sims.config import BAR_HEIGHT, ORG
from sims.services.org import OrgItem, OrgService, OrgSnapshot
# Order in which states are shown / counted. Anything else discovered at parse
# time is appended after these.
DEFAULT_STATE_ORDER = ["NEXT", "IN-PROGRESS", "TODO"]
def _state_order(states: list[str]) -> list[str]:
seen = set()
ordered: list[str] = []
for s in DEFAULT_STATE_ORDER:
if s in states and s not in seen:
ordered.append(s)
seen.add(s)
for s in states:
if s not in seen:
ordered.append(s)
seen.add(s)
return ordered
def _open_in_emacs(item: OrgItem) -> None:
cmd_template = ORG.get("emacsclient_command", "emacsclient")
parts = cmd_template.split() if isinstance(cmd_template, str) else list(cmd_template)
cmd = [*parts, "-c", f"+{item.line}", item.file]
try:
subprocess.Popen(cmd, start_new_session=True)
logger.info(f"[Org] Opened {item.file}:{item.line} in emacsclient")
except Exception as e:
logger.error(f"[Org] Failed to open emacsclient: {e}")
class OrgDropdown(Window):
def __init__(self, service: OrgService, monitor: int = 0):
width = int(ORG.get("dropdown_width", 420))
super().__init__(
name="org-dropdown",
layer="top",
anchor="top",
margin=f"{BAR_HEIGHT}px 0 0 0",
keyboard_mode="on-demand",
exclusivity="none",
visible=False,
monitor=monitor,
)
self._service = service
self._width = width
self._header = Label(
name="org-dropdown-title",
label="Org Agenda",
h_align="start",
)
close_button = Button(
name="org-dropdown-close",
image=Image(icon_name="window-close-symbolic", icon_size=16),
on_clicked=lambda *_: self.hide(),
)
header_row = Box(name="org-dropdown-header", orientation="h", spacing=8)
header_row.pack_start(self._header, True, True, 0)
header_row.pack_end(close_button, False, False, 0)
self._sections_box = Box(
name="org-dropdown-sections",
orientation="v",
spacing=10,
h_expand=True,
)
scroll = ScrolledWindow(
name="org-dropdown-scroll",
h_scrollbar_policy="never",
v_scrollbar_policy="automatic",
child=self._sections_box,
h_expand=True,
v_expand=True,
)
body = Box(
name="org-dropdown-body",
orientation="v",
spacing=8,
children=[header_row, scroll],
h_expand=True,
v_expand=True,
)
body.set_size_request(self._width, int(ORG.get("dropdown_height", 480)))
self.add(body)
self.connect("key-press-event", self._on_key_press)
self._service.connect("items-changed", lambda _s, snap: self._refresh(snap))
self._refresh(self._service.snapshot)
def toggle(self) -> None:
if self.get_visible():
self.hide()
else:
self.show()
def show(self) -> None: # type: ignore[override]
super().show()
self.show_all()
def _on_key_press(self, _w, event):
if event.keyval == Gdk.KEY_Escape:
self.hide()
return True
return False
def _refresh(self, snapshot: OrgSnapshot) -> None:
for child in self._sections_box.get_children():
self._sections_box.remove(child)
child.destroy()
if not snapshot.items:
self._sections_box.add(
Label(
name="org-dropdown-empty",
label="Nothing on the agenda.",
h_align="start",
)
)
self._sections_box.show_all()
return
by_state: dict[str, list[OrgItem]] = {}
for it in snapshot.items:
by_state.setdefault(it.state, []).append(it)
for state in _state_order(list(by_state.keys())):
items = by_state.get(state, [])
if not items:
continue
section = self._build_section(state, items)
self._sections_box.add(section)
self._sections_box.show_all()
def _build_section(self, state: str, items: list[OrgItem]) -> Box:
section = Box(
name="org-dropdown-section",
orientation="v",
spacing=4,
style_classes=["org-state", f"org-state-{state.lower()}"],
)
header = Box(orientation="h", spacing=6)
header.add(
Label(
name="org-dropdown-section-title",
label=state,
h_align="start",
style_classes=["org-state-label"],
)
)
header.add(
Label(
label=f"({len(items)})",
h_align="start",
style_classes=["org-state-count"],
)
)
section.add(header)
for item in items:
section.add(self._build_item(item))
return section
def _build_item(self, item: OrgItem) -> Button:
title_parts = []
if item.priority:
title_parts.append(f"#{item.priority}")
title_parts.append(item.headline)
if item.tags:
title_parts.append(":" + ":".join(item.tags) + ":")
title = " ".join(title_parts)
title_label = Label(
label=title,
h_align="start",
ellipsization="end",
style_classes=["org-item-title"],
)
meta_bits = []
if item.scheduled:
meta_bits.append(f"S: {item.scheduled}")
if item.deadline:
meta_bits.append(f"D: {item.deadline}")
meta_bits.append(os.path.basename(item.file))
meta_label = Label(
label=" · ".join(meta_bits),
h_align="start",
style_classes=["org-item-meta"],
)
content = Box(orientation="v", spacing=2, h_expand=True)
content.add(title_label)
content.add(meta_label)
button = Button(
name="org-item",
child=content,
on_clicked=lambda *_: self._on_item_clicked(item),
style_classes=["org-item"],
)
return button
def _on_item_clicked(self, item: OrgItem) -> None:
_open_in_emacs(item)
self.hide()
class OrgWidget(Button):
def __init__(self, service: OrgService, dropdown: OrgDropdown | None, **kwargs):
self._service = service
self._dropdown = dropdown
self.icon = Label(label="", name="org-icon")
self.label = Label("0", name="org-count")
container = Box(orientation="h", spacing=4, children=[self.icon, self.label])
super().__init__(
name="org-widget",
child=container,
on_clicked=lambda *_: self._on_clicked(),
**kwargs,
)
self._service.connect("items-changed", lambda _s, snap: self._refresh(snap))
self._refresh(self._service.snapshot)
def _on_clicked(self) -> None:
if self._dropdown is not None:
self._dropdown.toggle()
def _refresh(self, snapshot: OrgSnapshot) -> None:
counted = list(ORG.get("counted_states", ["TODO", "NEXT", "IN-PROGRESS"]))
total = snapshot.total_counted(counted)
next_count = snapshot.counts.get("NEXT", 0)
in_progress = snapshot.counts.get("IN-PROGRESS", 0)
self.label.set_text(str(total))
classes = ["org-widget"]
if total == 0:
classes.append("empty")
if next_count > 0:
classes.append("has-next")
if in_progress > 0:
classes.append("has-in-progress")
self.set_style_classes(classes)
tooltip_lines = []
for state in _state_order(list(snapshot.counts.keys())):
n = snapshot.counts.get(state, 0)
if n:
tooltip_lines.append(f"{state}: {n}")
self.set_tooltip_text("\n".join(tooltip_lines) if tooltip_lines else "No open org items")