diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56e7201..e5409e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+- Removed the 4-column outer margin that cat mode and TUI body rows
+ shared, plus the additional 2-column inset on cat-mode table rows.
+ Content now starts at column 0. The gutter originally mirrored `glow`;
+ with TUI as the default it cost 4 cols for no remaining visual gain.
+
## [0.5.0] - 2026-05-21
### Changed
diff --git a/docs/LINK_PICKER_DESIGN.md b/docs/LINK_PICKER_DESIGN.md
index 58bb43f..0fe5302 100644
--- a/docs/LINK_PICKER_DESIGN.md
+++ b/docs/LINK_PICKER_DESIGN.md
@@ -79,3 +79,16 @@ They are complementary. A realistic plan could be:
2. Revisit **C** later if the inline-hint flow feels worth the rendering complexity.
Either way, the status-bar `take(9)` overlay should be retired once a replacement lands.
+
+---
+
+## Update — 2026-05-22: `MARGIN_WIDTH` gutter no longer exists
+
+The constraint phrased as "must not shift the body layout's column
+alignment for heading images (`MARGIN_WIDTH` gutter)" referenced the
+4-column outer margin that cat mode and TUI body rows used to share.
+That gutter was removed (along with the `MARGIN_WIDTH` constant) now
+that TUI is the default mode. The underlying constraint still holds —
+label/prefix spans added by any future link picker must not shift the
+column where heading images land — but the alignment column is now `0`
+(or `30` when ToC is open), not `4` / `34`.
diff --git a/docs/TUI_MODE_DEBUG_LOG.md b/docs/TUI_MODE_DEBUG_LOG.md
index 78a58c4..28ebfa4 100644
--- a/docs/TUI_MODE_DEBUG_LOG.md
+++ b/docs/TUI_MODE_DEBUG_LOG.md
@@ -195,3 +195,18 @@ Residual items from Rounds 1-3. Test on both Ghostty and iTerm2:
hypotheses tested.
- Keep `make check` green at each round; manual-only behavior fixes
still need snapshot validation that cat mode is unaffected.
+
+---
+
+## Update — 2026-05-22: `MARGIN_WIDTH` is gone
+
+This log references `MARGIN_WIDTH` in the H3 bug analysis ("No
+`MARGIN_WIDTH` prefix on TUI body RLines", "column aligns with image
+column (`MARGIN_WIDTH = 4`)"). That constant — along with the 4-space
+outer margin that mirrored `glow` — has been removed now that TUI is the
+default mode. Body rows in both cat and TUI start at column 0; heading
+images are placed at column 0 (or column 30 when the ToC panel is open).
+
+The H3 fix described in the original log is unaffected in spirit: heading
+images still need to be placed at the same column where the text would
+have rendered. The number is just `0`/`30` now instead of `4`/`34`.
diff --git a/docs/TUI_MODE_PLAN.md b/docs/TUI_MODE_PLAN.md
index e70f8ad..60f3f54 100644
--- a/docs/TUI_MODE_PLAN.md
+++ b/docs/TUI_MODE_PLAN.md
@@ -3299,3 +3299,30 @@ git commit -m "chore: bump version to 0.4.0"
- **Placeholders:** No TBD/TODO in code. Where a task body mentions "refined in Phase N", the referenced task implements it.
- **Type consistency:** `Style` fields (`fg/bg/bold/italic/underline/strikethrough/dim`) introduced in Task 1.1 are used consistently through `cat.rs` (Task 1.8) and `viewport.rs`/`search.rs`. `HeadingImage` moved to `render.rs` in Task 1.1 and re-referenced in `tui/kitty.rs` (Task 3.2) via path `crate::render::HeadingImage`. Kitty protocol APIs in Task 3.1 (`transmit/place/delete_placement/delete_all_for_client`) are the ones called by `ImageLifecycle` in Task 3.2.
- **Known manual gates:** Task 1.9 requires human audit of snapshot diffs; Task 8.1 is the Ghostty/iTerm2 QA pass. Both are called out explicitly.
+
+---
+
+## Update — 2026-05-22: Outer margin removed
+
+The original plan reserved a 4-column outer margin (`MARGIN` = `" "`,
+`MARGIN_WIDTH` = `4`) on every cat-mode line and on every TUI body row,
+mirroring `glow`'s gutter. With TUI now the default mode (post-v0.5.0), the
+gutter cost a fixed 4 columns of horizontal real estate for no remaining
+visual win, so it has been removed entirely.
+
+What changed vs. the snippets in this plan:
+
+- `src/style.rs` — `MARGIN` and `MARGIN_WIDTH` constants deleted.
+- `src/cat.rs` — every `{MARGIN}` prefix dropped from `write_line` /
+ `emit_code_block` / `write_paragraph`; `prefix_visual_width` drops the
+ `MARGIN_WIDTH` term (only quote bars contribute); `wrap_and_write` no
+ longer subtracts margin from `term_width`.
+- `src/tui/mod.rs` — the per-row leading-space span is gone; the heading
+ image `col_offset` collapses to `0` when ToC is closed and `30` when it
+ is open.
+- `src/tui/viewport.rs` — `wrap_all` no longer reserves 4 cols.
+
+Snapshot fixtures under `fixtures/expected/*.ansi` were regenerated. The
+visible diff is exactly: leading `" "` removed from each rendered line,
+and wrap points shift later (because each line now has 4 more usable
+columns). All `make check` gates remain green.
diff --git a/fixtures/expected/emoji-test.ansi b/fixtures/expected/emoji-test.ansi
index 917f602..7a91423 100644
--- a/fixtures/expected/emoji-test.ansi
+++ b/fixtures/expected/emoji-test.ansi
@@ -1,35 +1,35 @@
-
+
-
+
- 这是一份专门用于验证标题图片渲染的测试文档。
+这是一份专门用于验证标题图片渲染的测试文档。
-
+
- • 单个 emoji: 😀 😎 ✨ 🚀
- • 中英混排: Hello 世界 🌍
- • 符号混排: ✅ Done · ⚠ Warning · ❌ Failed
- • 常见 emoji 变体: ☀️ ❤️ ⭐️
+ • 单个 emoji: 😀 😎 ✨ 🚀
+ • 中英混排: Hello 世界 🌍
+ • 符号混排: ✅ Done · ⚠ Warning · ❌ Failed
+ • 常见 emoji 变体: ☀️ ❤️ ⭐️
-
+
- 正文仍然由终端自身字体渲染,所以这里主要用来对比标题和正文的表现差异。
+正文仍然由终端自身字体渲染,所以这里主要用来对比标题和正文的表现差异。
- [38;5;240m│ [3;38;5;250m引用块里也放几个字符:💡 🛠 📦[0m
+[38;5;240m│ [3;38;5;250m引用块里也放几个字符:💡 🛠 📦[0m
-
+
- [1mCase[0m [2m │ [0m[1mExample[0m
- [2m──────────────────[0m[2m ┼ [0m[2m───────────────────[0m
- Single emoji [2m │ [0m😀
- Mixed text [2m │ [0m修正版 ✨ version 2
- Symbol-like [2m │ [0m✅ ⚠ ❌
- Variation selector[2m │ [0m☀️ ❤️
+[1mCase[0m [2m │ [0m[1mExample[0m
+[2m──────────────────[0m[2m ┼ [0m[2m───────────────────[0m
+Single emoji [2m │ [0m😀
+Mixed text [2m │ [0m修正版 ✨ version 2
+Symbol-like [2m │ [0m✅ ⚠ ❌
+Variation selector[2m │ [0m☀️ ❤️
-
+
- 这一行用于观察复杂 ZWJ emoji 的边界表现:👨👩👧👦 👩🏽💻 🧑🚀
+这一行用于观察复杂 ZWJ emoji 的边界表现:👨👩👧👦 👩🏽💻 🧑🚀
-
+
- 如果修复生效,H1-H3 里的大部分单 emoji 和常见符号不应再显示成缺字框。
+如果修复生效,H1-H3 里的大部分单 emoji 和常见符号不应再显示成缺字框。
diff --git a/fixtures/expected/full-syntax-zh.ansi b/fixtures/expected/full-syntax-zh.ansi
index 26d3451..99e1b81 100644
--- a/fixtures/expected/full-syntax-zh.ansi
+++ b/fixtures/expected/full-syntax-zh.ansi
@@ -1,70 +1,70 @@
-
+
-
+
-
+
-
+
- [1m这是六级标题 h6[0m
+[1m这是六级标题 h6[0m
-
+
- [3m这段文字将显示为斜体[23m
- [3m这也是斜体[23m
+[3m这段文字将显示为斜体[23m
+[3m这也是斜体[23m
- [1m这段文字将显示为粗体[0m
- [1m这也是粗体[0m
+[1m这段文字将显示为粗体[0m
+[1m这也是粗体[0m
- [3m你 [23m[1m[3m可以[0m[3m 组合使用它们[23m
+[3m你 [23m[1m[3m可以[0m[3m 组合使用它们[23m
-
+
-
+
- • 项目 1
- • 项目 2
- • 项目 2a
- • 项目 2b
- • 项目 3a
- • 项目 3b
+ • 项目 1
+ • 项目 2
+ • 项目 2a
+ • 项目 2b
+ • 项目 3a
+ • 项目 3b
-
+
- 1. 项目 1
- 2. 项目 2
- 3. 项目 3
- 1. 项目 3a
- 2. 项目 3b
+ 1. 项目 1
+ 2. 项目 2
+ 3. 项目 3
+ 1. 项目 3a
+ 2. 项目 3b
-
+
- [2m[🖼 这是替代文本。](/image/Markdown-mark.svg)[0m
+[2m[🖼 这是替代文本。](/image/Markdown-mark.svg)[0m
-
+
- 你可能正在使用 [36m[4mMarkdown 实时预览[24m[0m [38;5;245m(https://markdownlivepreview.com/)[0m。
+你可能正在使用 [36m[4mMarkdown 实时预览[24m[0m [38;5;245m(https://markdownlivepreview.com/)[0m。
-
+
- [38;5;240m│ [3;38;5;250mMarkdown 是一种轻量级标记语言,使用纯文本格式语法,由 John Gruber 和[0m
- [38;5;240m│ [3;38;5;250mAaron Swartz 于 2004 年创建。[0m
- [38;5;240m│ [38;5;240m│ [3;38;5;250mMarkdown 常用于编写 readme[0m
- [38;5;240m│ [38;5;240m│ [3;38;5;250m文件、在线论坛中的消息格式化,以及使用纯文本编辑器创建富文本。[0m
+[38;5;240m│ [3;38;5;250mMarkdown 是一种轻量级标记语言,使用纯文本格式语法,由 John Gruber 和 Aaron[0m
+[38;5;240m│ [3;38;5;250mSwartz 于 2004 年创建。[0m
+[38;5;240m│ [38;5;240m│ [3;38;5;250mMarkdown 常用于编写 readme[0m
+[38;5;240m│ [38;5;240m│ [3;38;5;250m文件、在线论坛中的消息格式化,以及使用纯文本编辑器创建富文本。[0m
-
+
- [1m左对齐列[0m[2m │ [0m[1m居中对齐列[0m
- [2m────────[0m[2m ┼ [0m[2m──────────[0m
- 左侧 foo[2m │ [0m右侧 foo
- 左侧 bar[2m │ [0m右侧 bar
- 左侧 baz[2m │ [0m右侧 baz
+[1m左对齐列[0m[2m │ [0m[1m居中对齐列[0m
+[2m────────[0m[2m ┼ [0m[2m──────────[0m
+左侧 foo[2m │ [0m右侧 foo
+左侧 bar[2m │ [0m右侧 bar
+左侧 baz[2m │ [0m右侧 baz
-
+
- [48;5;236m[38;5;213m let message = '你好,世界'; [0m
- [48;5;236m[38;5;213m alert(message); [0m
+[48;5;236m[38;5;213m let message = '你好,世界'; [0m
+[48;5;236m[38;5;213m alert(message); [0m
-
+
- 这个网站使用了 [38;5;213m[48;5;236m markedjs/marked [0m。
+这个网站使用了 [38;5;213m[48;5;236m markedjs/marked [0m。
diff --git a/fixtures/expected/full-syntax.ansi b/fixtures/expected/full-syntax.ansi
index 43ae30b..e87813f 100644
--- a/fixtures/expected/full-syntax.ansi
+++ b/fixtures/expected/full-syntax.ansi
@@ -1,71 +1,71 @@
-
+
-
+
-
+
-
+
- [1mThis is a Heading h6[0m
+[1mThis is a Heading h6[0m
-
+
- [3mThis text will be italic[23m
- [3mThis will also be italic[23m
+[3mThis text will be italic[23m
+[3mThis will also be italic[23m
- [1mThis text will be bold[0m
- [1mThis will also be bold[0m
+[1mThis text will be bold[0m
+[1mThis will also be bold[0m
- [3mYou [23m[1m[3mcan[0m[3m combine them[23m
+[3mYou [23m[1m[3mcan[0m[3m combine them[23m
-
+
-
+
- • Item 1
- • Item 2
- • Item 2a
- • Item 2b
- • Item 3a
- • Item 3b
+ • Item 1
+ • Item 2
+ • Item 2a
+ • Item 2b
+ • Item 3a
+ • Item 3b
-
+
- 1. Item 1
- 2. Item 2
- 3. Item 3
- 1. Item 3a
- 2. Item 3b
+ 1. Item 1
+ 2. Item 2
+ 3. Item 3
+ 1. Item 3a
+ 2. Item 3b
-
+
- [2m[🖼 This is an alt text.](/image/Markdown-mark.svg)[0m
+[2m[🖼 This is an alt text.](/image/Markdown-mark.svg)[0m
-
+
- You may be using [36m[4mMarkdown Live Preview[24m[0m [38;5;245m(https://markdownlivepreview.com/)[0m.
+You may be using [36m[4mMarkdown Live Preview[24m[0m [38;5;245m(https://markdownlivepreview.com/)[0m.
-
+
- [38;5;240m│ [3;38;5;250mMarkdown is a lightweight markup language with plain-text-formatting[0m
- [38;5;240m│ [3;38;5;250msyntax, created in 2004 by John Gruber with Aaron Swartz.[0m
- [38;5;240m│ [38;5;240m│ [3;38;5;250mMarkdown is often used to format readme files, for writing messages[0m
- [38;5;240m│ [38;5;240m│ [3;38;5;250min online discussion forums, and to create rich text using a plain[0m
- [38;5;240m│ [38;5;240m│ [3;38;5;250mtext editor.[0m
+[38;5;240m│ [3;38;5;250mMarkdown is a lightweight markup language with plain-text-formatting syntax,[0m
+[38;5;240m│ [3;38;5;250mcreated in 2004 by John Gruber with Aaron Swartz.[0m
+[38;5;240m│ [38;5;240m│ [3;38;5;250mMarkdown is often used to format readme files, for writing messages in[0m
+[38;5;240m│ [38;5;240m│ [3;38;5;250monline discussion forums, and to create rich text using a plain text[0m
+[38;5;240m│ [38;5;240m│ [3;38;5;250meditor.[0m
-
+
- [1mLeft columns[0m[2m │ [0m[1mRight columns[0m
- [2m────────────[0m[2m ┼ [0m[2m─────────────[0m
- left foo [2m │ [0mright foo
- left bar [2m │ [0mright bar
- left baz [2m │ [0mright baz
+[1mLeft columns[0m[2m │ [0m[1mRight columns[0m
+[2m────────────[0m[2m ┼ [0m[2m─────────────[0m
+left foo [2m │ [0mright foo
+left bar [2m │ [0mright bar
+left baz [2m │ [0mright baz
-
+
- [48;5;236m[38;5;213m let message = 'Hello world'; [0m
- [48;5;236m[38;5;213m alert(message); [0m
+[48;5;236m[38;5;213m let message = 'Hello world'; [0m
+[48;5;236m[38;5;213m alert(message); [0m
-
+
- This web site is using [38;5;213m[48;5;236m markedjs/marked [0m.
+This web site is using [38;5;213m[48;5;236m markedjs/marked [0m.
diff --git a/fixtures/expected/tasklist.ansi b/fixtures/expected/tasklist.ansi
index 3f89221..cf33244 100644
--- a/fixtures/expected/tasklist.ansi
+++ b/fixtures/expected/tasklist.ansi
@@ -1,36 +1,36 @@
-
+
-
+
- [✓] Setup project structure
- [✓] Add markdown parser
- [ ] Implement task list rendering
- [ ] Add configuration options
- [ ] Write documentation
+ [✓] Setup project structure
+ [✓] Add markdown parser
+ [ ] Implement task list rendering
+ [ ] Add configuration options
+ [ ] Write documentation
-
+
- [✓] Phase 1
- [✓] Design architecture
- [✓] Write prototype
- [ ] Code review
- [ ] Phase 2
- [ ] Performance optimization
- [ ] Integration testing
+ [✓] Phase 1
+ [✓] Design architecture
+ [✓] Write prototype
+ [ ] Code review
+ [ ] Phase 2
+ [ ] Performance optimization
+ [ ] Integration testing
-
+
- [✓] Completed task
- [ ] Pending task
- • Regular list item
- [✓] Another done item
+ [✓] Completed task
+ [ ] Pending task
+ • Regular list item
+ [✓] Another done item
-
+
- 1. Ordered item one
- 2. Ordered item two
+ 1. Ordered item one
+ 2. Ordered item two
- [ ] Task after ordered list
- [✓] [9mCompleted and struck through[29m
- [ ] Task with [1mbold[0m and [3mitalic[23m text
- [ ] Task with [38;5;213m[48;5;236m inline code [0m
+ [ ] Task after ordered list
+ [✓] [9mCompleted and struck through[29m
+ [ ] Task with [1mbold[0m and [3mitalic[23m text
+ [ ] Task with [38;5;213m[48;5;236m inline code [0m
diff --git a/fixtures/expected/unsupported-syntax.ansi b/fixtures/expected/unsupported-syntax.ansi
index de165a9..84cfd91 100644
--- a/fixtures/expected/unsupported-syntax.ansi
+++ b/fixtures/expected/unsupported-syntax.ansi
@@ -1,207 +1,205 @@
- [2m────────────────────────────────────────────────────────────[0m
+[2m────────────────────────────────────────────────────────────[0m
-
+
-
+
- This fixture collects every Markdown feature listed as [1mmissing[0m or [1mpartial[0m in
- [38;5;213m[48;5;236m docs/MARKDOWN_FEATURE_COVERAGE.md [0m. Use it to verify what termdown renders
- today and
- as a regression fixture as features are added.
+This fixture collects every Markdown feature listed as [1mmissing[0m or [1mpartial[0m in
+[38;5;213m[48;5;236m docs/MARKDOWN_FEATURE_COVERAGE.md [0m. Use it to verify what termdown renders
+today and
+as a regression fixture as features are added.
- The YAML frontmatter above should be hidden by the renderer. Currently it
- leaks into
- the rendered output.
+The YAML frontmatter above should be hidden by the renderer. Currently it leaks
+into
+the rendered output.
-
+
- A raw HTML block:
+A raw HTML block:
- [2m
[0m
- [2m Hello from inline HTML.[0m
- [2m
[0m
+[2m[0m
+[2m Hello from inline HTML.[0m
+[2m
[0m
- Inline HTML in a paragraph: this word is [4munderlined via HTML[24m and this one is
- red via HTML. An inline
- break and an
- HTML abbreviation.
+Inline HTML in a paragraph: this word is [4munderlined via HTML[24m and this one is
+red via HTML. An inline
+ break and an
+HTML abbreviation.
- An HTML comment: end of line.
+An HTML comment: end of line.
-
+
- Bare URL: https://example.com/docs/readme.html
- Bare email: support@example.com
- URL in text: visit https://github.com/rrbe/termdown for the source.
+Bare URL: https://example.com/docs/readme.html
+Bare email: support@example.com
+URL in text: visit https://github.com/rrbe/termdown for the source.
-
+
- [38;5;240m│ [3;38;5;250m[!NOTE][0m
- [38;5;240m│ [3;38;5;250mUseful information that users should know, even when skimming content.[0m
+[38;5;240m│ [3;38;5;250m[!NOTE][0m
+[38;5;240m│ [3;38;5;250mUseful information that users should know, even when skimming content.[0m
- [38;5;240m│ [3;38;5;250m[!TIP][0m
- [38;5;240m│ [3;38;5;250mHelpful advice for doing things better or more easily.[0m
+[38;5;240m│ [3;38;5;250m[!TIP][0m
+[38;5;240m│ [3;38;5;250mHelpful advice for doing things better or more easily.[0m
- [38;5;240m│ [3;38;5;250m[!IMPORTANT][0m
- [38;5;240m│ [3;38;5;250mKey information users need to know to achieve their goal.[0m
+[38;5;240m│ [3;38;5;250m[!IMPORTANT][0m
+[38;5;240m│ [3;38;5;250mKey information users need to know to achieve their goal.[0m
- [38;5;240m│ [3;38;5;250m[!WARNING][0m
- [38;5;240m│ [3;38;5;250mUrgent info that needs immediate user attention to avoid problems.[0m
+[38;5;240m│ [3;38;5;250m[!WARNING][0m
+[38;5;240m│ [3;38;5;250mUrgent info that needs immediate user attention to avoid problems.[0m
- [38;5;240m│ [3;38;5;250m[!CAUTION][0m
- [38;5;240m│ [3;38;5;250mAdvises about risks or negative outcomes of certain actions.[0m
+[38;5;240m│ [3;38;5;250m[!CAUTION][0m
+[38;5;240m│ [3;38;5;250mAdvises about risks or negative outcomes of certain actions.[0m
-
+
- Here is a sentence with a footnote.[^1] And another one.[^longnote]
+Here is a sentence with a footnote.[^1] And another one.[^longnote]
- Inline footnote: text with an inline footnote.^[This is an inline footnote
- body.]
+Inline footnote: text with an inline footnote.^[This is an inline footnote
+body.]
- [^1]: This is the first footnote body.
- [^longnote]: This footnote has [1mbold[0m, [38;5;213m[48;5;236m code [0m, and multiple
+[^1]: This is the first footnote body.
+[^longnote]: This footnote has [1mbold[0m, [38;5;213m[48;5;236m code [0m, and multiple
- [48;5;236m[38;5;213m paragraphs. It should render as a numbered reference in the main text, [0m
- [48;5;236m[38;5;213m with the body collected at the bottom of the document. [0m
+[48;5;236m[38;5;213m paragraphs. It should render as a numbered reference in the main text, [0m
+[48;5;236m[38;5;213m with the body collected at the bottom of the document. [0m
-
+
- Inline math: the Pythagorean theorem says $a^2 + b^2 = c^2$.
+Inline math: the Pythagorean theorem says $a^2 + b^2 = c^2$.
- Display math:
+Display math:
- $$
- \int_{-\infty}^{\infty} e^{-x^2} , dx = \sqrt{\pi}
- $$
+$$
+\int_{-\infty}^{\infty} e^{-x^2} , dx = \sqrt{\pi}
+$$
- A matrix:
+A matrix:
- $$
- A = \begin{pmatrix} 1 & 2 \ 3 & 4 \end{pmatrix}
- $$
+$$
+A = \begin{pmatrix} 1 & 2 \ 3 & 4 \end{pmatrix}
+$$
-
+
- Term 1
- : Definition of term 1.
+Term 1
+: Definition of term 1.
- Term 2
- : First paragraph of the definition.
+Term 2
+: First paragraph of the definition.
- [48;5;236m[38;5;213m Second paragraph of the definition, indented. [0m
+[48;5;236m[38;5;213m Second paragraph of the definition, indented. [0m
- Apple
- : A red or green fruit.
+Apple
+: A red or green fruit.
- Orange
- : A citrus fruit with a tough rind.
+Orange
+: A citrus fruit with a tough rind.
-
+
- Straight quotes that should become curly: "Hello," she said. 'Yes,' he
- replied.
- Ellipsis from three dots... and an em-dash -- like this, and an en-dash --
- too.
+Straight quotes that should become curly: "Hello," she said. 'Yes,' he replied.
+Ellipsis from three dots... and an em-dash -- like this, and an en-dash -- too.
-
+
- Reference a page with [[WikiLink]] syntax, and with an alias like
- [[Target Page|the display text]].
+Reference a page with [[WikiLink]] syntax, and with an alias like
+[[Target Page|the display text]].
-
+
- Water is H~2~O and Einstein said E=mc^2^. Also 10^th^ and x~n+1~.
+Water is H~2~O and Einstein said E=mc^2^. Also 10^th^ and x~n+1~.
-
+
- A flowchart:
+A flowchart:
- [48;5;236m[38;5;213m flowchart LR [0m
- [48;5;236m[38;5;213m A[Start] --> B{Decision} [0m
- [48;5;236m[38;5;213m B -->|Yes| C[Do thing] [0m
- [48;5;236m[38;5;213m B -->|No| D[Skip] [0m
- [48;5;236m[38;5;213m C --> E[End] [0m
- [48;5;236m[38;5;213m D --> E [0m
+[48;5;236m[38;5;213m flowchart LR [0m
+[48;5;236m[38;5;213m A[Start] --> B{Decision} [0m
+[48;5;236m[38;5;213m B -->|Yes| C[Do thing] [0m
+[48;5;236m[38;5;213m B -->|No| D[Skip] [0m
+[48;5;236m[38;5;213m C --> E[End] [0m
+[48;5;236m[38;5;213m D --> E [0m
- A sequence diagram:
+A sequence diagram:
- [48;5;236m[38;5;213m sequenceDiagram [0m
- [48;5;236m[38;5;213m Alice->>Bob: Hello Bob [0m
- [48;5;236m[38;5;213m Bob-->>Alice: Hi Alice [0m
- [48;5;236m[38;5;213m Alice-)Bob: See you later! [0m
+[48;5;236m[38;5;213m sequenceDiagram [0m
+[48;5;236m[38;5;213m Alice->>Bob: Hello Bob [0m
+[48;5;236m[38;5;213m Bob-->>Alice: Hi Alice [0m
+[48;5;236m[38;5;213m Alice-)Bob: See you later! [0m
- A class diagram:
+A class diagram:
- [48;5;236m[38;5;213m classDiagram [0m
- [48;5;236m[38;5;213m class Animal { [0m
- [48;5;236m[38;5;213m +String name [0m
- [48;5;236m[38;5;213m +int age [0m
- [48;5;236m[38;5;213m +makeSound() void [0m
- [48;5;236m[38;5;213m } [0m
- [48;5;236m[38;5;213m Animal <|-- Dog [0m
- [48;5;236m[38;5;213m Animal <|-- Cat [0m
+[48;5;236m[38;5;213m classDiagram [0m
+[48;5;236m[38;5;213m class Animal { [0m
+[48;5;236m[38;5;213m +String name [0m
+[48;5;236m[38;5;213m +int age [0m
+[48;5;236m[38;5;213m +makeSound() void [0m
+[48;5;236m[38;5;213m } [0m
+[48;5;236m[38;5;213m Animal <|-- Dog [0m
+[48;5;236m[38;5;213m Animal <|-- Cat [0m
-
+
- [48;5;236m[38;5;213m @startuml [0m
- [48;5;236m[38;5;213m Alice -> Bob: Authentication Request [0m
- [48;5;236m[38;5;213m Bob --> Alice: Authentication Response [0m
- [48;5;236m[38;5;213m @enduml [0m
+[48;5;236m[38;5;213m @startuml [0m
+[48;5;236m[38;5;213m Alice -> Bob: Authentication Request [0m
+[48;5;236m[38;5;213m Bob --> Alice: Authentication Response [0m
+[48;5;236m[38;5;213m @enduml [0m
- [48;5;236m[38;5;213m digraph G { [0m
- [48;5;236m[38;5;213m rankdir=LR; [0m
- [48;5;236m[38;5;213m A -> B -> C; [0m
- [48;5;236m[38;5;213m A -> C; [0m
- [48;5;236m[38;5;213m } [0m
+[48;5;236m[38;5;213m digraph G { [0m
+[48;5;236m[38;5;213m rankdir=LR; [0m
+[48;5;236m[38;5;213m A -> B -> C; [0m
+[48;5;236m[38;5;213m A -> C; [0m
+[48;5;236m[38;5;213m } [0m
-
+
- [48;5;236m[38;5;213m fn main() { [0m
- [48;5;236m[38;5;213m let greeting = "Hello, termdown!"; [0m
- [48;5;236m[38;5;213m println!("{}", greeting); [0m
- [48;5;236m[38;5;213m } [0m
+[48;5;236m[38;5;213m fn main() { [0m
+[48;5;236m[38;5;213m let greeting = "Hello, termdown!"; [0m
+[48;5;236m[38;5;213m println!("{}", greeting); [0m
+[48;5;236m[38;5;213m } [0m
- [48;5;236m[38;5;213m def fib(n: int) -> int: [0m
- [48;5;236m[38;5;213m a, b = 0, 1 [0m
- [48;5;236m[38;5;213m for _ in range(n): [0m
- [48;5;236m[38;5;213m a, b = b, a + b [0m
- [48;5;236m[38;5;213m return a [0m
+[48;5;236m[38;5;213m def fib(n: int) -> int: [0m
+[48;5;236m[38;5;213m a, b = 0, 1 [0m
+[48;5;236m[38;5;213m for _ in range(n): [0m
+[48;5;236m[38;5;213m a, b = b, a + b [0m
+[48;5;236m[38;5;213m return a [0m
- [48;5;236m[38;5;213m { [0m
- [48;5;236m[38;5;213m "name": "termdown", [0m
- [48;5;236m[38;5;213m "features": ["headings", "tables", "tasklists"], [0m
- [48;5;236m[38;5;213m "version": "0.2.0" [0m
- [48;5;236m[38;5;213m } [0m
+[48;5;236m[38;5;213m { [0m
+[48;5;236m[38;5;213m "name": "termdown", [0m
+[48;5;236m[38;5;213m "features": ["headings", "tables", "tasklists"], [0m
+[48;5;236m[38;5;213m "version": "0.2.0" [0m
+[48;5;236m[38;5;213m } [0m
-
+
- Local image (does not exist, exercises error path):
+Local image (does not exist, exercises error path):
- [2m[🖼 local image alt](./fixtures/nonexistent.png)[0m
+[2m[🖼 local image alt](./fixtures/nonexistent.png)[0m
- Remote image:
+Remote image:
- [2m[🖼 remote image](https://example.com/banner.png)[0m
+[2m[🖼 remote image](https://example.com/banner.png)[0m
- Reference-style image:
+Reference-style image:
- [2m[🖼 ref image](https://example.com/banner.png)[0m
+[2m[🖼 ref image](https://example.com/banner.png)[0m
-
+
- Shortcodes like :smile:, :rocket:, :tada: should ideally become 😄 🚀 🎉.
- Unicode emoji themselves work fine: 😄 🚀 🎉.
+Shortcodes like :smile:, :rocket:, :tada: should ideally become 😄 🚀 🎉.
+Unicode emoji themselves work fine: 😄 🚀 🎉.
-
+
- [1mLeft[0m [2m │ [0m[1mCenter[0m [2m │ [0m[1mRight[0m
- [2m─────────────────[0m[2m ┼ [0m[2m────────────[0m[2m ┼ [0m[2m──────[0m
- a [2m │ [0mb [2m │ [0mc
- long left content[2m │ [0mcenter [2m │ [0m1
- x [2m │ [0m[1mbold[0m in cell[2m │ [0m[38;5;213m[48;5;236m code [0m
+[1mLeft[0m [2m │ [0m[1mCenter[0m [2m │ [0m[1mRight[0m
+[2m─────────────────[0m[2m ┼ [0m[2m────────────[0m[2m ┼ [0m[2m──────[0m
+a [2m │ [0mb [2m │ [0mc
+long left content[2m │ [0mcenter [2m │ [0m1
+x [2m │ [0m[1mbold[0m in cell[2m │ [0m[38;5;213m[48;5;236m code [0m
-
+
- If every section above renders with rich formatting, termdown has full
- coverage of the
- audited feature set.
+If every section above renders with rich formatting, termdown has full coverage
+of the
+audited feature set.
diff --git a/src/cat.rs b/src/cat.rs
index 14b5502..17e1515 100644
--- a/src/cat.rs
+++ b/src/cat.rs
@@ -1,14 +1,13 @@
-//! Stream a `RenderedDoc` to stdout as ANSI text, matching the existing
-//! cat-mode visual output. Wrapping, margins, quote prefixes, list
-//! indentation, and Kitty heading image emission all happen here.
+//! Stream a `RenderedDoc` to stdout as ANSI text. Wrapping, quote prefixes,
+//! list indentation, and Kitty heading image emission all happen here.
use std::io::{BufWriter, Write};
use crate::layout::{Color, Line, LineKind, RenderedDoc, Span, Style};
use crate::render;
use crate::style::{
- display_width, Colors, BOLD_ON, DIM_ON, ITALIC_OFF, ITALIC_ON, MARGIN, MARGIN_WIDTH, RESET,
- STRIKETHROUGH_OFF, STRIKETHROUGH_ON, UNDERLINE_OFF, UNDERLINE_ON,
+ display_width, Colors, BOLD_ON, DIM_ON, ITALIC_OFF, ITALIC_ON, RESET, STRIKETHROUGH_OFF,
+ STRIKETHROUGH_ON, UNDERLINE_OFF, UNDERLINE_ON,
};
pub fn print(doc: &RenderedDoc, term_width: usize, colors: &Colors) {
@@ -51,17 +50,17 @@ fn write_line(
}
LineKind::HorizontalRule => {
let width = term_width.min(62).saturating_sub(2);
- let _ = writeln!(out, "{MARGIN}{DIM_ON}{}{RESET}", "\u{2500}".repeat(width));
+ let _ = writeln!(out, "{DIM_ON}{}{RESET}", "\u{2500}".repeat(width));
}
LineKind::Heading { id, .. } => {
if let Some(image_id) = id {
if let Some(img) = images.iter().find(|i| i.id == *image_id) {
- let _ = writeln!(out, "{MARGIN}{}", render::kitty_display(&img.png));
+ let _ = writeln!(out, "{}", render::kitty_display(&img.png));
return;
}
}
let text = render_spans_plain(&line.spans);
- let _ = writeln!(out, "{MARGIN}{BOLD_ON}{text}{RESET}");
+ let _ = writeln!(out, "{BOLD_ON}{text}{RESET}");
}
LineKind::BlockQuote { depth } => {
write_paragraph(out, &line.spans, *depth as usize, term_width, colors);
@@ -70,26 +69,20 @@ fn write_line(
write_paragraph(out, &line.spans, 0, term_width, colors);
}
LineKind::ListItem { .. } => {
- // Layout has already baked the per-depth indent and the bullet or
- // numbered marker into the first text span, so cat only needs to
- // prepend the outer margin.
+ // Layout has already baked the indent and bullet/number marker
+ // into the first text span.
let body = render_spans_ansi(&line.spans, colors);
- let buf = format!("{MARGIN}{body}");
- wrap_and_write(out, &buf, term_width, "");
+ wrap_and_write(out, &body, term_width);
}
LineKind::CodeBlock { .. } => {
// Single-line code blocks are handled via emit_code_block; this
// branch is unreachable in practice because `print` batches them.
let text = render_spans_plain(&line.spans);
- let _ = writeln!(
- out,
- "{MARGIN}{}{} {text} {RESET}",
- colors.code_bg, colors.code_fg
- );
+ let _ = writeln!(out, "{}{} {text} {RESET}", colors.code_bg, colors.code_fg);
}
LineKind::Table => {
let rendered = render_spans_ansi(&line.spans, colors);
- let _ = writeln!(out, "{MARGIN} {rendered}");
+ let _ = writeln!(out, "{rendered}");
}
}
}
@@ -103,7 +96,7 @@ fn emit_code_block(out: &mut W, group: &[Line], colors: &Colors) {
let pad = max_w.saturating_sub(display_width(text));
let _ = writeln!(
out,
- "{MARGIN}{}{} {text}{} {RESET}",
+ "{}{} {text}{} {RESET}",
colors.code_bg,
colors.code_fg,
" ".repeat(pad)
@@ -123,13 +116,12 @@ fn write_paragraph(
let bars: String = (0..quote_depth)
.map(|_| format!("{}\u{2502} ", colors.quote_bar))
.collect();
- format!("{MARGIN}{bars}{}", colors.quote_text)
+ format!("{bars}{}", colors.quote_text)
} else {
- MARGIN.to_string()
+ String::new()
};
let suffix = if quote_depth > 0 { RESET } else { "" };
- let prefix_visual_width = MARGIN_WIDTH + quote_depth * 3;
- let max_text_width = term_width.saturating_sub(prefix_visual_width);
+ let max_text_width = term_width.saturating_sub(quote_depth * 3);
if max_text_width == 0 || display_width(&body) <= max_text_width {
let _ = writeln!(out, "{prefix}{body}{suffix}");
@@ -140,14 +132,13 @@ fn write_paragraph(
}
}
-fn wrap_and_write(out: &mut W, text: &str, term_width: usize, suffix: &str) {
- let max = term_width.saturating_sub(MARGIN_WIDTH);
- if max == 0 || display_width(text) <= max {
- let _ = writeln!(out, "{text}{suffix}");
+fn wrap_and_write(out: &mut W, text: &str, term_width: usize) {
+ if display_width(text) <= term_width {
+ let _ = writeln!(out, "{text}");
return;
}
- for wrapped in wrap_text(text, max) {
- let _ = writeln!(out, "{wrapped}{suffix}");
+ for wrapped in wrap_text(text, term_width) {
+ let _ = writeln!(out, "{wrapped}");
}
}
@@ -305,7 +296,7 @@ mod tests {
#[test]
fn write_paragraph_wraps_quoted_content() {
use crate::layout::{Span, Style};
- use crate::style::{Colors, MARGIN};
+ use crate::style::Colors;
use crate::theme::Theme;
let colors = Colors::for_theme(Theme::Dark);
@@ -316,16 +307,12 @@ mod tests {
style: Style::default(),
}];
- // width=12, quote_depth=1 → prefix width = MARGIN_WIDTH(2) + 1*3 = 5,
- // so max_text_width = 12 - 5 = 7. "alpha" fits (5), "beta" fits (4 → 9
- // > 7 alone? No: 5+1+4=10 > 7), so lines: "alpha", "beta", "gamma".
- write_paragraph(&mut out, &spans, 1, 12, &colors);
+ // width=8, quote_depth=1 → prefix width = 1*3 = 3, max_text_width =
+ // 8 - 3 = 5. Each word ("alpha"/"beta"/"gamma") is on its own line.
+ write_paragraph(&mut out, &spans, 1, 8, &colors);
let got = String::from_utf8(out).unwrap();
- let prefix = format!(
- "{MARGIN}{}\u{2502} {}",
- colors.quote_bar, colors.quote_text
- );
+ let prefix = format!("{}\u{2502} {}", colors.quote_bar, colors.quote_text);
// Each wrapped word should appear on its own prefixed line.
assert!(got.contains(&format!("{prefix}alpha{RESET}")));
assert!(got.contains(&format!("{prefix}beta{RESET}")));
diff --git a/src/layout.rs b/src/layout.rs
index 623163d..2d6e801 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -305,8 +305,8 @@ pub fn build(md: &str, config: &Config, theme: Theme) -> RenderedDoc {
Event::Start(Tag::Item) => {
in_item = true;
// Reset the per-item buffer and seed it with the marker that this
- // item needs (bullet or number). Indentation is baked in so
- // cat.rs only needs to append a margin.
+ // item needs (bullet or number). Indentation is baked in so cat
+ // and TUI can emit the spans verbatim.
spans.clear();
text_buf.clear();
let depth = list_stack.len();
@@ -773,9 +773,9 @@ fn strip_html_comments(s: &str) -> String {
out
}
-/// Render accumulated table rows into `LineKind::Table` lines with padding and separators.
-/// Keeps the margin-less column layout the existing cat mode produces — the outer
-/// " " margin is added by `cat.rs`.
+/// Render accumulated table rows into `LineKind::Table` lines with padding
+/// and separators. Cells start at column 0; the renderer emits the spans
+/// verbatim.
fn emit_table(lines: &mut Vec, rows: &[Vec>]) {
if rows.is_empty() {
return;
diff --git a/src/style.rs b/src/style.rs
index 7d987df..fb39d1d 100644
--- a/src/style.rs
+++ b/src/style.rs
@@ -82,11 +82,6 @@ pub fn heading_style(level: u8, theme: Theme) -> HeadingStyle {
}
}
-// ─── Layout ─────────────────────────────────────────────────────────────────
-
-pub const MARGIN: &str = " ";
-pub const MARGIN_WIDTH: usize = 4;
-
// ─── ANSI Escape Codes ──────────────────────────────────────────────────────
pub const BOLD_ON: &str = "\x1b[1m";
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index 776f679..0aef458 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -21,12 +21,14 @@ use tui_textarea::TextArea;
use crate::config::Config;
use crate::layout;
-use crate::style::MARGIN_WIDTH;
use crate::theme::Theme;
use crate::tui::search::SearchState;
use viewport::Viewport;
+/// Width of the Table-of-Contents side panel when it is open.
+const TOC_PANEL_WIDTH: u16 = 30;
+
enum Mode {
Normal,
Search {
@@ -283,7 +285,7 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu
let size = terminal.size()?;
let body_height = size.height.saturating_sub(1);
let body_width = if app.active().toc_open {
- size.width.saturating_sub(30)
+ size.width.saturating_sub(TOC_PANEL_WIDTH)
} else {
size.width
};
@@ -826,13 +828,6 @@ fn draw(frame: &mut ratatui::Frame, app: &App) {
})
});
- // Every body row is prefixed with this margin so TUI indentation matches
- // cat mode's 4-col gutter. Without it, text starts at column 0 of the
- // body area, clashing visually with heading images (which are placed at
- // the MARGIN_WIDTH column offset to align with cat mode's output).
- let margin = " ".repeat(MARGIN_WIDTH);
- let margin_span = RSpan::raw(margin);
-
let mut rendered: Vec = Vec::new();
for vl in active.viewport.visible() {
let logical = &active.doc.lines[vl.logical_index];
@@ -853,17 +848,14 @@ fn draw(frame: &mut ratatui::Frame, app: &App) {
current_logical,
);
let rspans = clipped_spans(logical, vl.byte_start, vl.byte_end, &matches, app.theme);
- let mut full_spans: Vec = Vec::with_capacity(rspans.len() + 1);
- full_spans.push(margin_span.clone());
- full_spans.extend(rspans);
- rendered.push(RLine::from(full_spans));
+ rendered.push(RLine::from(rspans));
}
let body_area = if active.toc_open {
let split = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([
- ratatui::layout::Constraint::Length(30),
+ ratatui::layout::Constraint::Length(TOC_PANEL_WIDTH),
ratatui::layout::Constraint::Min(20),
])
.split(chunks[0]);
@@ -1249,12 +1241,8 @@ fn refine_image_rows(doc: &mut layout::RenderedDoc, cell_px_height: u32) {
fn desired_image_placements(app: &App) -> HashMap {
let active = app.active();
- let col_offset: u16 = if active.toc_open {
- // ToC panel (30) + margin within body panel (MARGIN_WIDTH).
- 30 + MARGIN_WIDTH as u16
- } else {
- MARGIN_WIDTH as u16
- };
+ // Heading images must start past the ToC panel when it is open.
+ let col_offset: u16 = if active.toc_open { TOC_PANEL_WIDTH } else { 0 };
// When the help popup is open, drop placements whose rows intersect the
// popup rectangle. Kitty images live on a separate graphics layer, so
// without this they would show through the popup; dropping them all
diff --git a/src/tui/viewport.rs b/src/tui/viewport.rs
index 8d7ec56..a5f0d97 100644
--- a/src/tui/viewport.rs
+++ b/src/tui/viewport.rs
@@ -127,8 +127,7 @@ fn wrap_all(lines: &[Line], width: u16) -> Vec {
use crate::layout::LineKind;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
- // Reserve margin (4 cols) to match cat-mode indent.
- let max: usize = (width as usize).saturating_sub(4);
+ let max: usize = width as usize;
let mut out = Vec::with_capacity(lines.len());
for (li, line) in lines.iter().enumerate() {
@@ -456,7 +455,7 @@ mod tests {
};
let mut vp = Viewport::new(10, 20);
vp.ensure_wrap(&doc);
- // With max width 20 - 4 (margin) = 16 cols, 24 cols should split into 2 visual lines.
+ // With max width 20 cols, 24 cols should split into 2 visual lines.
assert!(
vp.total_visual_lines() >= 2,
"CJK content should wrap across lines"
diff --git a/tests/cli.rs b/tests/cli.rs
index 57dcf3e..6f9e77b 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -142,7 +142,7 @@ fn stdin_rendering_works_without_terminal_warning_when_supported() {
let output = run_termdown(&["-"], Some("hello\n"), &[("TERM_PROGRAM", "ghostty")], &[]);
assert!(output.status.success());
- assert_eq!(stdout_text(&output), " hello\n");
+ assert_eq!(stdout_text(&output), "hello\n");
assert!(stderr_text(&output).trim().is_empty());
}
@@ -159,8 +159,8 @@ fn file_input_renders_table_output() {
assert!(output.status.success());
assert!(stderr_text(&output).trim().is_empty());
- assert!(stdout.contains(" A │ B"));
- assert!(stdout.contains(" x │ long"));
+ assert!(stdout.contains("A │ B"));
+ assert!(stdout.contains("x │ long"));
}
#[test]
@@ -204,12 +204,9 @@ fn html_inline_tags_map_to_ansi_and_block_renders_dim() {
"clean output was: {clean:?}"
);
// Block HTML lines preserved verbatim.
- assert!(clean.contains(" "), "clean output was: {clean:?}");
- assert!(
- clean.contains("
x
"),
- "clean output was: {clean:?}"
- );
- assert!(clean.contains("
"), "clean output was: {clean:?}");
+ assert!(clean.contains(""), "clean output was: {clean:?}");
+ assert!(clean.contains("
x
"), "clean output was: {clean:?}");
+ assert!(clean.contains("
"), "clean output was: {clean:?}");
// ANSI codes present in raw output: bold (\x1b[1m) and underline (\x1b[4m).
assert!(raw.contains("\x1b[1m"), "raw output: {raw:?}");
@@ -226,7 +223,7 @@ fn unsupported_terminal_emits_warning_on_stderr() {
);
assert!(output.status.success());
- assert_eq!(stdout_text(&output), " hello\n");
+ assert_eq!(stdout_text(&output), "hello\n");
let stderr = stderr_text(&output);
assert!(stderr.contains("termdown: warning: terminal may not support Kitty graphics protocol"));
assert!(stderr.contains("termdown: headings require Ghostty, Kitty, WezTerm, or iTerm2"));