Skip to content

Add markdown-table-wrap-buffer.el — opt-in in-buffer table wrapping (markdown / gfm / md-ts / org) - #4

Closed
SayreBlades wants to merge 5 commits into
dnouri:mainfrom
SayreBlades:feat/in-buffer-wrap
Closed

Add markdown-table-wrap-buffer.el — opt-in in-buffer table wrapping (markdown / gfm / md-ts / org)#4
SayreBlades wants to merge 5 commits into
dnouri:mainfrom
SayreBlades:feat/in-buffer-wrap

Conversation

@SayreBlades

Copy link
Copy Markdown
Contributor

Add markdown-table-wrap-buffer.el — opt-in in-buffer table wrapping (markdown / gfm / md-ts / org)

Summary

This PR adds a new, purely additive filemarkdown-table-wrap-buffer.el — that turns markdown-table-wrap's string→string transform into a set of in-buffer commands and a minor mode for Emacs. It rewrites raw table text in place so the wrapped result is the buffer text: links stay clickable, isearch/yank/copy work, and point moves naturally.

No behavior changes for existing users. The new file is opt-in: loading it changes nothing until markdown-table-wrap-buffer-mode is enabled. markdown-table-wrap.el is byte-for-byte unchanged.


Why

markdown-table-wrap is an excellent engine (pure function, no buffer state, markup-aware wrapping with a clean unwrap round-trip). Today it's consumed by pi-coding-agent's chat buffer via display overlays — the raw text stays canonical, a wrapped view is shown on top. That overlay approach is great for a read-only chat render, but it doesn't help users editing ordinary Markdown/Org files, and it doesn't generalize to other consumers.

This PR provides the missing "use it in your own buffers" layer:

  • A user opening a long .md or .org file with a wide table can hit TAB on the table and have it wrap to the window width — directly in the source, multi-line, instead of the existing single-line markdown-table-align / org-table-align that blows past the viewport.
  • A package author who wants "wrap this table region to width N" gets a clean, mode-agnostic command without re-implementing detection/conversion.

Everything is built on the existing public API of markdown-table-wrap (markdown-table-wrap, markdown-table-wrap-unwrap, markdown-table-wrap-inside-code-fence-p, markdown-table-wrap--split-table-row). No new core engine code, no changes to existing functions.


What's added

One new file: markdown-table-wrap-buffer.el.

Commands

Command Description
markdown-table-wrap-table-at-point Wrap the pipe/org table at point. (C-c C-w)
markdown-table-wrap-buffer Wrap every table in the current buffer.
markdown-table-wrap-buffer-region Wrap every table in the active region.
markdown-table-wrap-buffer-set-width Pin a wrap width for the current buffer.

Minor mode

markdown-table-wrap-buffer-mode (lighter TWrap) — context-sensitive TAB:

  • On a table → wrap it.
  • Off a table → fall through to the major mode's normal TAB.

markdown-table-wrap-buffer-turn-on — hook-friendly enabler for markdown-mode-hook, md-ts-mode-hook, org-mode-hook. Covers gfm-mode automatically (derives from markdown-mode). Skips pi-coding-agent-chat-mode (which derives from md-ts-mode) so it doesn't shadow pi's own chat TAB.

Customization

Variable Default Description
markdown-table-wrap-buffer-width nil (window width) Pin a fixed wrap width.
markdown-table-wrap-buffer-max-cell-height nil (unlimited) Cap cell height; passed to markdown-table-wrap.
markdown-table-wrap-buffer-tab-override t Bind TAB to wrap on table when the minor mode is on.
markdown-table-wrap-buffer-key C-c C-w Always-active wrap key in the minor mode.

How it works (the interesting bits)

Width selection

Default: window-max-chars-per-line. Override: markdown-table-wrap-buffer-width, then a numeric prefix arg. In doom+evil the universal argument is SPC u (doom rebinds C-u to evil-scroll-up), so SPC u 60 TAB wraps to 60; in plain emacs it's C-u 60 TAB. The file detects the prefix uniformly via prefix-numeric-value.

Table detection — universal, mode-agnostic

