Source code for scitex_notification._backends._emacs

#!/usr/bin/env python3
# Timestamp: "2026-01-13 (ywatanabe)"
# File: /home/ywatanabe/proj/scitex-notification/src/scitex_notification/_backends/_emacs.py

"""Emacs notification backend using emacsclient."""

from __future__ import annotations

import asyncio
import shutil
import subprocess
from datetime import datetime
from typing import Optional

from ._types import BaseNotifyBackend, NotifyLevel, NotifyResult


[docs] class EmacsBackend(BaseNotifyBackend): """Notification via Emacs using emacsclient. Displays notifications in Emacs minibuffer or as alerts. Supports different display methods: - popup: temporary popup buffer (default, most noticeable) - minibuffer: message function - alert: alert.el package - notifications: notifications.el (desktop notifications from Emacs) """ name = "emacs"
[docs] def __init__(self, method: str = "popup", timeout: float = 5.0): """Initialize Emacs backend. Parameters ---------- method : str Notification method: 'popup', 'minibuffer', 'alert', or 'notifications' timeout : float Display timeout for visual methods """ self.method = method self.timeout = timeout
[docs] def is_available(self) -> bool: """Check if emacsclient is available.""" return shutil.which("emacsclient") is not None
def _escape_elisp_string(self, s: str) -> str: """Escape a string for use in elisp.""" return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") def _get_face_for_level(self, level: NotifyLevel) -> str: """Get Emacs face name for notification level.""" faces = { NotifyLevel.INFO: "success", NotifyLevel.WARNING: "warning", NotifyLevel.ERROR: "error", NotifyLevel.CRITICAL: "error", } return faces.get(level, "default")
[docs] async def send( self, message: str, title: Optional[str] = None, level: NotifyLevel = NotifyLevel.INFO, **kwargs, ) -> NotifyResult: """Send notification via Emacs.""" try: method = kwargs.get("method", self.method) timeout = kwargs.get("timeout", self.timeout) # Escape strings for elisp msg_escaped = self._escape_elisp_string(message) title_escaped = self._escape_elisp_string(title or "SciTeX") face = self._get_face_for_level(level) # Build elisp command based on method if method == "popup": # Popup buffer - most noticeable level_colors = { NotifyLevel.INFO: "#98C379", # green NotifyLevel.WARNING: "#E5C07B", # yellow NotifyLevel.ERROR: "#E06C75", # red NotifyLevel.CRITICAL: "#E06C75", # red } color = level_colors.get(level, "#98C379") elisp = f""" (let* ((buf (get-buffer-create "*SciTeX Alert*")) (timeout {int(timeout)})) (with-current-buffer buf (erase-buffer) (insert (propertize "\\n ╔══════════════════════════════════════╗\\n" 'face '(:foreground "{color}" :weight bold))) (insert (propertize " ║ SciTeX Alert ║\\n" 'face '(:foreground "{color}" :weight bold))) (insert (propertize " ╠══════════════════════════════════════╣\\n" 'face '(:foreground "{color}"))) (insert (propertize (format " ║ [%s] %s\\n" "{level.value.upper()}" "{msg_escaped}") 'face '(:foreground "{color}"))) (insert (propertize " ╚══════════════════════════════════════╝\\n" 'face '(:foreground "{color}"))) (goto-char (point-min))) (display-buffer buf '((display-buffer-in-side-window) (side . bottom) (window-height . 8))) (run-at-time timeout nil (lambda () (when-let ((win (get-buffer-window buf t))) (delete-window win)) (kill-buffer buf))) (message "[SciTeX] %s" "{msg_escaped}")) """ elif method == "alert": # Use alert.el package (if installed) severity_map = { NotifyLevel.INFO: "normal", NotifyLevel.WARNING: "moderate", NotifyLevel.ERROR: "high", NotifyLevel.CRITICAL: "urgent", } severity = severity_map.get(level, "normal") elisp = f""" (if (fboundp 'alert) (alert "{msg_escaped}" :title "{title_escaped}" :severity '{severity} :timeout {int(timeout)}) (message "[%s] %s: %s" "{level.value.upper()}" "{title_escaped}" "{msg_escaped}")) """ elif method == "notifications": # Use notifications.el (requires D-Bus) urgency_map = { NotifyLevel.INFO: "normal", NotifyLevel.WARNING: "normal", NotifyLevel.ERROR: "critical", NotifyLevel.CRITICAL: "critical", } urgency = urgency_map.get(level, "normal") elisp = f""" (if (fboundp 'notifications-notify) (notifications-notify :title "{title_escaped}" :body "{msg_escaped}" :urgency '{urgency} :timeout {int(timeout * 1000)}) (message "[%s] %s: %s" "{level.value.upper()}" "{title_escaped}" "{msg_escaped}")) """ else: # Default: minibuffer message with face elisp = f""" (let ((msg (propertize "[{level.value.upper()}] {title_escaped}: {msg_escaped}" 'face '{face}))) (message "%s" msg)) """ # Clean up elisp (remove extra whitespace) elisp = " ".join(elisp.split()) # Execute via emacsclient cmd = ["emacsclient", "--eval", elisp] loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, lambda: subprocess.run( cmd, capture_output=True, text=True, timeout=10, ), ) if result.returncode == 0: return NotifyResult( success=True, backend=self.name, message=message, timestamp=datetime.now().isoformat(), details={"method": method, "elisp_result": result.stdout.strip()}, ) else: return NotifyResult( success=False, backend=self.name, message=message, timestamp=datetime.now().isoformat(), error=result.stderr.strip() or "emacsclient failed", ) except subprocess.TimeoutExpired: return NotifyResult( success=False, backend=self.name, message=message, timestamp=datetime.now().isoformat(), error="emacsclient timed out", ) except Exception as e: return NotifyResult( success=False, backend=self.name, message=message, timestamp=datetime.now().isoformat(), error=str(e), )
# EOF