Skip to main content

lattice_notify/
mode.rs

1//! NOTIF.1f: `notifications-mode` — the `*notifications*` buffer.
2//!
3//! **The corner is the signal; this is where you act.** A notification
4//! is not focusable and is not going to be: aiming at a corner popup is
5//! worse than reading one. So actions live here, as ordinary chords on
6//! an ordinary buffer — everything-is-a-buffer means that needs no
7//! bespoke widget and no new global chord.
8//!
9//! It doubles as the queue view. The corner says `+N more`; this says
10//! what they are.
11
12use std::sync::{Arc, Mutex, OnceLock};
13
14use lattice_grammar::Effect;
15use lattice_mode::{
16    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
17    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
18    keymap_entry,
19};
20
21use crate::{NotificationId, NotificationStoreHandle, notification_at, render_buffer};
22
23/// Replace the buffer's whole content. Same shape magit's
24/// `buffer_io::replace_buffer_text` uses — a full-extent replace, so a
25/// shorter text leaves nothing of the old behind.
26async fn replace_buffer_text(handle: &Arc<dyn lattice_runtime::Document>, text: String) {
27    use lattice_protocol::edit::Edit;
28    use lattice_protocol::position::{Position, Range};
29    let snap = handle.snapshot();
30    // CV.3: ROPE space, deliberately. A full-extent replace must span
31    // to the end of the rope — for "a\n" that is (1,0), past the
32    // terminating newline. Content space would stop at (0,1) and leave
33    // the newline behind, so the "replace everything" contract would
34    // quietly stop replacing everything.
35    let last = snap.buffer.rope_line_count().saturating_sub(1);
36    let last_line = snap.buffer.line(last).unwrap_or_default();
37    let end = Position::new(last, last_line.len() as u32);
38    let _ = handle
39        .apply_edit_batch(vec![Edit::replace(
40            Range::new(Position::new(0, 0), end),
41            text,
42        )])
43        .await;
44}
45
46/// Render the store into `handle` and record the row map that goes
47/// with it — the two are only correct together.
48pub(crate) async fn rerender(
49    store: &NotificationStoreHandle,
50    rows: Option<&RowMapHandle>,
51    handle: &Arc<dyn lattice_runtime::Document>,
52) {
53    let (text, row_map) = render_buffer(store);
54    if let Some(rows) = rows {
55        rows.set(row_map);
56    }
57    replace_buffer_text(handle, text).await;
58}
59
60pub struct NotificationsMode;
61
62impl NotificationsMode {
63    pub fn mode_id() -> ModeId {
64        ModeId::new("notifications-mode")
65    }
66}
67
68fn keymap_entries() -> &'static [KeymapEntry] {
69    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
70    ENTRIES.get_or_init(|| {
71        vec![
72            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Run the action for the notification at cursor", cmd: "action:notification-run" },
73            keymap_entry! { mode: Normal, chord: "d", doc: "Dismiss the notification at cursor", cmd: "action:notification-dismiss" },
74        ]
75    })
76}
77
78/// The row→notification map for the buffer as last rendered.
79///
80/// Read rather than re-parsed, for the reason `magit-remote-mode`
81/// learned the hard way: decoding the rendered line makes a heading
82/// resolve to a record that does not exist.
83#[derive(Default)]
84pub struct RowMap(Mutex<Vec<Option<NotificationId>>>);
85
86pub type RowMapHandle = Arc<RowMap>;
87
88impl RowMap {
89    fn set(&self, rows: Vec<Option<NotificationId>>) {
90        if let Ok(mut slot) = self.0.lock() {
91            *slot = rows;
92        }
93    }
94    fn at(&self, line: u32) -> Option<NotificationId> {
95        let rows = self.0.lock().ok()?;
96        notification_at(&rows, line)
97    }
98}
99
100fn store(ctx: &ActionContext<'_>) -> Option<NotificationStoreHandle> {
101    ctx.services
102        .get::<NotificationStoreHandle>()
103        .map(|outer| (*outer).clone())
104}
105
106fn rows(ctx: &ActionContext<'_>) -> Option<RowMapHandle> {
107    ctx.services.get::<RowMapHandle>().map(|o| (*o).clone())
108}
109
110impl Mode for NotificationsMode {
111    type Guard = ();
112
113    fn id(&self) -> ModeId {
114        Self::mode_id()
115    }
116    fn kind(&self) -> ModeKind {
117        ModeKind::Major
118    }
119    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
120        None
121    }
122
123    fn options(&self) -> OptionOverrideSet {
124        lattice_config::overrides! {
125            lattice_config::ReadOnly = true,
126            lattice_config::NoFile = true,
127        }
128    }
129
130    fn required_capabilities(&self) -> CapabilitySet {
131        CapabilitySet::empty()
132    }
133    fn keymap(&self) -> Keymap {
134        Keymap::from_entries(keymap_entries())
135    }
136
137    /// RV.2 (2026-08-10): refresh is declared, not bound.
138    ///
139    /// `gr` used to be an entry in this mode's own keymap. It now lives
140    /// once on `refreshable-view-mode`, which the implies cascade
141    /// activates because this returns `Some`; the handler body for
142    /// `action:notification-refresh` is unchanged. See
143    /// `docs/dev/architecture/mode-architecture.md` §5.5.
144    fn refresh_action(&self) -> Option<&'static str> {
145        Some("action:notification-refresh")
146    }
147
148    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
149        vec![
150            // <CR> — run the notification's first action.
151            //
152            // A notification with none declines rather than erroring:
153            // most have nothing to do, and a key that complains in the
154            // common case trains you to stop pressing it.
155            ActionHandlerContribution {
156                action_name: "action:notification-run",
157                handler: Arc::new(|ctx: &ActionContext<'_>| {
158                    let id = rows(ctx)?.at(ctx.cursor.line)?;
159                    let store = store(ctx)?;
160                    let action = store
161                        .all()
162                        .into_iter()
163                        .find(|n| n.id == id)?
164                        .actions
165                        .into_iter()
166                        .next()?;
167                    Some(action.effect)
168                }),
169            },
170            ActionHandlerContribution {
171                action_name: "action:notification-dismiss",
172                handler: Arc::new(|ctx: &ActionContext<'_>| {
173                    let id = rows(ctx)?.at(ctx.cursor.line)?;
174                    store(ctx)?.dismiss(id);
175                    Some(Effect::OpenSyntheticBuffer {
176                        name: BUFFER_NAME.to_string(),
177                        mode_id: NotificationsMode::mode_id().as_str().to_string(),
178                        content: None,
179                        cursor: None,
180                        activate_minor: None,
181                    })
182                }),
183            },
184            ActionHandlerContribution {
185                action_name: "action:notification-refresh",
186                handler: Arc::new(|_ctx: &ActionContext<'_>| {
187                    Some(Effect::OpenSyntheticBuffer {
188                        name: BUFFER_NAME.to_string(),
189                        mode_id: NotificationsMode::mode_id().as_str().to_string(),
190                        content: None,
191                        cursor: None,
192                        activate_minor: None,
193                    })
194                }),
195            },
196        ]
197    }
198
199    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
200        Box::pin(async move {
201            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
202            let Some(bufs) = ctx.service::<BufferStoreHandle>() else {
203                return Ok(());
204            };
205            let Some(handle) = bufs.handle_for(buffer_id) else {
206                return Ok(());
207            };
208            let Some(store) = ctx.service::<NotificationStoreHandle>() else {
209                return Ok(());
210            };
211            let rows = ctx.service::<RowMapHandle>().map(|r| (*r).clone());
212            rerender(&store, rows.as_ref(), &handle).await;
213            Ok(())
214        })
215    }
216}
217
218pub const BUFFER_NAME: &str = "*notifications*";