Skip to main content

lattice_mode/modes/table/
mode.rs

1//! `table-mode`: the shared minor that edits pipe tables (TB.1).
2//!
3//! Design: [`table-mode.md`](../../../../../../docs/dev/architecture/table-mode.md).
4//! Slice plan: [`table-mode.md`](../../../../../../docs/dev/operations/slice-plans/table-mode.md).
5//!
6//! ## Why this is native and shared, not org's
7//!
8//! Pipe tables are markdown's and org's alike, so by "shared behaviour is a
9//! minor mode, never a copied keymap" the chords belong in ONE mode spanning
10//! both majors. The question that decides *where* is which owner can serve
11//! both, and only the host can: `markdown-mode` is a native major, so a table
12//! mode living in the org plugin would make markdown table editing require
13//! the org plugin installed and enabled — silently absent otherwise, since a
14//! chord nobody bound simply does nothing.
15//!
16//! The host was already the owner in fact. [`super::layout`] has been here
17//! since HP.1, its module doc naming "a `table-mode` in this directory" as
18//! its next consumer, and it measures cells by display width where the
19//! plugin's copy counted `char`s — a difference its own doc called "honestly
20//! wrong for CJK". Two engines existed; this slice keeps the better one.
21//!
22//! `org-table-mode` stays, and is not redundant: it is where behaviour that
23//! is genuinely org's goes — `#+TBLFM:` formulas above all. What it no longer
24//! carries is the generic surface every pipe table shares.
25//!
26//! ## Layering: these chords decline, they do not swallow
27//!
28//! `<Tab>` in a table advances a cell; anywhere else it must still mean what
29//! it meant. So the bodies return [`Effect::Declined`] when the caret is not
30//! in a table, and the dispatcher re-resolves the chord against the layers
31//! below — `org-mode`'s headline cycle, then the builtin jump-forward. Two
32//! hops, and the chain is a tested property rather than an assumed one.
33//!
34//! **`Declined` is right only for a chord shared with a lower layer.** A
35//! decline re-runs a multi-key chord's TRAILING key alone, so declining
36//! `<leader>tK` outside a table would fire a bare `K`. The `<leader>t…`
37//! family returns [`Effect::None`] instead: nothing below binds them, and
38//! consuming the chord is the honest no-op. See `decline-only-shared-chords`.
39
40use std::sync::{Arc, OnceLock};
41
42use lattice_grammar::effect::Effect;
43use lattice_grammar::registry::{ActionContext, ActionSpec};
44use lattice_grammar::{CommandRegistry, GrammarResult};
45use lattice_protocol::edit::{Edit, EditKind};
46use lattice_protocol::position::{Position, Range};
47
48use super::edit::{Cell, column_at, offset_of_column};
49use super::model::Table;
50use crate::registry::ModeRegistry;
51use crate::{
52    ActivationPolicy, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
53    keymap_entry,
54};
55
56/// Action name: Align the table at the cursor (`<leader>t|`). Consumes the chord (no-op) outside a table.
57pub const ALIGN: &str = "action:table-align";
58/// Action name: Next cell, realigning; adds a row past the last cell (`<Tab>`, Normal and Insert). Declines outside a table.
59pub const NEXT_CELL: &str = "action:table-next-cell";
60/// Action name: Previous cell, realigning (`<S-Tab>`, Normal and Insert). Declines outside a table.
61pub const PREV_CELL: &str = "action:table-prev-cell";
62/// Action name: Move the row up (`<leader>tK`).
63pub const ROW_UP: &str = "action:table-row-up";
64/// Action name: Move the row down (`<leader>tJ`).
65pub const ROW_DOWN: &str = "action:table-row-down";
66/// Action name: Move the column left (`<leader>tH`).
67pub const COLUMN_LEFT: &str = "action:table-column-left";
68/// Action name: Move the column right (`<leader>tL`).
69pub const COLUMN_RIGHT: &str = "action:table-column-right";
70/// Action name: Insert an empty row below (`<leader>tr`).
71pub const INSERT_ROW: &str = "action:table-insert-row";
72/// Action name: Insert an empty column to the right (`<leader>tc`).
73pub const INSERT_COLUMN: &str = "action:table-insert-column";
74/// Action name: Delete the row at the cursor (`<leader>tdr`).
75pub const DELETE_ROW: &str = "action:table-delete-row";
76/// Action name: Delete the column at the cursor (`<leader>tdc`).
77pub const DELETE_COLUMN: &str = "action:table-delete-column";
78// TB.3
79/// Action name: Insert a horizontal rule below the row (`<leader>t-`); TB.3.
80pub const INSERT_RULE: &str = "action:table-insert-rule";
81/// Action name: Sort the section by the cursor's column (`<leader>ts`); TB.3.
82pub const SORT: &str = "action:table-sort";
83/// Action name: Sort the section descending (`<leader>tS`); TB.3.
84pub const SORT_DESC: &str = "action:table-sort-descending";
85/// Action name: Empty the cell at the cursor (`<leader>tb`); TB.3.
86pub const BLANK_CELL: &str = "action:table-blank-cell";
87/// Action name: Copy the cell into the row below, incrementing a trailing number (`<leader>ty`); TB.3.
88pub const COPY_DOWN: &str = "action:table-copy-down";
89/// Action name: Swap rows and columns (`<leader>tT`); TB.3.
90pub const TRANSPOSE: &str = "action:table-transpose";
91// TB.4 — Insert-mode only.
92/// Action name: Same column, next row, adding a row at the end (Insert `<CR>`); TB.4. Declines outside a table.
93pub const NEXT_ROW: &str = "action:table-next-row";
94/// Action name: Realign, declining outside a table so the chord falls to the layer below (`<C-c><C-c>`, and Insert `<Esc>` with fall-through); TB.4.
95pub const REALIGN: &str = "action:table-realign";
96
97/// `table-mode` — pipe-table editing, on every major that has pipe tables.
98pub struct TableMode;
99
100impl TableMode {
101    /// The canonical id, `"table-mode"` — what [`Mode::id`](crate::Mode::id)
102    /// returns. Use it to name this mode without an instance (activation,
103    /// `implies`, keymap layers, tests).
104    pub fn mode_id() -> ModeId {
105        ModeId::new("table-mode")
106    }
107}
108
109impl Mode for TableMode {
110    type Guard = ();
111
112    fn id(&self) -> ModeId {
113        Self::mode_id()
114    }
115
116    fn kind(&self) -> ModeKind {
117        ModeKind::Minor
118    }
119
120    /// The majors that have pipe tables.
121    ///
122    /// `org-mode` is a PLUGIN major, and naming it here is deliberate rather
123    /// than a layering slip: the policy matches on a mode id at activation
124    /// time, so the host needs no knowledge of the plugin beyond the string —
125    /// and the alternative, org declaring the relationship from its side,
126    /// would mean the mode's activation surface lived in two repos.
127    ///
128    /// Not `Global`: a table mode on a Rust buffer would take `<Tab>` from
129    /// completion the moment a line started with `|`, and a match arm does.
130    fn activation_policy(&self) -> ActivationPolicy {
131        ActivationPolicy::Majors(vec![ModeId::new("markdown-mode"), ModeId::new("org-mode")])
132    }
133
134    fn keymap(&self) -> Keymap {
135        Keymap::from_entries(table_keymap_entries())
136    }
137
138    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
139        Box::pin(async { Ok(()) })
140    }
141}
142
143/// The chords, inherited from the mode this takes over from.
144///
145/// `<leader>t…` and the directional letters are org's set verbatim, because
146/// the users who have them should not have to relearn them for the same
147/// operations — and `K`/`J`/`H`/`L` keep one mnemonic across subtrees and
148/// table rows.
149fn table_keymap_entries() -> &'static [KeymapEntry] {
150    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
151    ENTRIES.get_or_init(|| {
152        vec![
153            keymap_entry! { mode: Normal, chord: "<Tab>", doc: "Next table cell", cmd: Some(NEXT_CELL) },
154            keymap_entry! { mode: Normal, chord: "<S-Tab>", doc: "Previous table cell", cmd: Some(PREV_CELL) },
155            keymap_entry! { mode: Normal, chord: "<leader>t|", doc: "Align this table", cmd: Some(ALIGN) },
156            // OE.4 — emacs' `C-c C-c` on a table realigns it, and this mode
157            // is where that arm belongs: `table-mode` owns pipe tables for
158            // markdown AND org, and org cannot reach `action:table-align`
159            // anyway (a guest cannot invoke a registered command — see
160            // `org-mode.md` §5.4).
161            //
162            // `REALIGN`, not `ALIGN`, because the difference between them is
163            // exactly what this chord needs: it DECLINES outside a table, so
164            // the dispatcher re-resolves one layer down and org's
165            // `org-ctrl-c-ctrl-c` gets the chord. `ALIGN` consumes it, which
166            // is right for `<leader>t|` (nothing beneath a leader prefix) and
167            // would make `C-c C-c` dead everywhere but a table.
168            //
169            // Markdown gets the same key for free, which is the point of the
170            // mode being shared.
171            keymap_entry! { mode: Normal, chord: "<C-c><C-c>", doc: "Realign the table at the cursor", cmd: Some(REALIGN) },
172            keymap_entry! { mode: Normal, chord: "<leader>tK", doc: "Move table row up", cmd: Some(ROW_UP) },
173            keymap_entry! { mode: Normal, chord: "<leader>tJ", doc: "Move table row down", cmd: Some(ROW_DOWN) },
174            keymap_entry! { mode: Normal, chord: "<leader>tH", doc: "Move table column left", cmd: Some(COLUMN_LEFT) },
175            keymap_entry! { mode: Normal, chord: "<leader>tL", doc: "Move table column right", cmd: Some(COLUMN_RIGHT) },
176            keymap_entry! { mode: Normal, chord: "<leader>tr", doc: "Insert a table row below", cmd: Some(INSERT_ROW) },
177            keymap_entry! { mode: Normal, chord: "<leader>tc", doc: "Insert a table column right", cmd: Some(INSERT_COLUMN) },
178            keymap_entry! { mode: Normal, chord: "<leader>tdr", doc: "Delete this table row", cmd: Some(DELETE_ROW) },
179            keymap_entry! { mode: Normal, chord: "<leader>tdc", doc: "Delete this table column", cmd: Some(DELETE_COLUMN) },
180            // TB.3. `-` for a rule is emacs' own mnemonic (`C-c -`), and the
181            // rest read as what they do rather than as emacs' chord letters:
182            // this mode is not org's, so `C-c ^` for sort has no claim here.
183            keymap_entry! { mode: Normal, chord: "<leader>t-", doc: "Insert a table rule below", cmd: Some(INSERT_RULE) },
184            keymap_entry! { mode: Normal, chord: "<leader>ts", doc: "Sort this section by the column at the cursor", cmd: Some(SORT) },
185            keymap_entry! { mode: Normal, chord: "<leader>tS", doc: "Sort this section descending", cmd: Some(SORT_DESC) },
186            keymap_entry! { mode: Normal, chord: "<leader>tb", doc: "Blank the table cell at the cursor", cmd: Some(BLANK_CELL) },
187            keymap_entry! { mode: Normal, chord: "<leader>ty", doc: "Copy this cell down, incrementing a trailing number", cmd: Some(COPY_DOWN) },
188            keymap_entry! { mode: Normal, chord: "<leader>tT", doc: "Transpose this table", cmd: Some(TRANSPOSE) },
189
190            // ── TB.4: the same keys, in Insert, meaning what a table wants
191            // there. A chord resolves per `BindingMode`, so this is the model
192            // working rather than a special case — a table is a thing you
193            // navigate in Normal and fill in in Insert.
194            //
195            // `<Tab>` and `<S-Tab>` reuse the Normal BODIES verbatim: moving
196            // to a cell and realigning is the same operation, and neither
197            // emits `Effect::EnterMode`, so Insert is simply kept. Writing a
198            // second pair of handlers to say that would be the duplication
199            // this mode exists to end.
200            keymap_entry! { mode: Insert, chord: "<Tab>", doc: "Next table cell", cmd: Some(NEXT_CELL) },
201            keymap_entry! { mode: Insert, chord: "<S-Tab>", doc: "Previous table cell", cmd: Some(PREV_CELL) },
202            // `<CR>` is bound in Insert ONLY, and the asymmetry is the point.
203            // In Normal it already means something a table row wants — the
204            // first non-blank of the next line. In Insert it means "split this
205            // line", which inside a table row is never what you meant.
206            keymap_entry! { mode: Insert, chord: "<CR>", doc: "Next table row, same column", cmd: Some(NEXT_ROW) },
207            // The chord that makes this feel like "as you type": fill a cell,
208            // press `<Esc>`, the table snaps. `fall_through` so the mode
209            // realigns and the dispatcher then continues to whatever `<Esc>`
210            // natively means — the mode never hardcodes exit-insert.
211            // `active-snippet-mode`'s `<Esc>` is the precedent (SN.3c.2b).
212            keymap_entry! {
213                mode: Insert,
214                chord: "<Esc>",
215                doc: "Realign this table, then exit insert (falls through to the native <Esc>)",
216                cmd: Some(REALIGN),
217                fall_through: true
218            },
219        ]
220    })
221}
222
223/// Register [`TableMode`] in `registry`. Called by
224/// [`register_foundation_modes`](crate::register_foundation_modes); the
225/// chords' bodies are registered separately by [`register_table_actions`]
226/// into the command registry, which must happen before the host translates
227/// mode keymaps or the entries' command names will not resolve.
228///
229/// # Panics
230///
231/// If `table-mode` is already registered.
232pub fn register_table_mode(registry: &mut ModeRegistry) {
233    registry
234        .register(TableMode)
235        .expect("table-mode must register without conflict");
236}
237
238// ── The bodies ──────────────────────────────────────────────────────────
239
240/// What a chord found when it fired: the table, the caret's cell, and the
241/// line the caret was on.
242struct Located {
243    table: Table,
244    cell: Cell,
245}
246
247fn locate(ctx: &ActionContext) -> Option<Located> {
248    let line_count = ctx.buffer.rope_line_count();
249    let table = Table::at(|n| ctx.buffer.line(n), ctx.cursor.line, line_count)?;
250    let row = table.row_index(ctx.cursor.line)?;
251    let text = ctx.buffer.line(ctx.cursor.line)?;
252    let column = column_at(&text, ctx.cursor.byte as usize);
253    Some(Located {
254        table,
255        cell: Cell { row, column },
256    })
257}
258
259/// Replace the table's lines with `rendered` and park the caret in `cell`.
260///
261/// **One edit over the whole span.** A column insert touches every line, and
262/// a half-applied column is a corrupt table — worse than either end state.
263/// It also means `u` undoes the operation rather than its last row.
264fn rewrite(ctx: &ActionContext, table: &Table, rendered: Vec<String>, cell: Cell) -> Effect {
265    // **The replaced range is the OLD table's span, end to end.** Deriving the
266    // end line from `rendered` instead is the bug this comment exists to stop
267    // coming back: for align and the two swaps the row count is unchanged and
268    // the two agree, so it looks right — and then an insert addresses a line
269    // past the buffer's end and does nothing at all, while a delete leaves the
270    // last row behind. The org plugin's integration suite caught it on
271    // `<leader>tr`; the unit tests here did not, because they assert the
272    // rendered TEXT and never the range it lands in.
273    let caret_line = table.first + cell.row as u32;
274    let caret_col = rendered
275        .get(cell.row)
276        .map(|l| offset_of_column(l, cell.column) as u32)
277        .unwrap_or(0);
278    // TB.4: **no edit when nothing changed.** Without this, `<Tab>` through an
279    // already-aligned table pushes an undo entry per cell and `<Esc>` pushes
280    // one every time you leave Insert inside a table — undo steps for edits
281    // that changed nothing, which is worse than the papercut it looks like:
282    // it makes `u` stop meaning "undo my last change". The caret still has to
283    // move, so this is a cursor-only effect rather than `None`.
284    let unchanged = rendered.len() as u32 == table.last - table.first + 1
285        && rendered
286            .iter()
287            .enumerate()
288            .all(|(i, l)| ctx.buffer.line(table.first + i as u32).as_deref() == Some(l.as_str()));
289    if unchanged {
290        return Effect::CursorMove(Position::new(caret_line, caret_col));
291    }
292    let end_byte = ctx
293        .buffer
294        .line(table.last)
295        .map(|l| l.len() as u32)
296        .unwrap_or(0);
297    let text = rendered.join("\n");
298    Effect::ApplyEdit {
299        target: ctx.buffer_id,
300        edit: Edit {
301            range: Range::new(
302                Position::new(table.first, 0),
303                Position::new(table.last, end_byte),
304            ),
305            kind: EditKind::Replace { text },
306        },
307        cursor: Some(Position::new(caret_line, caret_col)),
308    }
309}
310
311/// A body that needs a table under the caret, sharing one decline policy.
312///
313/// `shared` says whether the chord is one a lower layer also binds. It
314/// decides what "not in a table" returns, and getting it wrong is not
315/// cosmetic: `Declined` re-runs a multi-key chord's trailing key ALONE, so
316/// declining `<leader>tK` would fire a bare `K`.
317fn in_table(
318    shared: bool,
319    f: impl Fn(&ActionContext, Located) -> Option<Effect> + Send + Sync + 'static,
320) -> Arc<dyn Fn(&ActionContext) -> GrammarResult<Effect> + Send + Sync> {
321    let declined = if shared {
322        Effect::Declined
323    } else {
324        Effect::None
325    };
326    Arc::new(move |ctx: &ActionContext| {
327        let Some(found) = locate(ctx) else {
328            return Ok(declined.clone());
329        };
330        // An operation that cannot apply (a row at the edge, the last
331        // column) consumes the chord rather than declining: the caret IS in a
332        // table, so falling through to a headline cycle would be a surprise.
333        Ok(f(ctx, found).unwrap_or(Effect::None))
334    })
335}
336
337fn spec(
338    shared: bool,
339    f: impl Fn(&ActionContext, Located) -> Option<Effect> + Send + Sync + 'static,
340) -> ActionSpec {
341    ActionSpec {
342        apply: in_table(shared, f),
343        args_schema: vec![],
344    }
345}
346
347/// Move the caret to `cell` without changing the text — what `<Tab>` does
348/// when the table is already aligned.
349fn move_to(ctx: &ActionContext, table: &Table, cell: Cell) -> Option<Effect> {
350    let rendered = table.render();
351    Some(rewrite(ctx, table, rendered, cell))
352}
353
354/// Register every `table-mode` action.
355///
356/// The mode owns its keymap AND these bodies — the standing rule, and the gap
357/// `magit-project-diff` shipped through when it declared one without the
358/// other.
359pub fn register_table_actions(registry: &mut CommandRegistry) {
360    registry.register_action(
361        ALIGN,
362        "Align the columns of the table at the cursor.",
363        spec(false, |ctx, found| {
364            let rendered = found.table.render();
365            Some(rewrite(ctx, &found.table, rendered, found.cell))
366        }),
367    );
368    // `<Tab>` and `<S-Tab>` are SHARED — org's headline cycle and the builtin
369    // jump-list sit below them — so these two decline.
370    registry.register_action(
371        NEXT_CELL,
372        "Move to the next cell of the table at the cursor, aligning it.",
373        spec(true, |ctx, found| {
374            // TB.3: at the last cell, `<Tab>` ADDS a row and lands in it.
375            // Emacs' org-table does, and it is how people actually build a
376            // table — type a row, Tab, type the next. `Table::next_cell`
377            // deliberately answers `None` there rather than inventing this,
378            // because "add a row" and "leave the table" are both defensible
379            // and the choice belongs to the surface, not the model.
380            match found.table.next_cell(found.cell) {
381                Some(next) => move_to(ctx, &found.table, next),
382                None => {
383                    let last = found.table.rows.len().saturating_sub(1);
384                    let (next, cell) = found.table.insert_row(Cell {
385                        row: last,
386                        column: found.cell.column,
387                    });
388                    let rendered = next.render();
389                    Some(rewrite(ctx, &found.table, rendered, cell))
390                }
391            }
392        }),
393    );
394    registry.register_action(
395        PREV_CELL,
396        "Move to the previous cell of the table at the cursor, aligning it.",
397        spec(true, |ctx, found| {
398            let prev = found.table.prev_cell(found.cell)?;
399            move_to(ctx, &found.table, prev)
400        }),
401    );
402    for (name, doc, delta) in [
403        (ROW_UP, "Move this table row up.", -1isize),
404        (ROW_DOWN, "Move this table row down.", 1),
405    ] {
406        registry.register_action(
407            name,
408            doc,
409            spec(false, move |ctx, found| {
410                let (next, cell) = found.table.move_row(found.cell, delta)?;
411                let rendered = next.render();
412                Some(rewrite(ctx, &found.table, rendered, cell))
413            }),
414        );
415    }
416    for (name, doc, delta) in [
417        (COLUMN_LEFT, "Move this table column left.", -1isize),
418        (COLUMN_RIGHT, "Move this table column right.", 1),
419    ] {
420        registry.register_action(
421            name,
422            doc,
423            spec(false, move |ctx, found| {
424                let (next, cell) = found.table.move_column(found.cell, delta)?;
425                let rendered = next.render();
426                Some(rewrite(ctx, &found.table, rendered, cell))
427            }),
428        );
429    }
430    registry.register_action(
431        INSERT_ROW,
432        "Insert an empty table row below the cursor.",
433        spec(false, |ctx, found| {
434            let (next, cell) = found.table.insert_row(found.cell);
435            let rendered = next.render();
436            Some(rewrite(ctx, &found.table, rendered, cell))
437        }),
438    );
439    registry.register_action(
440        INSERT_COLUMN,
441        "Insert an empty table column right of the cursor.",
442        spec(false, |ctx, found| {
443            let (next, cell) = found.table.insert_column(found.cell);
444            let rendered = next.render();
445            Some(rewrite(ctx, &found.table, rendered, cell))
446        }),
447    );
448    registry.register_action(
449        DELETE_ROW,
450        "Delete the table row at the cursor.",
451        spec(false, |ctx, found| {
452            let (next, cell) = found.table.delete_row(found.cell)?;
453            let rendered = next.render();
454            Some(rewrite(ctx, &found.table, rendered, cell))
455        }),
456    );
457    registry.register_action(
458        DELETE_COLUMN,
459        "Delete the table column at the cursor.",
460        spec(false, |ctx, found| {
461            let (next, cell) = found.table.delete_column(found.cell)?;
462            let rendered = next.render();
463            Some(rewrite(ctx, &found.table, rendered, cell))
464        }),
465    );
466
467    // ── TB.3 ────────────────────────────────────────────────────────────
468    registry.register_action(
469        INSERT_RULE,
470        "Insert a horizontal rule below the cursor's table row.",
471        spec(false, |ctx, found| {
472            let (next, cell) = found.table.insert_rule(
473                found.cell,
474                rule_fallback(ctx.path.as_deref().map(|p| p.as_path())),
475            );
476            let rendered = next.render();
477            Some(rewrite(ctx, &found.table, rendered, cell))
478        }),
479    );
480    for (name, doc, descending) in [
481        (
482            SORT,
483            "Sort this table section by the column at the cursor.",
484            false,
485        ),
486        (
487            SORT_DESC,
488            "Sort this table section by the column at the cursor, descending.",
489            true,
490        ),
491    ] {
492        registry.register_action(
493            name,
494            doc,
495            spec(false, move |ctx, found| {
496                let (next, cell) = found.table.sort_section(found.cell, descending)?;
497                let rendered = next.render();
498                Some(rewrite(ctx, &found.table, rendered, cell))
499            }),
500        );
501    }
502    registry.register_action(
503        BLANK_CELL,
504        "Empty the table cell at the cursor.",
505        spec(false, |ctx, found| {
506            let (next, cell) = found.table.blank_cell(found.cell)?;
507            let rendered = next.render();
508            Some(rewrite(ctx, &found.table, rendered, cell))
509        }),
510    );
511    registry.register_action(
512        COPY_DOWN,
513        "Copy this cell into the row below, incrementing a trailing number.",
514        spec(false, |ctx, found| {
515            let (next, cell) = found.table.copy_down(found.cell)?;
516            let rendered = next.render();
517            Some(rewrite(ctx, &found.table, rendered, cell))
518        }),
519    );
520    registry.register_action(
521        TRANSPOSE,
522        "Swap the table's rows and columns.",
523        spec(false, |ctx, found| {
524            let (next, cell) = found.table.transpose(found.cell)?;
525            let rendered = next.render();
526            Some(rewrite(ctx, &found.table, rendered, cell))
527        }),
528    );
529
530    // ── TB.4 ────────────────────────────────────────────────────────────
531    // `<CR>` is SHARED with the native newline, so it declines outside a
532    // table — consuming it would break `<CR>` in every markdown and org
533    // buffer, which is a far larger surface than this mode.
534    registry.register_action(
535        NEXT_ROW,
536        "Move to the row below in the same table column, adding a row at the end.",
537        spec(true, |ctx, found| {
538            match found.table.down_cell(found.cell) {
539                Some(down) => move_to(ctx, &found.table, down),
540                None => {
541                    // Emacs' `org-table-next-row` creates a row at the bottom
542                    // rather than stopping, which is what makes `<CR>` the way
543                    // you enter a table's contents. You leave with `<Esc>`.
544                    let (next, cell) = found.table.insert_row(found.cell);
545                    let rendered = next.render();
546                    Some(rewrite(ctx, &found.table, rendered, cell))
547                }
548            }
549        }),
550    );
551    // `<Esc>` is shared too — and `fall_through` means the native exit-insert
552    // runs after this either way; declining is what keeps `<Esc>` working in
553    // the far more common case of not being in a table at all.
554    registry.register_action(
555        REALIGN,
556        "Realign the table at the cursor.",
557        spec(true, |ctx, found| {
558            let rendered = found.table.render();
559            Some(rewrite(ctx, &found.table, rendered, found.cell))
560        }),
561    );
562}
563
564/// Which rule style a table with NO rule should get: `+` for org, `|` for
565/// everything else.
566///
567/// This is the single place the dialect is not readable off the buffer — §2
568/// of the design says the table says which dialect it is, and a table with no
569/// rule has not said. So the file's extension answers, which is crude but
570/// true: the thing that decides whether `|---+---|` is idiomatic IS whether
571/// this is an org file.
572///
573/// Not a `table.dialect` option, for §2's reason — an option is a second
574/// source that can disagree with the buffer. Not the buffer's major mode
575/// either, tempting as that is: reaching it from a grammar action body means
576/// either a `lattice-syntax` dependency in `lattice-mode` or a new field on
577/// `ActionContext`, which is a large edge for one character. A path with no
578/// extension, or none at all, gets markdown's — the more common table
579/// spelling, and the one org accepts too.
580fn rule_fallback(path: Option<&std::path::Path>) -> char {
581    let is_org = path
582        .and_then(|p| p.extension())
583        .and_then(|e| e.to_str())
584        .is_some_and(|e| e.eq_ignore_ascii_case("org"));
585    if is_org { '+' } else { '|' }
586}