"""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")