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}