From 640a0df5f169f92d18e425cf0c0782aea23901e2 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:19:01 +0900 Subject: [PATCH 1/8] refactor: walk nested blocks through Block::children. --- .claude/rules/rendering.md | 3 ++- src/diagram/mod.rs | 46 +++++++++++++++----------------------- src/flavor/azure_devops.rs | 11 +++------ src/flavor/gfm.rs | 26 +++++++++------------ src/flavor/tests.rs | 14 ++++++++++++ src/markdown/model.rs | 43 ++++++++++++++++++++++++++--------- 6 files changed, 81 insertions(+), 62 deletions(-) diff --git a/.claude/rules/rendering.md b/.claude/rules/rendering.md index b92d054..fa3ee29 100644 --- a/.claude/rules/rendering.md +++ b/.claude/rules/rendering.md @@ -16,8 +16,9 @@ paths: `markdown/model.rs` は renderer-neutral に保つ。`ratatui` や端末型への依存をここへ持ち込まない。フレーバー差は `flavor/` の内側に閉じ、`layout/` 以降へ持ち込まない。`azure_devops.rs` は Azure 固有の記法を汎用の block / inline へ変換する役目であって、描画の分岐を作る役目ではない。 - **`Block::Image` は作らない。** CommonMark では画像は inline 要素であり、段落と独立した block にすると source range と wrap 処理が二重化する。`Inline::Image` として保持し、layout 側で `[image: ...]` / `[attachment: ...]` の placeholder か、描画領域へ変換する。 -- **`Block::Alert` も作らない。** GFM alert は「ラベルを持つ引用」であり、構造は `Block::Quote` と同一。`QuoteBlock.kind: Option` として持てば、`Block::range()`・`collect_headings`・`gfm::promote_diagrams`・`azure_devops.rs` の再帰といった既存の走査がそのまま動く。variant を足すとその全部に分岐が増え、得られるのは layout の 1 箇所の分岐だけになる。 +- **`Block::Alert` も作らない。** GFM alert は「ラベルを持つ引用」であり、構造は `Block::Quote` と同一。`QuoteBlock.kind: Option` として持てば、`Block::range()` や `Block::children()` を通る既存の走査がそのまま動く。variant を足すとその全部に分岐が増え、得られるのは layout の 1 箇所の分岐だけになる。 - alert の判定は `Options::ENABLE_GFM` に任せ、フレーバーで分けない。Azure DevOps Wiki も同じ `> [!NOTE]` 記法を持つため、`markdown/parser.rs` の共通経路に置くのが両フレーバーで正しい。未知の種別(`[!FOO]`)は `kind: None` の普通の引用になり、マーカーは本文に残る。 +- ブロック木の再帰は `Block::children()` / `children_mut()` だけを通す。走査ごとに container を列挙すると、どこまで降りるかが走査ごとにずれる。container を足すときはこの 2 つに足す。 - すべての user-authored block は `SourceRange`(byte 範囲 + 1-based 行)を持つ。`--line` と `--watch` の読み位置保持がこれに依存するので、新しい block を足すときも必ず持たせる。 - 未終端 `:::` container の body は **最初の空行で打ち切り**、diagnostic として報告する。markdown-it 系のように文書末尾まで伸ばすと、`::: mermaid` の閉じ忘れ 1 個で以降の本文全体が diagram body に吸収され、viewer として最も見たいものが読めなくなる。 - 閉じマーカー探索は code fence を認識する。fence 内の `:::` はリテラルであり closer ではない。fence 内の Azure 記法も同様にリテラルのまま残す。 diff --git a/src/diagram/mod.rs b/src/diagram/mod.rs index 147a6ba..f5309ee 100644 --- a/src/diagram/mod.rs +++ b/src/diagram/mod.rs @@ -38,38 +38,28 @@ fn resolve_blocks( diagnostics: &mut Vec, ) { for block in blocks.iter_mut() { - match block { - Block::Diagram(diagram) => { - if flavor == Flavor::AzureDevops { - diagram.warnings = azure_compat::warnings(&diagram.source); - for warning in &diagram.warnings { - diagnostics.push(Diagnostic::warning(warning.clone(), Some(diagram.range))); - } + if let Block::Diagram(diagram) = block { + if flavor == Flavor::AzureDevops { + diagram.warnings = azure_compat::warnings(&diagram.source); + for warning in &diagram.warnings { + diagnostics.push(Diagnostic::warning(warning.clone(), Some(diagram.range))); } - match mode { - // Source and off never invoke the renderer. - MermaidMode::Source | MermaidMode::Off => {} - MermaidMode::Image => match renderer.render_png(&diagram.source) { - Ok(png) => diagram.png = Some(png), - // Text may still draw it, and reports the error if not. - Err(_) => render_text(diagram, renderer, MermaidMode::Unicode, diagnostics), - }, - MermaidMode::Unicode | MermaidMode::Ascii => { - render_text(diagram, renderer, mode, diagnostics) - } - } - } - Block::Quote(q) => resolve_blocks(&mut q.blocks, renderer, mode, flavor, diagnostics), - Block::Details(d) => resolve_blocks(&mut d.blocks, renderer, mode, flavor, diagnostics), - Block::Footnote(f) => { - resolve_blocks(&mut f.blocks, renderer, mode, flavor, diagnostics) } - Block::List(l) => { - for item in &mut l.items { - resolve_blocks(&mut item.blocks, renderer, mode, flavor, diagnostics); + match mode { + // Source and off never invoke the renderer. + MermaidMode::Source | MermaidMode::Off => {} + MermaidMode::Image => match renderer.render_png(&diagram.source) { + Ok(png) => diagram.png = Some(png), + // Text may still draw it, and reports the error if not. + Err(_) => render_text(diagram, renderer, MermaidMode::Unicode, diagnostics), + }, + MermaidMode::Unicode | MermaidMode::Ascii => { + render_text(diagram, renderer, mode, diagnostics) } } - _ => {} + } + for children in block.children_mut() { + resolve_blocks(children, renderer, mode, flavor, diagnostics); } } } diff --git a/src/flavor/azure_devops.rs b/src/flavor/azure_devops.rs index 0eea538..ba0cdaa 100644 --- a/src/flavor/azure_devops.rs +++ b/src/flavor/azure_devops.rs @@ -522,14 +522,6 @@ fn post_process_blocks(blocks: &mut [Block]) { match block { Block::Heading(h) => post_process_inlines(&mut h.content), Block::Paragraph(p) => post_process_inlines(&mut p.content), - Block::Quote(q) => post_process_blocks(&mut q.blocks), - Block::Details(d) => post_process_blocks(&mut d.blocks), - Block::Footnote(f) => post_process_blocks(&mut f.blocks), - Block::List(l) => { - for item in &mut l.items { - post_process_blocks(&mut item.blocks); - } - } Block::Table(t) => { for cell in t.header.iter_mut().chain(t.rows.iter_mut().flatten()) { post_process_inlines(&mut cell.content); @@ -545,6 +537,9 @@ fn post_process_blocks(blocks: &mut [Block]) { } _ => {} } + for children in block.children_mut() { + post_process_blocks(children); + } } } diff --git a/src/flavor/gfm.rs b/src/flavor/gfm.rs index d8bf06d..de54603 100644 --- a/src/flavor/gfm.rs +++ b/src/flavor/gfm.rs @@ -13,21 +13,17 @@ pub fn parse(source: SourceText) -> Document { fn promote_diagrams(blocks: &mut [Block]) { for block in blocks.iter_mut() { - match block { - Block::Code(c) if c.language.as_deref() == Some("mermaid") => { - *block = Block::Diagram(DiagramBlock::new( - "mermaid".to_string(), - c.text.clone(), - c.range, - )); - } - Block::Quote(q) => promote_diagrams(&mut q.blocks), - Block::List(l) => { - for item in &mut l.items { - promote_diagrams(&mut item.blocks); - } - } - _ => {} + if let Block::Code(c) = block + && c.language.as_deref() == Some("mermaid") + { + *block = Block::Diagram(DiagramBlock::new( + "mermaid".to_string(), + c.text.clone(), + c.range, + )); + } + for children in block.children_mut() { + promote_diagrams(children); } } } diff --git a/src/flavor/tests.rs b/src/flavor/tests.rs index 57df73a..cd745c7 100644 --- a/src/flavor/tests.rs +++ b/src/flavor/tests.rs @@ -120,6 +120,20 @@ fn fenced_mermaid_works_in_gfm_flavor_too() { assert_eq!(kinds(&gfm("```mermaid\ngraph LR\n```\n")), vec!["diagram"]); } +#[test] +fn fenced_mermaid_in_a_gfm_footnote_becomes_a_diagram() { + let doc = gfm("Text[^1]\n\n[^1]: Note\n\n ```mermaid\n graph LR\n ```\n"); + let Some(Block::Footnote(footnote)) = + doc.blocks.iter().find(|b| matches!(b, Block::Footnote(_))) + else { + panic!("expected a footnote, got {:?}", kinds(&doc)); + }; + assert_eq!( + footnote.blocks.iter().map(kind).collect::>(), + vec!["paragraph", "diagram"] + ); +} + #[test] fn video_and_query_containers_become_placeholders() { let doc = azure( diff --git a/src/markdown/model.rs b/src/markdown/model.rs index d10df7e..2a8db03 100644 --- a/src/markdown/model.rs +++ b/src/markdown/model.rs @@ -245,6 +245,34 @@ pub struct ImageInline { pub alt: String, } +impl Block { + /// The block sequences nested directly inside this block. Every traversal + /// descends through this, so a container is never missed by one of them. + pub fn children(&self) -> impl Iterator { + let (own, items): (Option<&[Block]>, &[ListItem]) = match self { + Block::Quote(q) => (Some(&q.blocks), &[]), + Block::Details(d) => (Some(&d.blocks), &[]), + Block::Footnote(f) => (Some(&f.blocks), &[]), + Block::List(l) => (None, &l.items), + _ => (None, &[]), + }; + own.into_iter() + .chain(items.iter().map(|item| item.blocks.as_slice())) + } + + pub fn children_mut(&mut self) -> impl Iterator { + let (own, items): (Option<&mut [Block]>, &mut [ListItem]) = match self { + Block::Quote(q) => (Some(&mut q.blocks), &mut []), + Block::Details(d) => (Some(&mut d.blocks), &mut []), + Block::Footnote(f) => (Some(&mut f.blocks), &mut []), + Block::List(l) => (None, &mut l.items), + _ => (None, &mut []), + }; + own.into_iter() + .chain(items.iter_mut().map(|item| item.blocks.as_mut_slice())) + } +} + #[cfg(test)] impl Block { pub fn range(&self) -> SourceRange { @@ -275,16 +303,11 @@ pub fn headings(blocks: &[Block]) -> Vec<&HeadingBlock> { fn collect_headings<'a>(blocks: &'a [Block], out: &mut Vec<&'a HeadingBlock>) { for block in blocks { - match block { - Block::Heading(h) => out.push(h), - Block::Quote(q) => collect_headings(&q.blocks, out), - Block::Details(d) => collect_headings(&d.blocks, out), - Block::List(l) => { - for item in &l.items { - collect_headings(&item.blocks, out); - } - } - _ => {} + if let Block::Heading(h) = block { + out.push(h); + } + for children in block.children() { + collect_headings(children, out); } } } From 89dc5849468a8d3f3e739696fea46d6681d119a2 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:20:03 +0900 Subject: [PATCH 2/8] refactor: move option enums into the modules that use them. --- src/cli.rs | 92 +++++++++--------------------------------- src/config.rs | 16 ++++---- src/diagram/mermaid.rs | 2 +- src/diagram/mod.rs | 14 ++++++- src/diagram/tests.rs | 3 +- src/flavor/mod.rs | 10 ++++- src/flavor/tests.rs | 3 +- src/input.rs | 8 +++- src/layout/document.rs | 4 +- src/layout/icons.rs | 9 ++++- src/layout/inline.rs | 2 +- src/layout/theme.rs | 23 +++++++---- src/main.rs | 14 +++---- src/output/mod.rs | 8 +++- src/pager/app.rs | 5 ++- 15 files changed, 104 insertions(+), 109 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2c56e68..25b39ef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,8 +4,13 @@ use std::path::PathBuf; use clap::builder::styling::{AnsiColor, Styles}; use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, ValueEnum, parser::ValueSource}; +use crate::diagram::MermaidMode; +use crate::flavor::Flavor; use crate::image::Protocol; +use crate::input::InputSource; use crate::layout::icons::IconSet; +use crate::layout::theme::ThemeChoice; +use crate::output::ColorChoice; const STYLES: Styles = Styles::styled() .header(AnsiColor::Green.on_default().bold()) @@ -49,8 +54,8 @@ pub struct Cli { pub mermaid: MermaidMode, /// Theme - #[arg(long, value_enum, default_value_t = Theme::Auto)] - pub theme: Theme, + #[arg(long, value_enum, default_value_t = ThemeChoice::Auto)] + pub theme: ThemeChoice, /// ANSI color policy #[arg(long, value_enum, default_value_t = ColorWhen::Auto, value_name = "WHEN")] @@ -69,8 +74,8 @@ pub struct Cli { pub images: ImagesWhen, /// Glyphs used for alerts, code fences and placeholders - #[arg(long, value_enum, default_value_t = IconsSet::Unicode, value_name = "SET")] - pub icons: IconsSet, + #[arg(long, value_enum, default_value_t = IconSet::Unicode, value_name = "SET")] + pub icons: IconSet, /// Re-render the file when it changes on disk #[arg(long)] @@ -81,30 +86,6 @@ pub struct Cli { pub plain: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum Flavor { - Gfm, - #[value(name = "azure-devops")] - AzureDevops, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] -pub enum MermaidMode { - #[default] - Unicode, - Ascii, - Image, - Source, - Off, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum Theme { - Auto, - Dark, - Light, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum ColorWhen { Auto, @@ -135,22 +116,6 @@ pub enum ImagesWhen { Never, } -/// `nerd` is opt-in and never detected: whether a Nerd Font is installed is a -/// property of the terminal's font, and asking the terminal would mean writing -/// to the tty and waiting for an answer. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum IconsSet { - Unicode, - Nerd, -} - -/// Where the Markdown source comes from. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum InputSource { - File(PathBuf), - Stdin, -} - /// Which backend renders the document. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputMode { @@ -158,13 +123,6 @@ pub enum OutputMode { Stdout, } -/// Whether the stdout backend emits ANSI escapes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ColorChoice { - Ansi, - Plain, -} - /// Terminal facts that mode resolution depends on. Kept explicit so resolution /// stays a pure function and remains testable without a TTY. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -387,18 +345,6 @@ impl Cli { } } - /// Which glyph set the chrome is drawn with. - /// - /// Glyphs are ordinary characters rather than escape sequences, so unlike - /// images and hyperlinks they are not tied to the colour policy: `--plain` - /// still shows them. - pub fn icons(&self) -> IconSet { - match self.icons { - IconsSet::Unicode => IconSet::Unicode, - IconsSet::Nerd => IconSet::Nerd, - } - } - pub fn start_line(&self) -> Option { self.line.map(|n| n as usize) } @@ -442,7 +388,7 @@ mod tests { let cli = cli(&["a.md"]); assert_eq!(cli.flavor, Flavor::AzureDevops); assert_eq!(cli.mermaid, MermaidMode::Unicode); - assert_eq!(cli.theme, Theme::Auto); + assert_eq!(cli.theme, ThemeChoice::Auto); assert_eq!(cli.color, ColorWhen::Auto); } @@ -516,24 +462,24 @@ mod tests { let config = Config { flavor: Some(Flavor::Gfm), mermaid: Some(MermaidMode::Ascii), - theme: Some(Theme::Light), + theme: Some(ThemeChoice::Light), color: Some(ColorWhen::Never), hyperlinks: Some(HyperlinkWhen::Always), highlight: Some(HighlightWhen::Never), images: Some(ImagesWhen::Never), - icons: Some(IconsSet::Nerd), + icons: Some(IconSet::Nerd), width: Some(100), watch: None, }; let cli = configured(&["a.md"], &config); assert_eq!(cli.flavor, Flavor::Gfm); assert_eq!(cli.mermaid, MermaidMode::Ascii); - assert_eq!(cli.theme, Theme::Light); + assert_eq!(cli.theme, ThemeChoice::Light); assert_eq!(cli.color, ColorWhen::Never); assert_eq!(cli.hyperlinks, HyperlinkWhen::Always); assert_eq!(cli.highlight, HighlightWhen::Never); assert_eq!(cli.images, ImagesWhen::Never); - assert_eq!(cli.icons, IconsSet::Nerd); + assert_eq!(cli.icons, IconSet::Nerd); assert_eq!(cli.width, Some(100)); } @@ -541,14 +487,14 @@ mod tests { /// can still ask for the plain set on a machine without the font. #[test] fn icons_default_to_unicode_and_are_chosen_explicitly() { - assert_eq!(cli(&["a.md"]).icons(), IconSet::Unicode); - assert_eq!(cli(&["--icons", "nerd", "a.md"]).icons(), IconSet::Nerd); + assert_eq!(cli(&["a.md"]).icons, IconSet::Unicode); + assert_eq!(cli(&["--icons", "nerd", "a.md"]).icons, IconSet::Nerd); let config = Config { - icons: Some(IconsSet::Nerd), + icons: Some(IconSet::Nerd), ..Config::default() }; assert_eq!( - configured(&["--icons", "unicode", "a.md"], &config).icons(), + configured(&["--icons", "unicode", "a.md"], &config).icons, IconSet::Unicode ); } @@ -558,7 +504,7 @@ mod tests { #[test] fn icons_are_independent_of_the_colour_policy() { assert_eq!( - cli(&["--plain", "--icons", "nerd", "a.md"]).icons(), + cli(&["--plain", "--icons", "nerd", "a.md"]).icons, IconSet::Nerd ); } diff --git a/src/config.rs b/src/config.rs index 33b0d99..a4b5260 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,21 +12,23 @@ use std::path::PathBuf; use clap::ValueEnum; use serde::Deserialize; -use crate::cli::{ - ColorWhen, Flavor, HighlightWhen, HyperlinkWhen, IconsSet, ImagesWhen, MermaidMode, Theme, -}; +use crate::cli::{ColorWhen, HighlightWhen, HyperlinkWhen, ImagesWhen}; +use crate::diagram::MermaidMode; +use crate::flavor::Flavor; +use crate::layout::icons::IconSet; +use crate::layout::theme::ThemeChoice; /// Overrides an absent flag would otherwise take from its built-in default. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Config { pub flavor: Option, pub mermaid: Option, - pub theme: Option, + pub theme: Option, pub color: Option, pub hyperlinks: Option, pub highlight: Option, pub images: Option, - pub icons: Option, + pub icons: Option, pub width: Option, pub watch: Option, } @@ -186,12 +188,12 @@ mod tests { Config { flavor: Some(Flavor::Gfm), mermaid: Some(MermaidMode::Ascii), - theme: Some(Theme::Light), + theme: Some(ThemeChoice::Light), color: Some(ColorWhen::Always), hyperlinks: Some(HyperlinkWhen::Never), highlight: Some(HighlightWhen::Never), images: Some(ImagesWhen::Kitty), - icons: Some(IconsSet::Nerd), + icons: Some(IconSet::Nerd), width: Some(100), watch: None, } diff --git a/src/diagram/mermaid.rs b/src/diagram/mermaid.rs index a91b269..95e9d6c 100644 --- a/src/diagram/mermaid.rs +++ b/src/diagram/mermaid.rs @@ -7,7 +7,7 @@ use merman::ascii::{AsciiRenderOptions, HeadlessAsciiRenderer}; use merman::render::HeadlessRenderer; -use crate::cli::MermaidMode; +use crate::diagram::MermaidMode; use crate::markdown::model::DiagramPng; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/diagram/mod.rs b/src/diagram/mod.rs index f5309ee..269874a 100644 --- a/src/diagram/mod.rs +++ b/src/diagram/mod.rs @@ -12,11 +12,23 @@ mod raster; #[cfg(test)] mod tests; -use crate::cli::{Flavor, MermaidMode}; +use clap::ValueEnum; + use crate::diagnostic::Diagnostic; +use crate::flavor::Flavor; use crate::markdown::model::{Block, DiagramBlock, Document}; use mermaid::{DiagramRenderOptions, DiagramRenderer, MermanRenderer}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] +pub enum MermaidMode { + #[default] + Unicode, + Ascii, + Image, + Source, + Off, +} + pub fn resolve(document: &mut Document, mode: MermaidMode, flavor: Flavor) { let renderer = MermanRenderer; let mut diagnostics = Vec::new(); diff --git a/src/diagram/tests.rs b/src/diagram/tests.rs index 8df2a69..2dfc1dd 100644 --- a/src/diagram/tests.rs +++ b/src/diagram/tests.rs @@ -1,4 +1,5 @@ -use crate::cli::{Flavor, MermaidMode}; +use crate::diagram::MermaidMode; +use crate::flavor::Flavor; use crate::diagnostic::Severity; use crate::diagram::resolve; use crate::flavor; diff --git a/src/flavor/mod.rs b/src/flavor/mod.rs index 238766f..cd35d3f 100644 --- a/src/flavor/mod.rs +++ b/src/flavor/mod.rs @@ -4,10 +4,18 @@ pub mod gfm; #[cfg(test)] mod tests; -use crate::cli::Flavor; +use clap::ValueEnum; + use crate::markdown::model::Document; use crate::source::SourceText; +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Flavor { + Gfm, + #[value(name = "azure-devops")] + AzureDevops, +} + pub fn parse(source: SourceText, flavor: Flavor) -> Document { match flavor { Flavor::Gfm => gfm::parse(source), diff --git a/src/flavor/tests.rs b/src/flavor/tests.rs index cd745c7..0cdad1b 100644 --- a/src/flavor/tests.rs +++ b/src/flavor/tests.rs @@ -1,5 +1,4 @@ -use crate::cli::Flavor; -use crate::flavor::parse; +use crate::flavor::{Flavor, parse}; use crate::markdown::model::*; use crate::source::SourceText; diff --git a/src/input.rs b/src/input.rs index 7bd38e4..05c098e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,9 +1,15 @@ use std::io::Read; use std::path::{Path, PathBuf}; -use crate::cli::InputSource; use crate::error::{AppError, Result}; +/// Where the Markdown source comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputSource { + File(PathBuf), + Stdin, +} + /// UTF-8 Markdown together with the provenance the renderer needs for the /// status bar and for resolving relative link targets. #[derive(Debug, Clone)] diff --git a/src/layout/document.rs b/src/layout/document.rs index 2d4495f..e1ea8fc 100644 --- a/src/layout/document.rs +++ b/src/layout/document.rs @@ -6,7 +6,7 @@ use std::rc::Rc; -use crate::cli::MermaidMode; +use crate::diagram::MermaidMode; use crate::layout::highlight; use crate::layout::inline::{InlineContext, segments, spans}; use crate::layout::table::layout_table; @@ -671,7 +671,7 @@ fn expand_tabs(text: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::cli::Flavor; + use crate::flavor::Flavor; use crate::image::{CellSize, ImageSupport, Protocol}; use crate::layout::icons::IconSet; use crate::markdown::model::{AlertKind, DiagramPng}; diff --git a/src/layout/icons.rs b/src/layout/icons.rs index 315bbc0..f3af139 100644 --- a/src/layout/icons.rs +++ b/src/layout/icons.rs @@ -10,13 +10,18 @@ //! terminals that draw them two columns wide are off by one instead of by two, //! and the space keeps the glyph from touching the label. +use clap::ValueEnum; + use crate::markdown::model::AlertKind; /// Which glyphs the rendered surface uses. /// /// `Unicode` produces an empty prefix everywhere, so the default output is -/// byte-for-byte what it was before icons existed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +/// byte-for-byte what it was before icons existed. `nerd` is opt-in and never +/// detected: whether a Nerd Font is installed is a property of the terminal's +/// font, and asking the terminal would mean writing to the tty and waiting for +/// an answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] pub enum IconSet { #[default] Unicode, diff --git a/src/layout/inline.rs b/src/layout/inline.rs index d4083b8..8f0b4a9 100644 --- a/src/layout/inline.rs +++ b/src/layout/inline.rs @@ -20,7 +20,7 @@ pub struct InlineContext { /// How diagram blocks are presented. Diagrams are rendered before layout; /// this only selects between the rendered form, the source and an omitted /// marker. - pub mermaid: crate::cli::MermaidMode, + pub mermaid: crate::diagram::MermaidMode, /// Whether code blocks are split into syntax roles. Off for plain output, /// where every role would collapse to the same bytes anyway. pub highlight: bool, diff --git a/src/layout/theme.rs b/src/layout/theme.rs index f6502e5..e0baf7a 100644 --- a/src/layout/theme.rs +++ b/src/layout/theme.rs @@ -1,9 +1,18 @@ //! Backend-neutral styling for semantic roles. -use crate::cli::Theme as ThemeOption; +use clap::ValueEnum; + use crate::layout::{StyleRole, SyntaxKind}; use crate::markdown::model::AlertKind; +/// The theme the reader asked for; `Variant` is what it resolves to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ThemeChoice { + Auto, + Dark, + Light, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Color { Red, @@ -99,11 +108,11 @@ pub enum Variant { impl Variant { /// Theme `auto` never issues a blocking terminal query. It uses the /// conventional `COLORFGBG` hint when present and falls back to dark. - pub fn resolve(option: ThemeOption) -> Self { + pub fn resolve(option: ThemeChoice) -> Self { match option { - ThemeOption::Dark => Variant::Dark, - ThemeOption::Light => Variant::Light, - ThemeOption::Auto => Self::from_env().unwrap_or(Variant::Dark), + ThemeChoice::Dark => Variant::Dark, + ThemeChoice::Light => Variant::Light, + ThemeChoice::Auto => Self::from_env().unwrap_or(Variant::Dark), } } @@ -242,8 +251,8 @@ mod tests { #[test] fn explicit_theme_options_win() { - assert_eq!(Variant::resolve(ThemeOption::Dark), Variant::Dark); - assert_eq!(Variant::resolve(ThemeOption::Light), Variant::Light); + assert_eq!(Variant::resolve(ThemeChoice::Dark), Variant::Dark); + assert_eq!(Variant::resolve(ThemeChoice::Light), Variant::Light); } #[test] diff --git a/src/main.rs b/src/main.rs index 5d2b257..c858ad4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,7 +55,7 @@ fn run(cli: &Cli) -> Result<()> { base_dir: loaded.base_dir, highlight: cli.highlight(color), images: images.map(image::ImageSupport::detect), - icons: cli.icons(), + icons: cli.icons, }; match mode { cli::OutputMode::Pager => pager::run(pager::PagerInput { @@ -87,11 +87,11 @@ fn run(cli: &Cli) -> Result<()> { /// watched; the CLI already rejects that combination. fn watched( cli: &Cli, - source: &cli::InputSource, - mermaid: cli::MermaidMode, + source: &input::InputSource, + mermaid: diagram::MermaidMode, ) -> Option { match source { - cli::InputSource::File(path) if cli.watch => Some(pager::Watched { + input::InputSource::File(path) if cli.watch => Some(pager::Watched { path: path.clone(), flavor: cli.flavor, mermaid, @@ -100,10 +100,10 @@ fn watched( } } -fn flavor_label(flavor: cli::Flavor) -> &'static str { +fn flavor_label(flavor: flavor::Flavor) -> &'static str { match flavor { - cli::Flavor::Gfm => "gfm", - cli::Flavor::AzureDevops => "azure-devops", + flavor::Flavor::Gfm => "gfm", + flavor::Flavor::AzureDevops => "azure-devops", } } diff --git a/src/output/mod.rs b/src/output/mod.rs index 7bf4fa2..0303f44 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -6,11 +6,17 @@ pub mod plain; use std::io::Write; -use crate::cli::ColorChoice; use crate::error::{AppError, Result}; use crate::layout::theme::Theme; use crate::layout::{RenderedDocument, RenderedLine, RenderedSpan}; +/// Whether the stdout backend emits ANSI escapes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorChoice { + Ansi, + Plain, +} + pub fn write_document( out: &mut impl Write, document: &RenderedDocument, diff --git a/src/pager/app.rs b/src/pager/app.rs index f603152..00c9bd2 100644 --- a/src/pager/app.rs +++ b/src/pager/app.rs @@ -11,7 +11,8 @@ use crossterm::event::{self, Event}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use crate::cli::{Flavor, MermaidMode}; +use crate::diagram::MermaidMode; +use crate::flavor::Flavor; use crate::error::{AppError, Result}; use crate::input; use crate::layout::inline::InlineContext; @@ -41,7 +42,7 @@ pub struct Watched { impl Watched { fn reload(&self) -> Result { - let loaded = input::load(&crate::cli::InputSource::File(self.path.clone()))?; + let loaded = input::load(&input::InputSource::File(self.path.clone()))?; let mut document = crate::flavor::parse(SourceText::new(loaded.text), self.flavor); crate::diagram::resolve(&mut document, self.mermaid, self.flavor); Ok(document) From 1e542509d943506f446864322e13e3419b69a34a Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:20:32 +0900 Subject: [PATCH 3/8] refactor: share document building between startup and reload. --- .claude/rules/runtime.md | 2 +- src/document.rs | 15 +++++++++++++++ src/main.rs | 5 ++--- src/pager/app.rs | 9 ++------- 4 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 src/document.rs diff --git a/.claude/rules/runtime.md b/.claude/rules/runtime.md index 2e2ff28..85045ac 100644 --- a/.claude/rules/runtime.md +++ b/.claude/rules/runtime.md @@ -34,7 +34,7 @@ config は **既存フラグの既定値を上書きするだけ**。テーマ ## レイアウトの再実行 -ドキュメントは起動時に一度だけ parse し、layout はリサイズと reload のときだけ再実行する。frame ごとに layout や diagram 描画を走らせない。reload は起動時とまったく同じ経路(`input::load` → `flavor::parse` → `diagram::resolve` → `layout_document`)を通るので、再読込した文書は開き直した文書と区別できない。 +ドキュメントは起動時に一度だけ parse し、layout はリサイズと reload のときだけ再実行する。frame ごとに layout や diagram 描画を走らせない。reload は起動時とまったく同じ経路(`input::load` → `document::build` → `layout_document`)を通るので、再読込した文書は開き直した文書と区別できない。parse から diagram 描画までの手順は `document::build` にだけ書く。 ## File watch diff --git a/src/document.rs b/src/document.rs new file mode 100644 index 0000000..b5f06d1 --- /dev/null +++ b/src/document.rs @@ -0,0 +1,15 @@ +//! Markdown text to a document ready for layout. +//! +//! Startup and every `--watch` reload go through `build`, so a reloaded +//! document is indistinguishable from one opened fresh. + +use crate::diagram::{self, MermaidMode}; +use crate::flavor::{self, Flavor}; +use crate::markdown::model::Document; +use crate::source::SourceText; + +pub fn build(text: String, flavor: Flavor, mermaid: MermaidMode) -> Document { + let mut document = flavor::parse(SourceText::new(text), flavor); + diagram::resolve(&mut document, mermaid, flavor); + document +} diff --git a/src/main.rs b/src/main.rs index c858ad4..7f36f7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; mod diagnostic; mod diagram; +mod document; mod error; mod flavor; mod image; @@ -17,7 +18,6 @@ use error::{AppError, Result}; use layout::LayoutOptions; use layout::inline::InlineContext; use layout::theme::{Theme, Variant}; -use source::SourceText; /// Used when the terminal size is unavailable, such as when stdout is a pipe. const DEFAULT_WIDTH: usize = 80; @@ -45,8 +45,7 @@ fn run(cli: &Cli) -> Result<()> { let images = cli.images(ctx, color); let mermaid = cli.mermaid_mode(images); - let mut document = flavor::parse(SourceText::new(loaded.text), cli.flavor); - diagram::resolve(&mut document, mermaid, cli.flavor); + let document = document::build(loaded.text, cli.flavor, mermaid); let inline = InlineContext { mermaid, // The root depends on the path alone, so a reload under `--watch` keeps diff --git a/src/pager/app.rs b/src/pager/app.rs index 00c9bd2..d4c6b6e 100644 --- a/src/pager/app.rs +++ b/src/pager/app.rs @@ -26,14 +26,11 @@ use crate::pager::images::{self, Placed}; use crate::pager::state::{OutlineItem, PagerState, rendered_line_for_source, source_line_at}; use crate::pager::view::{ViewContext, draw, help_rows, widest_line}; use crate::pager::watch::Watch; -use crate::source::SourceText; /// How long a frame waits for input before looping again. const POLL: Duration = Duration::from_millis(250); -/// The file to follow, and how to parse it again. Reloading repeats exactly the -/// steps taken at startup, so a reloaded document is indistinguishable from one -/// opened fresh. +/// The file to follow, and how to parse it again. pub struct Watched { pub path: PathBuf, pub flavor: Flavor, @@ -43,9 +40,7 @@ pub struct Watched { impl Watched { fn reload(&self) -> Result { let loaded = input::load(&input::InputSource::File(self.path.clone()))?; - let mut document = crate::flavor::parse(SourceText::new(loaded.text), self.flavor); - crate::diagram::resolve(&mut document, self.mermaid, self.flavor); - Ok(document) + Ok(crate::document::build(loaded.text, self.flavor, self.mermaid)) } } From 3aa34cbd5b859184d5b714c24b1acfb0d001b2f0 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:20:55 +0900 Subject: [PATCH 4/8] refactor: restore the reading position through one path after resize and reload. --- src/pager/app.rs | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/src/pager/app.rs b/src/pager/app.rs index d4c6b6e..9b74822 100644 --- a/src/pager/app.rs +++ b/src/pager/app.rs @@ -164,7 +164,8 @@ fn event_loop(input: PagerInput) -> Result<()> { rendered = layout_document(&document, LayoutOptions::new(width), &inline); images::release(protocol, &mut sent)?; texts = line_texts(&rendered); - restore(&mut state, &rendered, &texts, &document, anchor); + let viewport = (state.height, state.width); + restore(&mut state, &rendered, &texts, &document, anchor, viewport); state.status = Some("reloaded".to_string()); } // A file being rewritten can be briefly missing or invalid. @@ -194,42 +195,29 @@ fn event_loop(input: PagerInput) -> Result<()> { // Every placement is new after a re-layout. images::release(protocol, &mut sent)?; texts = line_texts(&rendered); - state.resize( - rendered.lines.len(), - widest_line(&rendered.lines), - rows.saturating_sub(1) as usize, - columns as usize, - ); - state.set_outline(outline(&document, &rendered)); - state.initial_line = None; - if let Some(line) = anchor - && let Some(index) = rendered_line_for_source(&rendered.lines, line) - { - state.top = index.min(state.max_top()); - } - // After the viewport is back where it was, so the current match - // is chosen from the reader's place in the new layout. - state.recompute_searches(&texts); + let viewport = (rows.saturating_sub(1) as usize, columns as usize); + restore(&mut state, &rendered, &texts, &document, anchor, viewport); } _ => {} } } } -/// Put the reader back where they were after the surface was rebuilt at the -/// same size, keeping `anchor`'s source line in view. +/// Put the reader back where they were after the surface was rebuilt, keeping +/// `anchor`'s source line in view. `viewport` is the body's height and width. fn restore( state: &mut PagerState, rendered: &RenderedDocument, texts: &[String], document: &Document, anchor: Option, + (height, width): (usize, usize), ) { state.resize( rendered.lines.len(), widest_line(&rendered.lines), - state.height, - state.width, + height, + width, ); state.set_outline(outline(document, rendered)); state.initial_line = None; @@ -238,6 +226,8 @@ fn restore( { state.top = index.min(state.max_top()); } + // After the viewport is back where it was, so the current match is chosen + // from the reader's place in the new layout. state.recompute_searches(texts); } From 6751a015ae97c783b0fdf0eba021052c96389a24 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:21:32 +0900 Subject: [PATCH 5/8] refactor: detect URL schemes with one RFC 3986 check. --- src/image/mod.rs | 2 +- src/layout/inline.rs | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/image/mod.rs b/src/image/mod.rs index 39df61c..3e2534d 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -270,7 +270,7 @@ fn local_path(dest: &str) -> Option { /// `scheme:` per RFC 3986, requiring at least two characters so a Windows drive /// letter is not mistaken for one. -fn has_scheme(dest: &str) -> bool { +pub(crate) fn has_scheme(dest: &str) -> bool { let Some(colon) = dest.find(':') else { return false; }; diff --git a/src/layout/inline.rs b/src/layout/inline.rs index 8f0b4a9..b573864 100644 --- a/src/layout/inline.rs +++ b/src/layout/inline.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; +use crate::image::has_scheme; use crate::layout::icons::IconSet; use crate::layout::{RenderedSpan, StyleRole}; use crate::markdown::model::{Inline, plain_text}; @@ -228,19 +229,7 @@ fn display_target(dest: &str, ctx: &InlineContext) -> String { } fn is_relative_path(dest: &str) -> bool { - !dest.starts_with('#') - && !dest.starts_with('/') - && !dest.starts_with("mailto:") - && !has_scheme(dest) -} - -fn has_scheme(dest: &str) -> bool { - match dest.find("://") { - Some(index) => dest[..index] - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'), - None => false, - } + !dest.starts_with('#') && !dest.starts_with('/') && !has_scheme(dest) } fn is_image_path(dest: &str) -> bool { @@ -463,6 +452,21 @@ mod tests { assert!(rendered.contains("docs")); } + #[test] + fn a_target_with_any_scheme_is_shown_as_written() { + let c = InlineContext { + base_dir: Some(PathBuf::from("docs")), + ..Default::default() + }; + for dest in [ + "mailto:a@example.com", + "tel:+81-3-0000", + "vscode:extension/x", + ] { + assert_eq!(display_target(dest, &c), dest); + } + } + #[test] fn stdin_leaves_relative_targets_unresolved() { let inlines = vec![Inline::Link(LinkInline { From d68e524cf9d2aed9b489358de9b59b17f6294b05 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:22:21 +0900 Subject: [PATCH 6/8] refactor: drop hand-copied border, flavor name and line cast. --- src/cli.rs | 8 ++------ src/layout/document.rs | 14 +++++++------- src/main.rs | 12 ++++++------ src/pager/app.rs | 4 ++-- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 25b39ef..f4ded1a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,8 +42,8 @@ pub struct Cli { pub width: Option, /// Open near the given 1-based source line - #[arg(short = 'l', long, value_name = "LINE", value_parser = clap::value_parser!(u64).range(1..))] - pub line: Option, + #[arg(short = 'l', long, value_name = "LINE", value_parser = clap::builder::RangedU64ValueParser::::new().range(1..))] + pub line: Option, /// Markdown flavor #[arg(long, value_enum, default_value_t = Flavor::AzureDevops)] @@ -344,10 +344,6 @@ impl Cli { (mode, _) => mode, } } - - pub fn start_line(&self) -> Option { - self.line.map(|n| n as usize) - } } #[cfg(test)] diff --git a/src/layout/document.rs b/src/layout/document.rs index e1ea8fc..0218bc1 100644 --- a/src/layout/document.rs +++ b/src/layout/document.rs @@ -20,6 +20,8 @@ use crate::source::SourceRange; const TAB_STOP: usize = 4; +const BORDER: &str = "│ "; + pub fn layout_document( document: &Document, options: LayoutOptions, @@ -125,7 +127,6 @@ fn details( ctx: &InlineContext, out: &mut Vec, ) { - const BORDER: &str = "│ "; let marker = "▾ "; let marker_width = display_width(marker); let summary = spans(&d.summary, ctx); @@ -191,7 +192,7 @@ fn placeholder(p: &PlaceholderBlock, width: usize, out: &mut Vec) for text in source.lines() { out.push(RenderedLine { spans: vec![ - RenderedSpan::new("│ ", StyleRole::CodeBorder), + RenderedSpan::new(BORDER, StyleRole::CodeBorder), RenderedSpan::new(expand_tabs(text), StyleRole::Muted), ], source_range: Some(p.range), @@ -249,7 +250,7 @@ fn diagram(d: &DiagramBlock, width: usize, ctx: &InlineContext, out: &mut Vec, ) { - const BORDER: &str = "│ "; let role = match q.kind { Some(kind) => StyleRole::Alert(kind), None => StyleRole::Quote, @@ -579,7 +579,7 @@ fn code(c: &CodeBlock, ctx: &InlineContext, out: &mut Vec) { Some(lines) => lines[index].clone(), None => vec![RenderedSpan::new(text, StyleRole::Code)], }; - let mut spans = vec![RenderedSpan::new("│ ", StyleRole::CodeBorder)]; + let mut spans = vec![RenderedSpan::new(BORDER, StyleRole::CodeBorder)]; spans.extend(content); out.push(RenderedLine { spans, diff --git a/src/main.rs b/src/main.rs index 7f36f7f..24ca3fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,7 @@ fn run(cli: &Cli) -> Result<()> { title: loaded.display_name, flavor: flavor_label(cli.flavor), width_override: cli.width.map(usize::from), - start_line: cli.start_line(), + start_line: cli.line, watched: watched(cli, &source, mermaid), }), cli::OutputMode::Stdout => { @@ -99,11 +99,11 @@ fn watched( } } -fn flavor_label(flavor: flavor::Flavor) -> &'static str { - match flavor { - flavor::Flavor::Gfm => "gfm", - flavor::Flavor::AzureDevops => "azure-devops", - } +fn flavor_label(flavor: flavor::Flavor) -> String { + clap::ValueEnum::to_possible_value(&flavor) + .expect("every flavor has a name") + .get_name() + .to_string() } fn resolve_width(cli: &Cli, ctx: TerminalContext) -> usize { diff --git a/src/pager/app.rs b/src/pager/app.rs index 9b74822..1cea36e 100644 --- a/src/pager/app.rs +++ b/src/pager/app.rs @@ -49,7 +49,7 @@ pub struct PagerInput { pub inline: InlineContext, pub theme: Theme, pub title: String, - pub flavor: &'static str, + pub flavor: String, /// Overrides the terminal width when the reader passed `--width`. pub width_override: Option, pub start_line: Option, @@ -139,7 +139,7 @@ fn event_loop(input: PagerInput) -> Result<()> { let ctx = ViewContext { title: &title, - flavor, + flavor: &flavor, source_lines: document.source.line_count(), diagnostics: rendered.diagnostics.len(), theme, From cdd5f30ac1c88bbc5588bc29cb25265d9bbc6e56 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:22:59 +0900 Subject: [PATCH 7/8] refactor: lay out diagrams in their own module. --- src/layout/diagram.rs | 152 ++++++++++++++++++++++++++++++++++++++++ src/layout/document.rs | 153 +++-------------------------------------- src/layout/mod.rs | 1 + 3 files changed, 161 insertions(+), 145 deletions(-) create mode 100644 src/layout/diagram.rs diff --git a/src/layout/diagram.rs b/src/layout/diagram.rs new file mode 100644 index 0000000..c560b80 --- /dev/null +++ b/src/layout/diagram.rs @@ -0,0 +1,152 @@ +//! Diagram blocks to rendered lines: the rendered text, a picture, the source +//! or an error with the source underneath. + +use std::rc::Rc; + +use crate::diagram::MermaidMode; +use crate::layout::document::{BORDER, expand_tabs, line, reserve}; +use crate::layout::inline::InlineContext; +use crate::layout::wrap::{display_width, wrap_spans}; +use crate::layout::{RenderedLine, RenderedSpan, StyleRole}; +use crate::markdown::model::DiagramBlock; + +pub(super) fn diagram( + d: &DiagramBlock, + width: usize, + ctx: &InlineContext, + out: &mut Vec, +) { + if ctx.mermaid == MermaidMode::Off { + out.push(line( + vec![RenderedSpan::new( + format!("[{} diagram omitted]", d.language), + StyleRole::Muted, + )], + d.range, + )); + return; + } + + // Compatibility warnings sit directly above the diagram they describe. + for warning in &d.warnings { + for spans in wrap_spans( + &[RenderedSpan::new( + format!("! {warning}"), + StyleRole::Warning, + )], + width, + ) { + out.push(line(spans, d.range)); + } + } + + if let Some(message) = &d.error { + error_fallback(d, message, width, out); + return; + } + + if let Some(placement) = drawable_diagram(d, width, ctx) { + reserve(&placement, d.range, out); + return; + } + + let (header, body): (String, Vec) = match &d.rendered { + Some(lines) => (format!("╭─ {}", d.language), lines.clone()), + None => ( + format!("╭─ {} (source)", d.language), + d.source.lines().map(str::to_string).collect(), + ), + }; + + out.push(preformatted(header, StyleRole::DiagramBorder, d, true)); + for text in body { + out.push(RenderedLine { + spans: vec![ + RenderedSpan::new(BORDER, StyleRole::DiagramBorder), + // Diagram lines keep their leading whitespace and are never + // re-wrapped; the pager scrolls horizontally instead. + RenderedSpan::new(expand_tabs(&text), StyleRole::Diagram), + ], + source_range: Some(d.range), + no_wrap: true, + image: None, + }); + } + out.push(preformatted( + "╰─".to_string(), + StyleRole::DiagramBorder, + d, + true, + )); +} + +fn drawable_diagram( + d: &DiagramBlock, + width: usize, + ctx: &InlineContext, +) -> Option> { + let png = d.png.as_ref()?; + crate::image::from_png(png.bytes.clone(), ctx.images?, width, png.css_width) +} + +/// Show a short error and the original source, so a broken diagram never hides +/// what the author wrote. +fn error_fallback(d: &DiagramBlock, message: &str, width: usize, out: &mut Vec) { + let (title, role) = if d.unsupported { + ("Mermaid diagram not supported as text", StyleRole::Warning) + } else { + ("Mermaid render error", StyleRole::Error) + }; + let dashes = width.saturating_sub(display_width(title) + 4).max(2); + out.push(preformatted( + format!("╭─ {title} {}", "─".repeat(dashes)), + role, + d, + true, + )); + for spans in wrap_spans( + &[RenderedSpan::new(message, role)], + width.saturating_sub(2).max(1), + ) { + let mut all = vec![RenderedSpan::new(BORDER, role)]; + all.extend(spans); + out.push(RenderedLine { + spans: all, + source_range: Some(d.range), + no_wrap: false, + image: None, + }); + } + out.push(preformatted( + format!("├{}", "─".repeat(width.saturating_sub(1).max(2))), + role, + d, + true, + )); + for text in d.source.lines() { + out.push(RenderedLine { + spans: vec![ + RenderedSpan::new(BORDER, role), + RenderedSpan::new(expand_tabs(text), StyleRole::Diagram), + ], + source_range: Some(d.range), + no_wrap: true, + image: None, + }); + } + out.push(preformatted( + format!("╰{}", "─".repeat(width.saturating_sub(1).max(2))), + role, + d, + true, + )); +} + +fn preformatted(text: String, role: StyleRole, d: &DiagramBlock, no_wrap: bool) -> RenderedLine { + RenderedLine { + spans: vec![RenderedSpan::new(text, role)], + source_range: Some(d.range), + no_wrap, + image: None, + } +} diff --git a/src/layout/document.rs b/src/layout/document.rs index 0218bc1..ef39a74 100644 --- a/src/layout/document.rs +++ b/src/layout/document.rs @@ -6,21 +6,20 @@ use std::rc::Rc; -use crate::diagram::MermaidMode; -use crate::layout::highlight; use crate::layout::inline::{InlineContext, segments, spans}; use crate::layout::table::layout_table; use crate::layout::wrap::{display_width, wrap_spans}; use crate::layout::{LayoutOptions, RenderedDocument, RenderedLine, RenderedSpan, StyleRole}; +use crate::layout::{diagram, highlight}; use crate::markdown::model::{ - Block, CodeBlock, DetailsBlock, DiagramBlock, Document, FootnoteBlock, HeadingBlock, Inline, - ListBlock, ParagraphBlock, PlaceholderBlock, QuoteBlock, RawHtmlBlock, TocBlock, + Block, CodeBlock, DetailsBlock, Document, FootnoteBlock, HeadingBlock, Inline, ListBlock, + ParagraphBlock, PlaceholderBlock, QuoteBlock, RawHtmlBlock, TocBlock, }; use crate::source::SourceRange; const TAB_STOP: usize = 4; -const BORDER: &str = "│ "; +pub(super) const BORDER: &str = "│ "; pub fn layout_document( document: &Document, @@ -81,7 +80,7 @@ fn layout_block( Block::Toc(t) => toc(t, width, out), Block::Details(d) => details(d, width, depth, ctx, out), Block::Placeholder(p) => placeholder(p, width, out), - Block::Diagram(d) => diagram(d, width, ctx, out), + Block::Diagram(d) => diagram::diagram(d, width, ctx, out), } } @@ -203,142 +202,6 @@ fn placeholder(p: &PlaceholderBlock, width: usize, out: &mut Vec) } } -fn diagram(d: &DiagramBlock, width: usize, ctx: &InlineContext, out: &mut Vec) { - if ctx.mermaid == MermaidMode::Off { - out.push(line( - vec![RenderedSpan::new( - format!("[{} diagram omitted]", d.language), - StyleRole::Muted, - )], - d.range, - )); - return; - } - - // Compatibility warnings sit directly above the diagram they describe. - for warning in &d.warnings { - for spans in wrap_spans( - &[RenderedSpan::new( - format!("! {warning}"), - StyleRole::Warning, - )], - width, - ) { - out.push(line(spans, d.range)); - } - } - - if let Some(message) = &d.error { - error_fallback(d, message, width, out); - return; - } - - if let Some(placement) = drawable_diagram(d, width, ctx) { - reserve(&placement, d.range, out); - return; - } - - let (header, body): (String, Vec) = match &d.rendered { - Some(lines) => (format!("╭─ {}", d.language), lines.clone()), - None => ( - format!("╭─ {} (source)", d.language), - d.source.lines().map(str::to_string).collect(), - ), - }; - - out.push(preformatted(header, StyleRole::DiagramBorder, d, true)); - for text in body { - out.push(RenderedLine { - spans: vec![ - RenderedSpan::new(BORDER, StyleRole::DiagramBorder), - // Diagram lines keep their leading whitespace and are never - // re-wrapped; the pager scrolls horizontally instead. - RenderedSpan::new(expand_tabs(&text), StyleRole::Diagram), - ], - source_range: Some(d.range), - no_wrap: true, - image: None, - }); - } - out.push(preformatted( - "╰─".to_string(), - StyleRole::DiagramBorder, - d, - true, - )); -} - -fn drawable_diagram( - d: &DiagramBlock, - width: usize, - ctx: &InlineContext, -) -> Option> { - let png = d.png.as_ref()?; - crate::image::from_png(png.bytes.clone(), ctx.images?, width, png.css_width) -} - -/// Show a short error and the original source, so a broken diagram never hides -/// what the author wrote. -fn error_fallback(d: &DiagramBlock, message: &str, width: usize, out: &mut Vec) { - let (title, role) = if d.unsupported { - ("Mermaid diagram not supported as text", StyleRole::Warning) - } else { - ("Mermaid render error", StyleRole::Error) - }; - let dashes = width.saturating_sub(display_width(title) + 4).max(2); - out.push(preformatted( - format!("╭─ {title} {}", "─".repeat(dashes)), - role, - d, - true, - )); - for spans in wrap_spans( - &[RenderedSpan::new(message, role)], - width.saturating_sub(2).max(1), - ) { - let mut all = vec![RenderedSpan::new(BORDER, role)]; - all.extend(spans); - out.push(RenderedLine { - spans: all, - source_range: Some(d.range), - no_wrap: false, - image: None, - }); - } - out.push(preformatted( - format!("├{}", "─".repeat(width.saturating_sub(1).max(2))), - role, - d, - true, - )); - for text in d.source.lines() { - out.push(RenderedLine { - spans: vec![ - RenderedSpan::new(BORDER, role), - RenderedSpan::new(expand_tabs(text), StyleRole::Diagram), - ], - source_range: Some(d.range), - no_wrap: true, - image: None, - }); - } - out.push(preformatted( - format!("╰{}", "─".repeat(width.saturating_sub(1).max(2))), - role, - d, - true, - )); -} - -fn preformatted(text: String, role: StyleRole, d: &DiagramBlock, no_wrap: bool) -> RenderedLine { - RenderedLine { - spans: vec![RenderedSpan::new(text, role)], - source_range: Some(d.range), - no_wrap, - image: None, - } -} - fn heading(h: &HeadingBlock, width: usize, ctx: &InlineContext, out: &mut Vec) { let level = h.level.min(6); let marker = format!("{} ", "#".repeat(level as usize)); @@ -387,7 +250,7 @@ fn paragraph(p: &ParagraphBlock, width: usize, ctx: &InlineContext, out: &mut Ve /// The image covers cells the backend paints over; the reserved lines stay /// blank and carry the block's range so `--line` and `--watch` still find their /// way back to the source. -fn reserve( +pub(super) fn reserve( placement: &Rc, range: SourceRange, out: &mut Vec, @@ -641,7 +504,7 @@ fn raw_html(h: &RawHtmlBlock, out: &mut Vec) { } } -fn line(spans: Vec, range: SourceRange) -> RenderedLine { +pub(super) fn line(spans: Vec, range: SourceRange) -> RenderedLine { RenderedLine { spans, source_range: Some(range), @@ -652,7 +515,7 @@ fn line(spans: Vec, range: SourceRange) -> RenderedLine { /// Expand tabs to fixed tab stops so code indentation survives in a terminal /// that would otherwise apply its own tab handling. -fn expand_tabs(text: &str) -> String { +pub(super) fn expand_tabs(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut column = 0usize; for ch in text.chars() { diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 889df04..31eed92 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1,6 +1,7 @@ //! Terminal layout: document IR to a rendered surface carrying semantic style //! roles. No ratatui or ANSI types appear here; the output backends map roles. +mod diagram; pub mod document; pub mod highlight; pub mod icons; From abbb5f21ce7eddf6b2e3d01767bbe49245760fd3 Mon Sep 17 00:00:00 2001 From: halkn Date: Thu, 1 Oct 2026 18:22:59 +0900 Subject: [PATCH 8/8] style: format moved imports. --- src/diagram/tests.rs | 4 ++-- src/pager/app.rs | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/diagram/tests.rs b/src/diagram/tests.rs index 2dfc1dd..101f946 100644 --- a/src/diagram/tests.rs +++ b/src/diagram/tests.rs @@ -1,8 +1,8 @@ -use crate::diagram::MermaidMode; -use crate::flavor::Flavor; use crate::diagnostic::Severity; +use crate::diagram::MermaidMode; use crate::diagram::resolve; use crate::flavor; +use crate::flavor::Flavor; use crate::markdown::model::{Block, DiagramBlock, Document}; use crate::source::SourceText; diff --git a/src/pager/app.rs b/src/pager/app.rs index 1cea36e..0020b34 100644 --- a/src/pager/app.rs +++ b/src/pager/app.rs @@ -12,8 +12,8 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use crate::diagram::MermaidMode; -use crate::flavor::Flavor; use crate::error::{AppError, Result}; +use crate::flavor::Flavor; use crate::input; use crate::layout::inline::InlineContext; use crate::layout::theme::Theme; @@ -40,7 +40,11 @@ pub struct Watched { impl Watched { fn reload(&self) -> Result { let loaded = input::load(&input::InputSource::File(self.path.clone()))?; - Ok(crate::document::build(loaded.text, self.flavor, self.mermaid)) + Ok(crate::document::build( + loaded.text, + self.flavor, + self.mermaid, + )) } }