From 184fb226078412397c5bbbc61b24f341abf651d6 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 22 May 2026 09:48:57 +0800 Subject: [PATCH] refactor: remove 4-col outer margin from cat and TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MARGIN / MARGIN_WIDTH gutter originally mirrored glow's output. With TUI as the default mode it cost 4 cols of horizontal real estate for no remaining visual gain. Cat-mode table rows also lose their extra 2-col inset so all body content aligns at column 0. Hoist TOC_PANEL_WIDTH while in the area (was a magic 30 in three places). Snapshot fixtures regenerated; existing design docs get a dated "Update — 2026-05-22" appendix rather than in-place edits. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + docs/LINK_PICKER_DESIGN.md | 13 + docs/TUI_MODE_DEBUG_LOG.md | 15 ++ docs/TUI_MODE_PLAN.md | 27 +++ fixtures/expected/emoji-test.ansi | 44 ++-- fixtures/expected/full-syntax-zh.ansi | 90 +++---- fixtures/expected/full-syntax.ansi | 92 +++---- fixtures/expected/tasklist.ansi | 54 ++--- fixtures/expected/unsupported-syntax.ansi | 278 +++++++++++----------- src/cat.rs | 65 ++--- src/layout.rs | 10 +- src/style.rs | 5 - src/tui/mod.rs | 28 +-- src/tui/viewport.rs | 5 +- tests/cli.rs | 17 +- 15 files changed, 387 insertions(+), 362 deletions(-) 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 变体: ☀️ ❤️ ⭐️ - + - 正文仍然由终端自身字体渲染,所以这里主要用来对比标题和正文的表现差异。 +正文仍然由终端自身字体渲染,所以这里主要用来对比标题和正文的表现差异。 - │ 引用块里也放几个字符:💡 🛠 📦 +│ 引用块里也放几个字符:💡 🛠 📦 - + - Case  │ Example - ────────────────── ┼ ─────────────────── - Single emoji  │ 😀 - Mixed text  │ 修正版 ✨ version 2 - Symbol-like  │ ✅ ⚠ ❌ - Variation selector │ ☀️ ❤️ +Case  │ Example +────────────────── ┼ ─────────────────── +Single emoji  │ 😀 +Mixed text  │ 修正版 ✨ version 2 +Symbol-like  │ ✅ ⚠ ❌ +Variation selector │ ☀️ ❤️ - + - 这一行用于观察复杂 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 @@ - + - + - + - + - 这是六级标题 h6 +这是六级标题 h6 - + - 这段文字将显示为斜体 - 这也是斜体 +这段文字将显示为斜体 +这也是斜体 - 这段文字将显示为粗体 - 这也是粗体 +这段文字将显示为粗体 +这也是粗体 - 你 可以 组合使用它们 +你 可以 组合使用它们 - + - + - • 项目 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 - + - [🖼 这是替代文本。](/image/Markdown-mark.svg) +[🖼 这是替代文本。](/image/Markdown-mark.svg) - + - 你可能正在使用 Markdown 实时预览 (https://markdownlivepreview.com/)。 +你可能正在使用 Markdown 实时预览 (https://markdownlivepreview.com/)。 - + - │ Markdown 是一种轻量级标记语言,使用纯文本格式语法,由 John Gruber 和 - │ Aaron Swartz 于 2004 年创建。 - │ │ Markdown 常用于编写 readme - │ │ 文件、在线论坛中的消息格式化,以及使用纯文本编辑器创建富文本。 +│ Markdown 是一种轻量级标记语言,使用纯文本格式语法,由 John Gruber 和 Aaron +│ Swartz 于 2004 年创建。 +│ │ Markdown 常用于编写 readme +│ │ 文件、在线论坛中的消息格式化,以及使用纯文本编辑器创建富文本。 - + - 左对齐列 │ 居中对齐列 - ──────── ┼ ────────── - 左侧 foo │ 右侧 foo - 左侧 bar │ 右侧 bar - 左侧 baz │ 右侧 baz +左对齐列 │ 居中对齐列 +──────── ┼ ────────── +左侧 foo │ 右侧 foo +左侧 bar │ 右侧 bar +左侧 baz │ 右侧 baz - + -  let message = '你好,世界';  -  alert(message);  + let message = '你好,世界';  + alert(message);  - + - 这个网站使用了  markedjs/marked 。 +这个网站使用了  markedjs/marked 。 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 @@ - + - + - + - + - This is a Heading h6 +This is a Heading h6 - + - This text will be italic - This will also be italic +This text will be italic +This will also be italic - This text will be bold - This will also be bold +This text will be bold +This will also be bold - You can combine them +You can combine them - + - + - • 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 - + - [🖼 This is an alt text.](/image/Markdown-mark.svg) +[🖼 This is an alt text.](/image/Markdown-mark.svg) - + - You may be using Markdown Live Preview (https://markdownlivepreview.com/). +You may be using Markdown Live Preview (https://markdownlivepreview.com/). - + - │ Markdown is a lightweight markup language with plain-text-formatting - │ syntax, created in 2004 by John Gruber with Aaron Swartz. - │ │ Markdown is often used to format readme files, for writing messages - │ │ in online discussion forums, and to create rich text using a plain - │ │ text editor. +│ Markdown is a lightweight markup language with plain-text-formatting syntax, +│ created in 2004 by John Gruber with Aaron Swartz. +│ │ Markdown is often used to format readme files, for writing messages in +│ │ online discussion forums, and to create rich text using a plain text +│ │ editor. - + - Left columns │ Right columns - ──────────── ┼ ───────────── - left foo  │ right foo - left bar  │ right bar - left baz  │ right baz +Left columns │ Right columns +──────────── ┼ ───────────── +left foo  │ right foo +left bar  │ right bar +left baz  │ right baz - + -  let message = 'Hello world';  -  alert(message);  + let message = 'Hello world';  + alert(message);  - + - This web site is using  markedjs/marked . +This web site is using  markedjs/marked . 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 - [✓] Completed and struck through - [ ] Task with bold and italic text - [ ] Task with  inline code  + [ ] Task after ordered list + [✓] Completed and struck through + [ ] Task with bold and italic text + [ ] Task with  inline code  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 @@ - ──────────────────────────────────────────────────────────── +──────────────────────────────────────────────────────────── - + - + - This fixture collects every Markdown feature listed as missing or partial in -  docs/MARKDOWN_FEATURE_COVERAGE.md . 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 missing or partial in + docs/MARKDOWN_FEATURE_COVERAGE.md . 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: - 
 -  Hello from inline HTML. - 
 +
 + Hello from inline HTML. +
 - Inline HTML in a paragraph: this word is underlined via HTML and this one is - red via HTML. An inline - break and an - HTML abbreviation. +Inline HTML in a paragraph: this word is underlined via HTML 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. - + - │ [!NOTE] - │ Useful information that users should know, even when skimming content. +│ [!NOTE] +│ Useful information that users should know, even when skimming content. - │ [!TIP] - │ Helpful advice for doing things better or more easily. +│ [!TIP] +│ Helpful advice for doing things better or more easily. - │ [!IMPORTANT] - │ Key information users need to know to achieve their goal. +│ [!IMPORTANT] +│ Key information users need to know to achieve their goal. - │ [!WARNING] - │ Urgent info that needs immediate user attention to avoid problems. +│ [!WARNING] +│ Urgent info that needs immediate user attention to avoid problems. - │ [!CAUTION] - │ Advises about risks or negative outcomes of certain actions. +│ [!CAUTION] +│ Advises about risks or negative outcomes of certain actions. - + - 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 bold,  code , and multiple +[^1]: This is the first footnote body. +[^longnote]: This footnote has bold,  code , and multiple -  paragraphs. It should render as a numbered reference in the main text,  -  with the body collected at the bottom of the document.  + paragraphs. It should render as a numbered reference in the main text,  + with the body collected at the bottom of the document.  - + - 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. -  Second paragraph of the definition, indented.  + Second paragraph of the definition, indented.  - 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: -  flowchart LR  -  A[Start] --> B{Decision}  -  B -->|Yes| C[Do thing]  -  B -->|No| D[Skip]  -  C --> E[End]  -  D --> E  + flowchart LR  + A[Start] --> B{Decision}  + B -->|Yes| C[Do thing]  + B -->|No| D[Skip]  + C --> E[End]  + D --> E  - A sequence diagram: +A sequence diagram: -  sequenceDiagram  -  Alice->>Bob: Hello Bob  -  Bob-->>Alice: Hi Alice  -  Alice-)Bob: See you later!  + sequenceDiagram  + Alice->>Bob: Hello Bob  + Bob-->>Alice: Hi Alice  + Alice-)Bob: See you later!  - A class diagram: +A class diagram: -  classDiagram  -  class Animal {  -  +String name  -  +int age  -  +makeSound() void  -  }  -  Animal <|-- Dog  -  Animal <|-- Cat  + classDiagram  + class Animal {  + +String name  + +int age  + +makeSound() void  + }  + Animal <|-- Dog  + Animal <|-- Cat  - + -  @startuml  -  Alice -> Bob: Authentication Request  -  Bob --> Alice: Authentication Response  -  @enduml  + @startuml  + Alice -> Bob: Authentication Request  + Bob --> Alice: Authentication Response  + @enduml  -  digraph G {  -  rankdir=LR;  -  A -> B -> C;  -  A -> C;  -  }  + digraph G {  + rankdir=LR;  + A -> B -> C;  + A -> C;  + }  - + -  fn main() {  -  let greeting = "Hello, termdown!";  -  println!("{}", greeting);  -  }  + fn main() {  + let greeting = "Hello, termdown!";  + println!("{}", greeting);  + }  -  def fib(n: int) -> int:  -  a, b = 0, 1  -  for _ in range(n):  -  a, b = b, a + b  -  return a  + def fib(n: int) -> int:  + a, b = 0, 1  + for _ in range(n):  + a, b = b, a + b  + return a  -  {  -  "name": "termdown",  -  "features": ["headings", "tables", "tasklists"],  -  "version": "0.2.0"  -  }  + {  + "name": "termdown",  + "features": ["headings", "tables", "tasklists"],  + "version": "0.2.0"  + }  - + - Local image (does not exist, exercises error path): +Local image (does not exist, exercises error path): - [🖼 local image alt](./fixtures/nonexistent.png) +[🖼 local image alt](./fixtures/nonexistent.png) - Remote image: +Remote image: - [🖼 remote image](https://example.com/banner.png) +[🖼 remote image](https://example.com/banner.png) - Reference-style image: +Reference-style image: - [🖼 ref image](https://example.com/banner.png) +[🖼 ref image](https://example.com/banner.png) - + - 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: 😄 🚀 🎉. - + - Left  │ Center  │ Right - ───────────────── ┼ ──────────── ┼ ────── - a  │ b  │ c - long left content │ center  │ 1 - x  │ bold in cell │  code  +Left  │ Center  │ Right +───────────────── ┼ ──────────── ┼ ────── +a  │ b  │ c +long left content │ center  │ 1 +x  │ bold in cell │  code  - + - 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"));