A table region is a maximal run of lines matching ^[ \t]*| (the first column of a GFM pipe table or an Org pipe table). This works in markdown-mode, gfm-mode, md-ts-mode, and org-mode without mode-specific APIs, so the file has no hard dependency on markdown-mode or org. Code fences are respected via markdown-table-wrap-inside-code-fence-p (so a pipe table inside ``` is left alone).

GFM path

Pipe text is passed straight to markdown-table-wrap.

Org path

Org tables use |---+---| separators and optional width cookies (|<5>|, |<l5>|). The file:

  1. Parses with org-table-to-lisp (robust; returns rows of strings or hline).
  2. Strips width cookies from each cell.
  3. Emits canonical GFM pipe text (the +-joined hline becomes |---|---|).
  4. Wraps via markdown-table-wrap.
  5. Reinserts the wrapped pipe text — which is itself valid Org pipe text, so Org still recognizes it as a table (org-at-table-pt).

Org tables carrying a #+TBLFM: formula line are refused with a clear user-error (wrapping would silently drop the formula). This is a deliberate, conservative first cut; a follow-up could preserve #+TBLFM across a wrap.

TAB integration (the non-trivial part)

The two major-mode families have different native TAB conventions, both verified in source:

  • markdown-mode / gfm-mode / md-ts-mode: TABmarkdown-cyclemarkdown-table-align (single-line layout). So "TAB in table = layout" is already the native convention; this PR slots wrap into that same role.
  • org-mode: TABorg-cycleorg-table-next-field, which (when the table is flagged dirty) calls org-table-align as its first act (org-table.el:1057–1065, gated on org-table-automatic-realign which defaults to t). So in practice, org's TAB in a table is also "layout when dirty, then move cell" — the layout role is already there.

To slot in non-invasively (not clobber off-table behavior):

  • Markdown family: the minor mode binds TAB in its own keymap with a context check — on a table, wrap; off a table, call markdown-cycle. This replaces only the markdown-table-align branch of markdown-cycle.
  • Org: the minor mode registers a function on org-tab-first-hook (buffer-local, on enable; removed on disable). When org-at-table-p, it wraps and returns t to consume the TAB; otherwise it returns nil so org-cycle runs unchanged (visibility cycling, src-block indent, etc. are all unaffected off-table). This mirrors how doom's own +org-* hooks (+org-yas-expand-maybe-h, +org-indent-maybe-h) participate in org-tab-first-hook.

The tradeoff (documented in the file): in org, enabling the minor mode means TAB no longer calls org-table-next-field's cell-move on a table — it wraps instead. Cell-move remains reachable via RET and direct motion. This is the deliberate choice for layout-parity; users who prefer org's cell-nav can leave the minor mode off and use C-c C-w directly.

Re-wrap at a new width

Naïve "unwrap then wrap" is unsafe: markdown-table-wrap-unwrap is not idempotent on never-wrapped tables — its continuation-row heuristic can merge consecutive full data rows (e.g. an "Auth" row and a "DB" row collapse into one logical row). I verified this empirically while building this. So the file only unwraps when the table shows wrap markers (the all-empty-cell spacer rows that markdown-table-wrap inserts between wrapped logical rows); otherwise it wraps the raw text directly. Each wrap is wrapped in a single undo boundary (prepare-change-group + undo-amalgamate-change-group) so one undo recovers the previous form.

Buffer-position safety

When wrapping multiple tables in a region/buffer, regions are processed back-to-front so an earlier replacement (which changes buffer length) doesn't invalidate the positions of later regions. The trailing newline that the detection includes (start of the line after the table) is preserved on insert, so blank-line separation after a table survives.


Scenarios enabled

  1. Editing a wide Markdown file: open a .md with a table that runs off the right edge. TAB on the table → it wraps to the window width, multi-line, in the source. TAB off the table → normal markdown-cycle. Resize the window, SPC u 80 TAB → re-wrap to 80. undo → back to the previous form.
  2. Editing a wide Org file: same, in org-mode. TAB on the table → wrapped pipe text (still a valid org table). TAB off the table → org-cycle (visibility cycling) unchanged. #+TBLFM tables are protected.
  3. A package consumer: (markdown-table-wrap-buffer-region beg end 60) wraps all tables in a region to width 60 — no keybindings, no minor mode required. Useful for any tool that renders or normalizes markdown tables.
  4. pi-coding-agent chat buffers: can adopt this as an alternative "inline" render style (gated on a defcustom) where tables are rewritten in place rather than overlay-displayed — making links clickable, isearch/yank work, etc. (That integration is in pi, not this PR; this PR provides the reusable primitive.)

Opt-in by default — nothing changes for existing users

  • markdown-table-wrap.el is unchanged (byte-identical to upstream main).
  • Loading markdown-table-wrap-buffer.el defines commands, a minor mode, and defcustoms, but enables nothing. The minor mode is off by default.
  • Users who want it add to their config:
    (use-package markdown-table-wrap-buffer
      :after (markdown-mode org)
      :hook (markdown-mode . markdown-table-wrap-buffer-turn-on)  ; covers gfm-mode
      :hook (md-ts-mode    . markdown-table-wrap-buffer-turn-on)
      :hook (org-mode      . markdown-table-wrap-buffer-turn-on))
  • MELPA users who never enable the minor mode see zero behavior change.

Known limitations (documented in the file)

  1. Link URLs are dropped when a column is narrower than the full [text](url) token. This is the upstream engine's markdown-table-wrap--force-break-markup behavior: when a link token doesn't fit a column, it degrades to the visible text (URL discarded) to preserve legibility. The file preserves links intact when the column fits the markup. A future :preserve-links option could refuse to wrap a column narrower than its widest link token. (Worth noting because it bears on read-only renders like pi's chat.)

  2. Re-wrap of force-broken headers can be lossy at very narrow widths. markdown-table-wrap-unwrap rejoins continuation rows with spaces, so a header force-broken to "Featu"+"re" at width 30 rejoins as "Featu re" rather than "Feature". The file mitigates by only unwrapping when spacer rows are present and leaving an undo boundary; the underlying limitation is in markdown-table-wrap-unwrap.

  3. #+TBLFM: Org tables are refused, not wrapped. (Future enhancement.)

  4. org-table-next-field cell-nav on TAB is replaced by wrap when the minor mode is on in org. Deliberate (layout-parity); off-table TAB is unaffected. Users who want cell-nav keep the minor mode off and use C-c C-w.


Verification performed

Batch-tested against markdown-mode 2.8, org 9.x, md-ts-mode, in Emacs 30:

  • GFM wrap@30 produces multi-line rows with spacer rows, matching markdown-table-wrap's own output.
  • GFM re-wrap@50 (unwrap via spacer detection, then wrap@50).
  • markdown-table-wrap-buffer on a buffer with mixed content + two tables — both wrapped, surrounding paragraphs/lists/blank lines preserved.
  • Org wrap@30 — |---+---| hline + width cookies stripped; org-at-table-p is t afterwards.
  • Org #+TBLFM: table — user-error, table untouched.
  • markdown-table-wrap-buffer-mode on: TAB on a table wraps; off a table falls through to markdown-cycle (markdown) / org-cycle (org). Verified via org-tab-first-hook membership and fallthrough advice.
  • Numeric prefix wraps to that width.
  • One undo restores the original single-line table.
  • Code-fence tables skipped.
  • Tables at point-min and at point-max (no trailing newline) both wrap without error.
  • Hooks enable the minor mode in markdown/gfm/md-ts/org; pi-coding-agent-chat-mode is skipped by the guard.
  • Byte-compiles clean (no warnings).

Compatibility

  • Package-Requires: ((emacs "28.1") (markdown-table-wrap "0.2.0")) — same baseline as the core package.
  • License: GPL-3.0-or-later (same as upstream).
  • No new dependencies on markdown-mode or org at load time (those functions are declared and called lazily, so the file loads even without them present).

What this PR does not do

  • Does not change markdown-table-wrap.el.
  • Does not add any defcustom to the core engine.
  • Does not enable anything by default.
  • Does not address the markdown-table-wrap-unwrap idempotency issue (separate, worth its own discussion).
  • Does not add org #+TBLFM preservation (future work).

Happy to iterate on naming, key choices, or the org TAB tradeoff. The org TAB decision in particular is the one I'd most like feedback on — I went with "wrap replaces layout" for parity across modes, but I can see an argument for leaving org TAB as cell-nav and using C-c C-w only.

…/gfm/md-ts/org)

New file, purely additive. No change to upstream markdown-table-wrap.el.

Features:
- markdown-table-wrap-buffer-mode: context-sensitive TAB wraps the
  pipe/org table at point (off-table falls through to markdown-cycle /
  org-cycle).
- markdown-table-wrap-table-at-point (C-c C-w), -buffer, -region.
- GFM pipe tables (markdown-mode/gfm-mode/md-ts-mode) and Org tables
  (org-table-to-lisp -> pipe -> wrap -> reinsert).
- Width: window width default, defcustom override, numeric prefix
  (SPC u N in doom, C-u N in plain emacs).
- Org: wired via org-tab-first-hook (returns t to consume TAB on
  table, nil off-table so org-cycle runs). Mirrors doom's +org-* hooks.
- Org #+TBLFM tables refused (formula would be dropped).
- Re-wrap unwraps first only when spacer rows present (unwrap is not
  idempotent on never-wrapped tables). Single undo boundary per table.
- markdown-table-wrap-buffer-turn-on skips pi-coding-agent-chat-mode
  (derived from md-ts-mode) so it doesn't shadow pi's chat TAB.

All behaviors opt-in: loading the file changes nothing until the minor
mode is enabled (typically via markdown-mode-hook / md-ts-mode-hook /
org-mode-hook).

Known limitation: markdown-table-wrap drops link URLs when a column is
narrower than the full [text](url) token (upstream engine behavior);
clickability holds when the column fits the link markup.
…inding

doom binds normal-state TAB to markdown-cycle in
evil-markdown-mode-map; evil state maps override minor-mode maps,
so our minor-mode-map TAB binding never fired under evil (TAB just
collapsed folds / did nothing).

Replace the keymap binding with :around advice on markdown-cycle:
when on a table, wrap (honoring current-prefix-arg); otherwise call
the original markdown-cycle. This mirrors the org org-tab-first-hook
interception pattern and works regardless of evil state, without
fighting evil's keymap priority.

Remove the now-unused --fallback-tab and --markdown-tab helpers.

Verified: on-table wrap@30 via prefix; off-table markdown-cycle
fallthrough confirmed via advice.
--org-tab-h now always returns nil, so it never consumes TAB and
never wraps in org via TAB.  Org tables can still be wrapped via
markdown-table-wrap-table-at-point (C-c C-w).

This is a diagnostic step: org mode was reportedly broken by the
hook.  Disabling it isolates whether the hook is the cause.

Markdown/gfm/md-ts TAB (via markdown-cycle advice) is unaffected.
Point on a non-header row (separator or data row) captured only the
suffix from point onward, so TAB / C-c C-w wrapped a partial table.
Walk backward to the first table line so point anywhere in the table
captures the whole table. Verified: header, separator, data-row, and
table-at-bob all return the full region; off-table returns nil.
The org TAB hook (--org-tab-h on org-tab-first-hook) was temporarily
disabled in 1382623 to diagnose 'org mode reportedly broken by the
hook.' Root cause identified: --table-region-at-point did not walk
backward to the table start (fixed in def40a4), so TAB pressed on a
non-header (data) row captured only the suffix from point onward and
mangled the table. With def40a4 in place, TAB anywhere in the table
(header, hline, or data row) wraps the whole table correctly.

Verified headlessly (verify-org-tab.el) across five cases:
- A. hook on a data row -> wraps whole table, returns t
- B. integrated org-cycle on a data row (minor mode on) -> wraps cleanly
- C. off-table -> returns nil (org-cycle unaffected)
- D. #+TBLFM table -> returns nil (refused, table untouched)
- E. current-prefix-arg honored (SPC u 60 -> width 60)

Also pass current-prefix-arg to --effective-width for parity with the
markdown-cycle advice path (so V1.7 prefix-arg wrap works in org too).
@SayreBlades

Copy link
Copy Markdown
Contributor Author

Closing in favor of a display-overlay approach. In-buffer mutation produces semantically broken pipe tables (wrapped continuation rows render as extra blank data rows under any real markdown/org exporter). We're moving to a read-only pretty rendering over canonical source, toggled like inline images / org-latex-preview. Thanks for the review. (See upstream PR #2 for the complementary editor-inspection API.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant