284 lines
8.7 KiB
Python
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")
|