#!/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