use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use rich_rs::{Console, ConsoleOptions, Segments};
use crate::action::{ActionDecl, ParsedAction};
use crate::compose::ComposeResult;
use crate::event::{Event, EventCtx};
use crate::message::{
MarkdownTableOfContentsSelected, MarkdownTableOfContentsUpdated, Message, MessageEvent,
NavigatorUpdated, ScrollbarAxis, ScrollbarScrollTo, TreeNodeActivated,
};
use super::containers::VerticalScroll;
use super::delegate::{delegate_renderable, delegate_widget_method};
use super::markdown_model::parse_markdown_headings_with_lines;
use super::{Markdown, Tree, TreeNode, Widget, WidgetStyles};
type HeadingEntry = (usize, String, String);
const MARKDOWN_VIEWER_ACTIONS: &[ActionDecl] = &[ActionDecl {
name: "link",
namespace: "markdown_viewer",
description: "Follow a markdown link",
default_binding: None,
}];
pub struct MarkdownTableOfContents {
shared_headings: Arc<RwLock<Vec<HeadingEntry>>>,
styles: WidgetStyles,
}
impl MarkdownTableOfContents {
pub fn new(headings: Vec<HeadingEntry>) -> Self {
Self {
shared_headings: Arc::new(RwLock::new(headings)),
styles: WidgetStyles::default(),
}
}
pub fn with_shared_headings(shared: Arc<RwLock<Vec<HeadingEntry>>>) -> Self {
Self {
shared_headings: shared,
styles: WidgetStyles::default(),
}
}
pub fn set_headings(&mut self, headings: Vec<HeadingEntry>) {
if let Ok(mut data) = self.shared_headings.write() {
*data = headings;
}
}
fn build_tree_from_headings(headings: &[HeadingEntry]) -> Tree {
if headings.is_empty() {
let mut tree = Tree::new(vec![TreeNode::new("Contents")]);
tree.set_show_root_plain(false);
return tree;
}
let root = TreeNode::new("Contents").expanded(true).allow_expand(true);
let nodes = build_heading_nodes(headings);
let mut root = root;
for node in nodes {
root = root.with_child(node);
}
let mut tree = Tree::new(vec![root]);
tree.set_show_root_plain(false);
tree.set_auto_expand(false);
tree
}
}
struct MarkdownTableOfContentsTree {
headings: Vec<HeadingEntry>,
shared_headings: Arc<RwLock<Vec<HeadingEntry>>>,
tree: Tree,
}
impl MarkdownTableOfContentsTree {
fn with_shared_headings(shared: Arc<RwLock<Vec<HeadingEntry>>>) -> Self {
let initial = shared.read().map(|h| h.clone()).unwrap_or_default();
let tree = MarkdownTableOfContents::build_tree_from_headings(&initial);
Self {
headings: initial,
shared_headings: shared,
tree,
}
}
fn sync_headings(&mut self) {
let next = self.shared_headings.read().ok().map(|h| h.clone());
if let Some(headings) = next
&& headings != self.headings
{
self.headings = headings;
self.tree = MarkdownTableOfContents::build_tree_from_headings(&self.headings);
}
}
}
impl Widget for MarkdownTableOfContentsTree {
fn style_type(&self) -> &'static str {
"Tree"
}
fn content_width(&self) -> Option<usize> {
None
}
fn on_layout(&mut self, width: u16, height: u16) {
self.sync_headings();
self.tree.on_layout(width, height);
}
delegate_widget_method!(
tree,
[
render,
render_with_debug,
render_line,
render_lines,
compose,
take_composed_children,
focusable,
can_focus,
can_focus_children,
set_focus,
has_focus,
on_mount,
on_unmount,
on_tick,
on_resize,
set_virtual_content_size,
on_event_capture,
on_event,
on_message,
on_mouse_scroll,
on_mouse_move,
on_app_key,
on_app_action,
on_app_message,
on_app_tick,
on_app_mount,
scroll_offset,
scroll_offset_f32,
scroll_viewport_size,
scroll_virtual_content_size,
clips_descendants_to_content,
child_display_for_tree,
tree_child_content_inset,
layout_height,
layout_constraints,
preserve_underlay,
bindings,
binding_hints,
execute_action,
action_namespace,
action_registry,
styles,
styles_mut,
style_type_aliases,
style_id,
style_classes,
set_style_id,
border_title,
border_subtitle,
is_disabled,
set_disabled_state,
is_loading,
set_loading_state,
is_hovered,
set_hovered,
is_active,
mouse_interactive,
tooltip,
tooltip_anchor,
help_markup,
allow_select,
selection_at,
selection_word_range_at,
selection_all_range,
update_selection,
clear_selection,
get_selection,
selection_updated,
reactive_widget,
]
);
}
delegate_renderable!(MarkdownTableOfContentsTree);
impl Widget for MarkdownTableOfContents {
fn style_type(&self) -> &'static str {
"MarkdownTableOfContents"
}
fn compose(&self) -> ComposeResult {
vec![MarkdownTableOfContentsTree::with_shared_headings(self.shared_headings.clone()).into()]
}
fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
Segments::new()
}
fn layout_height(&self) -> Option<usize> {
None
}
fn content_width(&self) -> Option<usize> {
let headings = self.shared_headings.read().ok().map(|h| h.clone())?;
let tree = MarkdownTableOfContents::build_tree_from_headings(&headings);
let base = tree.content_width().unwrap_or(1);
let toc_meta = crate::css::selector_meta_generic(self);
let toc_resolved = crate::css::resolve_style(self, &toc_meta);
let tree_meta = crate::css::selector_meta_generic(&tree);
let tree_resolved = crate::css::with_style_stack(toc_meta, toc_resolved, || {
crate::css::resolve_style(&tree, &tree_meta)
});
let padding = tree_resolved.effective_padding();
Some(
base.saturating_add(usize::from(padding.left))
.saturating_add(usize::from(padding.right))
.max(1),
)
}
fn can_focus_children(&self) -> bool {
true
}
fn styles(&self) -> Option<&WidgetStyles> {
Some(&self.styles)
}
fn styles_mut(&mut self) -> Option<&mut WidgetStyles> {
Some(&mut self.styles)
}
fn on_message(&mut self, message: &MessageEvent, ctx: &mut EventCtx) {
if let Message::MarkdownTableOfContentsUpdated(MarkdownTableOfContentsUpdated {
headings,
}) = &message.message
{
if let Ok(mut shared) = self.shared_headings.write() {
*shared = headings.clone();
}
ctx.request_layout_invalidation();
ctx.request_repaint();
return;
}
if let Message::TreeNodeActivated(TreeNodeActivated {
data: Some(block_id),
..
}) = &message.message
{
ctx.post_message(Message::MarkdownTableOfContentsSelected(
MarkdownTableOfContentsSelected {
block_id: block_id.clone(),
},
));
ctx.set_handled();
}
}
}
const NUMERALS: [char; 7] = [' ', 'Ⅰ', 'Ⅱ', 'Ⅲ', 'Ⅳ', 'Ⅴ', 'Ⅵ'];
fn build_heading_nodes(headings: &[HeadingEntry]) -> Vec<TreeNode> {
struct TocNode {
label: String,
level: usize,
block_id: String,
children: Vec<TocNode>,
}
let mut roots: Vec<TocNode> = Vec::new();
for (level, title, block_id) in headings {
let depth = level.saturating_sub(1); let new_node = TocNode {
label: title.clone(),
level: *level,
block_id: block_id.clone(),
children: Vec::new(),
};
let mut target = &mut roots;
for _ in 0..depth {
if target.is_empty() {
break;
}
let last = target.last_mut().unwrap();
target = &mut last.children;
}
target.push(new_node);
}
fn to_tree_node(toc: &TocNode) -> TreeNode {
let has_children = !toc.children.is_empty();
let numeral = NUMERALS.get(toc.level).copied().unwrap_or(' ');
let prefixed_label = format!("{} {}", numeral, toc.label);
let mut node = TreeNode::new(prefixed_label)
.expanded(has_children)
.allow_expand(has_children)
.with_data(toc.block_id.clone());
for child in &toc.children {
node = node.with_child(to_tree_node(child));
}
node
}
roots.iter().map(to_tree_node).collect()
}
pub struct Navigator {
history: Vec<String>,
cursor: usize,
}
impl Navigator {
fn new() -> Self {
Self {
history: Vec::new(),
cursor: 0,
}
}
pub fn go(&mut self, location: impl Into<String>) -> bool {
let location = location.into();
self.history
.truncate(self.cursor + if self.history.is_empty() { 0 } else { 1 });
self.history.push(location);
self.cursor = self.history.len() - 1;
true
}
pub fn back(&mut self) -> Option<&str> {
if self.cursor > 0 {
self.cursor -= 1;
Some(&self.history[self.cursor])
} else {
None
}
}
pub fn forward(&mut self) -> Option<&str> {
if self.cursor + 1 < self.history.len() {
self.cursor += 1;
Some(&self.history[self.cursor])
} else {
None
}
}
pub fn at_start(&self) -> bool {
self.cursor == 0
}
pub fn at_end(&self) -> bool {
self.history.is_empty() || self.cursor >= self.history.len() - 1
}
pub fn location(&self) -> Option<&str> {
self.history.get(self.cursor).map(String::as_str)
}
}
pub struct MarkdownViewer {
inner: VerticalScroll,
shared_markup: Arc<RwLock<String>>,
shared_headings: Arc<RwLock<Vec<HeadingEntry>>>,
content: String,
classes: Vec<String>,
pub navigator: Navigator,
content_map: HashMap<String, String>,
toc_dirty: bool,
}
impl MarkdownViewer {
pub fn new(content: impl Into<String>) -> Self {
let content = content.into();
let navigator = Navigator::new();
let content_map = HashMap::new();
let shared_markup = Arc::new(RwLock::new(content.clone()));
let shared_headings = Arc::new(RwLock::new(Self::parse_headings(&content)));
let inner = VerticalScroll::new()
.scroll_step(2)
.with_child(Markdown::with_shared_markup(shared_markup.clone()).with_can_focus(true))
.with_child(MarkdownTableOfContents::with_shared_headings(
shared_headings.clone(),
));
Self {
inner,
shared_markup,
shared_headings,
content,
classes: vec!["-show-table-of-contents".to_string()],
navigator,
content_map,
toc_dirty: true,
}
}
pub fn register_content(&mut self, path: impl Into<String>, content: impl Into<String>) {
self.content_map.insert(path.into(), content.into());
}
pub fn show_table_of_contents(mut self, show: bool) -> Self {
self.set_show_table_of_contents(show);
self
}
pub fn set_show_table_of_contents(&mut self, show: bool) {
const CLASS: &str = "-show-table-of-contents";
if show {
if !self.classes.iter().any(|c| c == CLASS) {
self.classes.push(CLASS.to_string());
}
} else {
self.classes.retain(|c| c != CLASS);
}
}
pub fn is_showing_table_of_contents(&self) -> bool {
self.classes.iter().any(|c| c == "-show-table-of-contents")
}
pub fn set_content(&mut self, content: impl Into<String>) {
self.apply_content_update(content.into());
}
pub fn go(&mut self, path: impl Into<String>) -> bool {
let path = path.into();
if let Some(content) = self.content_map.get(&path).cloned() {
self.navigator.go(&path);
self.apply_content_update(content);
true
} else {
false
}
}
pub fn back(&mut self) -> bool {
if let Some(location) = self.navigator.back() {
if let Some(content) = self.content_map.get(location).cloned() {
self.apply_content_update(content);
return true;
}
}
false
}
pub fn forward(&mut self) -> bool {
if let Some(location) = self.navigator.forward() {
if let Some(content) = self.content_map.get(location).cloned() {
self.apply_content_update(content);
return true;
}
}
false
}
fn follow_link(&mut self, href: &str) -> bool {
let href = href.trim();
let path_part = href.split('#').next().unwrap_or_default().trim();
if path_part.is_empty() {
return false;
}
let mut candidates = vec![path_part.to_string()];
if let Some(stripped) = path_part.strip_prefix("./") {
candidates.push(stripped.to_string());
}
for candidate in candidates {
if self.content_map.contains_key(&candidate) && self.go(candidate) {
return true;
}
}
false
}
pub fn extract_headings(&self) -> Vec<(usize, String)> {
Self::parse_headings(&self.content)
.into_iter()
.map(|(level, title, _)| (level, title))
.collect()
}
fn apply_content_update(&mut self, content: String) {
self.content = content;
if let Ok(mut shared) = self.shared_markup.write() {
*shared = self.content.clone();
}
let headings = Self::parse_headings(&self.content);
if let Ok(mut shared_headings) = self.shared_headings.write() {
*shared_headings = headings;
}
self.toc_dirty = true;
}
fn flush_toc_message(&mut self, ctx: &mut EventCtx) {
if !self.toc_dirty {
return;
}
let headings = self
.shared_headings
.read()
.ok()
.map(|h| h.clone())
.unwrap_or_default();
ctx.post_message(Message::MarkdownTableOfContentsUpdated(
MarkdownTableOfContentsUpdated { headings },
));
self.toc_dirty = false;
}
fn heading_line_offset(&self, block_id: &str) -> usize {
let viewport_width = self
.inner
.scroll_viewport_size()
.map(|(w, _)| w)
.unwrap_or(80)
.max(1);
let toc_width = if self.is_showing_table_of_contents() {
MarkdownTableOfContents::with_shared_headings(self.shared_headings.clone())
.content_width()
.unwrap_or(0)
} else {
0
};
let markdown_width = viewport_width.saturating_sub(toc_width).max(1);
let content_width = markdown_width.saturating_sub(4).max(1);
let headings = Self::parse_heading_lines(&self.content);
let mut by_source_line: HashMap<usize, (usize, String)> = HashMap::new();
for (level, _title, id, source_line) in &headings {
by_source_line.insert(*source_line, (*level, id.clone()));
}
let mut visual_row = 0usize;
for (source_line, line) in self.content.lines().enumerate() {
let wraps = rich_rs::cell_len(line).div_ceil(content_width).max(1);
if let Some((level, id)) = by_source_line.get(&source_line) {
let (top, bottom) = heading_margins(*level);
if id == block_id {
return visual_row.saturating_sub(1 + top);
}
visual_row = visual_row
.saturating_add(top)
.saturating_add(wraps)
.saturating_add(bottom);
} else {
visual_row = visual_row.saturating_add(wraps);
}
}
0
}
fn parse_headings(content: &str) -> Vec<HeadingEntry> {
parse_markdown_heading_lines(content)
.into_iter()
.map(|(level, title, block_id, _)| (level, title, block_id))
.collect()
}
fn parse_heading_lines(content: &str) -> Vec<(usize, String, String, usize)> {
parse_markdown_heading_lines(content)
}
}
fn slugify_heading(title: &str) -> String {
let mut slug = String::new();
let mut prev_dash = false;
for ch in title.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if ch == '_' || ch == '-' {
if !prev_dash && !slug.is_empty() {
slug.push(ch);
prev_dash = true;
}
} else if ch.is_ascii_whitespace() && !prev_dash && !slug.is_empty() {
slug.push('-');
prev_dash = true;
}
}
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() {
"section".to_string()
} else {
slug
}
}
fn heading_margins(level: usize) -> (usize, usize) {
if level <= 2 { (2, 1) } else { (1, 1) }
}
pub(crate) fn parse_markdown_heading_lines(content: &str) -> Vec<(usize, String, String, usize)> {
let mut out = Vec::new();
let mut slug_counts: HashMap<String, usize> = HashMap::new();
for (marker_len, title, line_idx) in parse_markdown_headings_with_lines(content) {
let base = slugify_heading(&title);
let seen = slug_counts.entry(base.clone()).or_insert(0);
let block_id = if *seen == 0 {
base
} else {
format!("{base}-{}", *seen)
};
*seen += 1;
out.push((marker_len, title, block_id, line_idx));
}
out
}
impl Widget for MarkdownViewer {
fn style_type(&self) -> &'static str {
"MarkdownViewer"
}
fn style_classes(&self) -> &[String] {
&self.classes
}
fn focusable(&self) -> bool {
false
}
fn can_focus(&self) -> bool {
false
}
fn can_focus_children(&self) -> bool {
true
}
fn on_event_capture(&mut self, event: &Event, ctx: &mut EventCtx) {
self.flush_toc_message(ctx);
self.inner.on_event_capture(event, ctx);
}
fn on_event(&mut self, event: &Event, ctx: &mut EventCtx) {
self.flush_toc_message(ctx);
self.inner.on_event(event, ctx);
}
fn on_message(&mut self, message: &MessageEvent, ctx: &mut EventCtx) {
self.flush_toc_message(ctx);
if let Message::MarkdownTableOfContentsUpdated(MarkdownTableOfContentsUpdated {
headings,
}) = &message.message
{
if let Ok(mut shared_headings) = self.shared_headings.write() {
*shared_headings = headings.clone();
}
ctx.request_layout_invalidation();
ctx.request_repaint();
ctx.set_handled();
return;
}
if let Message::MarkdownTableOfContentsSelected(MarkdownTableOfContentsSelected {
block_id,
}) = &message.message
{
let target_line = self.heading_line_offset(block_id);
let scroll_duration = Some(Duration::from_millis(200));
ctx.post_message(Message::ScrollbarScrollTo(ScrollbarScrollTo {
axis: ScrollbarAxis::Vertical,
offset: target_line as f32,
animate: true,
scroll_duration,
}));
ctx.set_handled();
return;
}
self.inner.on_message(message, ctx);
}
fn action_namespace(&self) -> &str {
"markdown_viewer"
}
fn action_registry(&self) -> &[ActionDecl] {
MARKDOWN_VIEWER_ACTIONS
}
fn execute_action(&mut self, action: &ParsedAction, ctx: &mut EventCtx) -> bool {
if action.name == "link"
&& let Some(href) = action.arguments.first()
&& self.follow_link(href)
{
ctx.post_message(Message::NavigatorUpdated(NavigatorUpdated));
ctx.request_layout_invalidation();
ctx.request_repaint();
return true;
}
self.inner.execute_action(action, ctx)
}
delegate_widget_method!(
inner,
[
render,
render_with_debug,
render_line,
render_lines,
compose,
take_composed_children,
set_focus,
has_focus,
on_mount,
on_unmount,
on_tick,
on_resize,
on_layout,
set_virtual_content_size,
on_mouse_scroll,
on_mouse_move,
on_app_key,
on_app_action,
on_app_message,
on_app_tick,
on_app_mount,
scroll_offset,
scroll_offset_f32,
scroll_viewport_size,
scroll_virtual_content_size,
clips_descendants_to_content,
child_display_for_tree,
tree_child_content_inset,
layout_height,
content_width,
layout_constraints,
preserve_underlay,
bindings,
binding_hints,
styles,
styles_mut,
style_type_aliases,
style_id,
set_style_id,
border_title,
border_subtitle,
is_disabled,
set_disabled_state,
is_loading,
set_loading_state,
is_hovered,
set_hovered,
is_active,
mouse_interactive,
tooltip,
tooltip_anchor,
help_markup,
allow_select,
selection_at,
selection_word_range_at,
selection_all_range,
update_selection,
clear_selection,
get_selection,
selection_updated,
reactive_widget,
]
);
}
delegate_renderable!(MarkdownViewer);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_viewer_default_shows_toc() {
let viewer = MarkdownViewer::new("# Heading");
assert!(viewer.is_showing_table_of_contents());
}
#[test]
fn markdown_viewer_hide_toc() {
let viewer = MarkdownViewer::new("# Heading").show_table_of_contents(false);
assert!(!viewer.is_showing_table_of_contents());
}
#[test]
fn markdown_viewer_extracts_headings() {
let viewer = MarkdownViewer::new("# H1\n## H2\n### H3");
let h = viewer.extract_headings();
assert_eq!(h.len(), 3);
assert_eq!(h[0], (1, "H1".to_string()));
assert_eq!(h[1], (2, "H2".to_string()));
assert_eq!(h[2], (3, "H3".to_string()));
}
#[test]
fn markdown_viewer_is_scroll_host() {
let viewer = MarkdownViewer::new("# Test");
assert_eq!(viewer.scroll_offset(), (0, 0));
assert!(viewer.clips_descendants_to_content());
}
#[test]
fn markdown_viewer_children_include_scrollbars() {
let mut viewer = MarkdownViewer::new("# Test");
let children = viewer.take_composed_children();
assert!(
children.len() >= 2,
"expected at least 2 children (Markdown + TOC), got {}",
children.len()
);
let has_scrollbar = children.iter().any(|c| {
let st = c.style_type();
st.contains("Scrollbar") || st.contains("ScrollBar")
});
assert!(
has_scrollbar || children.len() >= 3,
"expected scrollbar widgets in children"
);
}
#[test]
fn markdown_viewer_composes_focusable_markdown_document_child() {
let mut viewer = MarkdownViewer::new("# Test");
let children = viewer.take_composed_children();
let markdown_child = children
.iter()
.find(|child| child.style_type() == "Markdown")
.expect("expected Markdown child in MarkdownViewer composition");
assert!(
markdown_child.focusable(),
"MarkdownViewer should compose a focusable Markdown child (Python parity)"
);
}
#[test]
fn markdown_viewer_style_type() {
let viewer = MarkdownViewer::new("# Test");
assert_eq!(viewer.style_type(), "MarkdownViewer");
}
#[test]
fn markdown_viewer_style_classes_include_toc_class() {
let viewer = MarkdownViewer::new("# Test");
assert!(
viewer
.style_classes()
.iter()
.any(|c| c == "-show-table-of-contents"),
"expected -show-table-of-contents class"
);
}
#[test]
fn markdown_viewer_toggle_toc_removes_class() {
let mut viewer = MarkdownViewer::new("# Test");
viewer.set_show_table_of_contents(false);
assert!(
!viewer
.style_classes()
.iter()
.any(|c| c == "-show-table-of-contents"),
"class should be removed when TOC is hidden"
);
}
#[test]
fn toc_set_headings_updates_content() {
let mut toc = MarkdownTableOfContents::new(vec![(1, "H1".to_string(), "h1".to_string())]);
toc.set_headings(vec![
(1, "H1".to_string(), "h1".to_string()),
(2, "H2".to_string(), "h2".to_string()),
]);
let headings = toc.shared_headings.read().unwrap().clone();
assert_eq!(headings.len(), 2);
}
#[test]
fn toc_compose_returns_tree_child() {
let toc = MarkdownTableOfContents::new(vec![
(1, "Chapter".to_string(), "chapter".to_string()),
(2, "Section".to_string(), "section".to_string()),
]);
let children = toc.compose();
assert!(
children.len() == 1,
"TOC should compose exactly one Tree child, got {}",
children.len()
);
match &children[0].builder {
crate::compose::WidgetBuilder::Ready(widget) => {
assert_eq!(widget.style_type(), "Tree");
}
}
}
#[test]
fn toc_child_tree_css_padding_and_bg_resolve_with_parent_context() {
let _guard = crate::css::set_style_context(crate::css::default_widget_stylesheet());
let toc = MarkdownTableOfContents::new(vec![(1, "H1".to_string(), "h1".to_string())]);
let tree = MarkdownTableOfContentsTree::with_shared_headings(toc.shared_headings.clone());
let toc_meta = crate::css::selector_meta_generic(&toc);
let toc_resolved = crate::css::resolve_style(&toc, &toc_meta);
let tree_meta = crate::css::selector_meta_generic(&tree);
let tree_resolved = crate::css::with_style_stack(toc_meta, toc_resolved, || {
crate::css::resolve_style(&tree, &tree_meta)
});
let padding = tree_resolved.effective_padding();
assert_eq!(
padding,
crate::style::Spacing::all(1),
"MarkdownTableOfContents > Tree should resolve padding: 1 from default CSS"
);
assert!(
tree_resolved.bg.is_some(),
"MarkdownTableOfContents > Tree should resolve background from default CSS"
);
}
#[test]
fn navigator_path_based_go_back_forward() {
let mut nav = Navigator::new();
nav.go("page1.md");
nav.go("page2.md");
assert_eq!(nav.location(), Some("page2.md"));
assert_eq!(nav.back(), Some("page1.md"));
assert_eq!(nav.location(), Some("page1.md"));
assert_eq!(nav.forward(), Some("page2.md"));
assert_eq!(nav.location(), Some("page2.md"));
}
#[test]
fn navigator_start_end_properties() {
let mut nav = Navigator::new();
assert!(nav.at_start());
assert!(nav.at_end());
nav.go("a.md");
assert!(nav.at_start());
assert!(nav.at_end());
nav.go("b.md");
assert!(!nav.at_start());
assert!(nav.at_end());
nav.back();
assert!(nav.at_start());
assert!(!nav.at_end());
}
#[test]
fn viewer_register_content_and_navigate() {
let mut viewer = MarkdownViewer::new("initial");
viewer.register_content("demo.md", "# Demo");
viewer.register_content("example.md", "# Example");
assert!(viewer.go("demo.md"));
assert_eq!(viewer.content, "# Demo");
assert!(viewer.go("example.md"));
assert_eq!(viewer.content, "# Example");
assert!(viewer.back());
assert_eq!(viewer.content, "# Demo");
assert!(viewer.forward());
assert_eq!(viewer.content, "# Example");
}
#[test]
fn viewer_go_unknown_path_returns_false() {
let mut viewer = MarkdownViewer::new("initial");
assert!(!viewer.go("nonexistent.md"));
assert_eq!(viewer.content, "initial");
}
#[test]
fn viewer_link_action_resolves_relative_registered_path() {
let mut viewer = MarkdownViewer::new("initial");
viewer.register_content("demo.md", "# Demo");
viewer.register_content("example.md", "# Example");
assert!(viewer.go("demo.md"));
let action =
crate::action::parse_action("link('./example.md')").expect("link action should parse");
let mut ctx = EventCtx::default();
assert!(viewer.execute_action(&action, &mut ctx));
assert_eq!(viewer.content, "# Example");
assert!(
ctx.take_messages()
.into_iter()
.any(|msg| matches!(msg.message, Message::NavigatorUpdated(_))),
"link navigation should emit NavigatorUpdated"
);
}
#[test]
fn toc_tree_hides_root() {
let toc = MarkdownTableOfContents::new(vec![(1, "H1".to_string(), "h1".to_string())]);
let headings = toc.shared_headings.read().unwrap().clone();
let tree = MarkdownTableOfContents::build_tree_from_headings(&headings);
assert!(!tree.showing_root());
}
#[test]
fn toc_tree_parent_nodes_start_expanded() {
let toc = MarkdownTableOfContents::new(vec![
(1, "Chapter".to_string(), "chapter".to_string()),
(2, "Section".to_string(), "section".to_string()),
]);
let headings = toc.shared_headings.read().unwrap().clone();
let tree = MarkdownTableOfContents::build_tree_from_headings(&headings);
if let Some(root) = tree.root() {
assert!(
root.is_expanded(),
"Root 'Contents' node should start expanded"
);
}
}
#[test]
fn toc_composed_tree_defers_intrinsic_width_to_toc_wrapper() {
let toc = MarkdownTableOfContents::new(vec![
(
1,
"A Long Chapter Title".to_string(),
"a-long-chapter-title".to_string(),
),
(2, "Section".to_string(), "section".to_string()),
]);
let tree = MarkdownTableOfContentsTree::with_shared_headings(toc.shared_headings.clone());
let w = tree.content_width();
assert_eq!(
w, None,
"Composed TOC Tree should fill parent pane; TOC wrapper computes intrinsic width"
);
}
#[test]
fn toc_wrapper_reports_intrinsic_width_for_dock_auto_layout() {
let _guard = crate::css::set_style_context(crate::css::default_widget_stylesheet());
let toc = MarkdownTableOfContents::new(vec![
(
1,
"A Long Chapter Title".to_string(),
"a-long-chapter-title".to_string(),
),
(2, "Section".to_string(), "section".to_string()),
]);
let w = toc.content_width();
assert!(
w.is_some() && w.unwrap() > 10,
"TOC wrapper should expose intrinsic width so dock:auto doesn't consume full viewport"
);
}
#[test]
fn tree_node_data_carries_heading_block_id() {
let toc = MarkdownTableOfContents::new(vec![
(1, "Chapter".to_string(), "chapter".to_string()),
(2, "Section".to_string(), "section".to_string()),
]);
let headings = toc.shared_headings.read().unwrap().clone();
let tree = MarkdownTableOfContents::build_tree_from_headings(&headings);
if let Some(root) = tree.root() {
let h1 = root.children_slice();
assert!(!h1.is_empty());
assert_eq!(h1[0].data(), Some("chapter"));
let h2_children = h1[0].children_slice();
assert!(!h2_children.is_empty());
assert_eq!(h2_children[0].data(), Some("section"));
}
}
#[test]
fn heading_line_offset_finds_visual_row_by_block_id() {
let content = "Some preamble\n\n# First Heading\n\nText\n\n## Second Heading\n";
let mut viewer = MarkdownViewer::new(content);
viewer.on_layout(80, 24);
let first = viewer.heading_line_offset("first-heading");
let second = viewer.heading_line_offset("second-heading");
assert!(first < second);
assert_eq!(first, 0);
assert_eq!(second, 6);
}
#[test]
fn toc_on_message_posts_toc_selected() {
let mut toc =
MarkdownTableOfContents::new(vec![(1, "Chapter".to_string(), "chapter".to_string())]);
let msg = MessageEvent {
sender: crate::node_id::NodeId::default(),
message: Message::TreeNodeActivated(TreeNodeActivated {
index: 0,
label: "Chapter".to_string(),
data: Some("chapter".to_string()),
}),
control: None,
};
let mut ctx = crate::event::EventCtx::default();
toc.on_message(&msg, &mut ctx);
assert!(ctx.handled());
let messages = ctx.take_messages();
assert!(
messages.iter().any(|m| matches!(
&m.message,
Message::MarkdownTableOfContentsSelected(
MarkdownTableOfContentsSelected { block_id }
) if block_id == "chapter"
)),
"TOC should post MarkdownTableOfContentsSelected with block_id"
);
}
#[test]
fn toc_on_message_updated_requests_layout_invalidation() {
let mut toc =
MarkdownTableOfContents::new(vec![(1, "Chapter".to_string(), "chapter".to_string())]);
let msg = MessageEvent {
sender: crate::node_id::NodeId::default(),
message: Message::MarkdownTableOfContentsUpdated(MarkdownTableOfContentsUpdated {
headings: vec![
(1, "Chapter".to_string(), "chapter".to_string()),
(2, "Section".to_string(), "section".to_string()),
],
}),
control: None,
};
let mut ctx = crate::event::EventCtx::default();
toc.on_message(&msg, &mut ctx);
assert!(
ctx.invalidation().layout,
"TOC updates should invalidate layout so dock:auto width can grow"
);
}
#[test]
fn toc_on_selected_does_not_post_toc_selected() {
let mut toc =
MarkdownTableOfContents::new(vec![(1, "Chapter".to_string(), "chapter".to_string())]);
let msg = MessageEvent {
sender: crate::node_id::NodeId::default(),
message: Message::TreeNodeSelected(crate::message::TreeNodeSelected {
index: 0,
label: "Chapter".to_string(),
data: Some("chapter".to_string()),
}),
control: None,
};
let mut ctx = crate::event::EventCtx::default();
toc.on_message(&msg, &mut ctx);
assert!(!ctx.handled());
assert!(ctx.take_messages().is_empty());
}
#[test]
fn parse_headings_generates_stable_slug_ids() {
let headings = MarkdownViewer::parse_headings("# Hello World\n## Hello World\n## !!!\n");
assert_eq!(headings[0].2, "hello-world");
assert_eq!(headings[1].2, "hello-world-1");
assert_eq!(headings[2].2, "section");
}
#[test]
fn toc_tree_width_handles_long_h2_titles() {
let content = "# Markdown Viewer\n\n## Features\n\n## Tables\n\n## Code Blocks\n\n## Litany Against Fear\n";
let headings = MarkdownViewer::parse_headings(content);
let tree = MarkdownTableOfContents::build_tree_from_headings(&headings);
let expected = rich_rs::cell_len("└── Ⅱ Litany Against Fear");
assert_eq!(tree.content_width(), Some(expected.max(1)));
}
#[test]
fn viewer_toc_update_requests_layout_invalidation() {
let mut viewer = MarkdownViewer::new("# Chapter");
let msg = MessageEvent {
sender: crate::node_id::NodeId::default(),
message: Message::MarkdownTableOfContentsUpdated(MarkdownTableOfContentsUpdated {
headings: vec![
(1, "Chapter".to_string(), "chapter".to_string()),
(2, "Section".to_string(), "section".to_string()),
(
2,
"Litany Against Fear".to_string(),
"litany-against-fear".to_string(),
),
],
}),
control: None,
};
let mut ctx = crate::event::EventCtx::default();
viewer.on_message(&msg, &mut ctx);
assert!(
ctx.invalidation().layout,
"MarkdownViewer must invalidate layout when TOC headings change"
);
}
#[test]
fn viewer_toc_selected_posts_scrollbar_scroll_to() {
let mut viewer = MarkdownViewer::new("# First\n\n## Second");
viewer.on_layout(80, 24);
let msg = MessageEvent {
sender: crate::node_id::NodeId::default(),
message: Message::MarkdownTableOfContentsSelected(MarkdownTableOfContentsSelected {
block_id: "second".to_string(),
}),
control: None,
};
let mut ctx = crate::event::EventCtx::default();
viewer.on_message(&msg, &mut ctx);
assert!(ctx.handled());
let messages = ctx.take_messages();
assert!(
messages.iter().any(|m| matches!(
&m.message,
Message::ScrollbarScrollTo(ScrollbarScrollTo {
axis: ScrollbarAxis::Vertical,
offset: _,
animate: true,
..
})
)),
"TOC selection should route through ScrollbarScrollTo for synchronized content+thumb scroll"
);
}
#[test]
fn shared_markup_syncs_on_layout() {
let shared = Arc::new(RwLock::new("# Hello".to_string()));
let mut md = Markdown::with_shared_markup(shared.clone());
let initial_height = md.layout_height().unwrap_or_default();
assert!(initial_height > 0);
*shared.write().unwrap() = "# Line1\n\n# Line2\n\n# Line3".to_string();
assert_eq!(md.layout_height(), Some(initial_height));
assert_eq!(md.extract_headings().len(), 1);
md.on_layout(40, 10);
assert_eq!(md.extract_headings().len(), 3);
assert!(md.layout_height().unwrap_or_default() > 0);
}
#[test]
fn viewer_go_updates_shared_markup() {
let mut viewer = MarkdownViewer::new("initial");
viewer.register_content("demo.md", "# Demo\n\nParagraph\n\nMore text");
viewer.go("demo.md");
let shared_content = viewer.shared_markup.read().unwrap().clone();
assert_eq!(shared_content, "# Demo\n\nParagraph\n\nMore text");
}
#[test]
fn viewer_go_updates_shared_headings() {
let mut viewer = MarkdownViewer::new("initial");
viewer.register_content("demo.md", "# Demo\n\n## Child");
viewer.go("demo.md");
let headings = viewer.shared_headings.read().unwrap().clone();
assert_eq!(headings.len(), 2);
assert_eq!(headings[0].2, "demo");
assert_eq!(headings[1].2, "child");
}
#[test]
fn viewer_back_forward_updates_shared_markup() {
let mut viewer = MarkdownViewer::new("initial");
viewer.register_content("a.md", "# Page A");
viewer.register_content("b.md", "# Page B");
viewer.go("a.md");
viewer.go("b.md");
assert_eq!(*viewer.shared_markup.read().unwrap(), "# Page B");
viewer.back();
assert_eq!(*viewer.shared_markup.read().unwrap(), "# Page A");
viewer.forward();
assert_eq!(*viewer.shared_markup.read().unwrap(), "# Page B");
}
#[test]
fn viewer_scroll_viewport_size_delegates() {
let viewer = MarkdownViewer::new("# Test");
assert_eq!(viewer.scroll_viewport_size(), None);
}
}