From 9503b3e0bcf5e45feb610370e71ce053470f43ec Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Tue, 19 May 2026 19:40:16 +0100 Subject: [PATCH 1/2] feat: add document facade editor --- src/app.rs | 106 ++++---- src/document/markdown.rs | 508 +++++++++++++++++++++++++++++++++++++++ src/document/mod.rs | 5 + src/document/model.rs | 486 +++++++++++++++++++++++++++++++++++++ src/editor/buffer.rs | 392 ++++++++++++++++++++++++++++-- src/editor/commands.rs | 39 +++ src/editor/render.rs | 8 +- src/main.rs | 1 + src/markdown/inline.rs | 2 + src/ui/editor.rs | 78 ++++-- 10 files changed, 1537 insertions(+), 88 deletions(-) create mode 100644 src/document/markdown.rs create mode 100644 src/document/mod.rs create mode 100644 src/document/model.rs diff --git a/src/app.rs b/src/app.rs index daf3df0..e6bb6b6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ use crate::{ render::{visible_rows, visual_line_bounds, wrap_index_for_column, wrap_line}, }, fs::tree::FileTree, - markdown::inline::{LinkKind, link_at_column}, + markdown::inline::LinkKind, markdown::{highlight::concealed_wrap_line, table::TableLayout}, }; @@ -387,20 +387,7 @@ impl App { fn selected_text(&self) -> Option { let selection = self.text_selection?; let (start, end) = selection.ordered(); - let start = self.buffer.char_index(start); - let end = self.buffer.char_index(end); - if start == end { - return None; - } - - Some( - self.buffer - .as_string() - .chars() - .skip(start) - .take(end.saturating_sub(start)) - .collect(), - ) + self.buffer.selected_markdown(start, end) } fn handle_normal_key(&mut self, key: KeyEvent) -> Result<()> { @@ -583,7 +570,13 @@ impl App { self.reset_preferred_column(); } KeyCode::Tab => { - self.buffer.insert_str(&mut self.cursor, " "); + if !self.buffer.move_table_cell(&mut self.cursor, 1) { + self.buffer.insert_str(&mut self.cursor, " "); + } + self.reset_preferred_column(); + } + KeyCode::BackTab => { + self.buffer.move_table_cell(&mut self.cursor, -1); self.reset_preferred_column(); } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -1105,6 +1098,10 @@ impl App { self.should_quit = true; } Command::Edit(path) => self.open_path(&path)?, + Command::Table { rows, columns } => { + self.buffer.insert_table(rows, columns, &mut self.cursor); + self.set_status(format!("Inserted {rows}x{columns} table")); + } Command::Unknown(value) => { self.set_status(format!("Unknown command: {value}")); } @@ -1684,9 +1681,7 @@ impl App { } fn follow_link_under_cursor(&mut self) -> Result<()> { - let line = self.buffer.line(self.cursor.line); - let source = line.trim_end_matches(['\r', '\n']); - let Some(link) = link_at_column(source, self.cursor.column) else { + let Some(link) = self.buffer.link_at_cursor(self.cursor) else { self.set_status("No link under cursor"); return Ok(()); }; @@ -1862,27 +1857,14 @@ impl App { } fn toggle_checkbox(&mut self) -> bool { - let original_cursor = self.cursor; - let line = self.buffer.line(original_cursor.line); - let trimmed = line.trim_end_matches(['\r', '\n']); - let leading_ws_len = trimmed.len() - trimmed.trim_start().len(); - let content = &trimmed[leading_ws_len..]; - - let col = leading_ws_len + 3; - - if content.starts_with("- [ ] ") || content.starts_with("- [x] ") { - let unchecked = content.starts_with("- [ ] "); - let start = self.buffer.char_index(Cursor { - line: original_cursor.line, - column: col, - }); - let replacement = if unchecked { "x" } else { " " }; - self.buffer - .replace_range(start, start + 1, replacement, &mut self.cursor); - self.cursor = original_cursor; + let mut cursor = self.cursor; + if self + .buffer + .toggle_checkbox_at_line(self.cursor.line, &mut cursor) + { + self.cursor = cursor; return true; } - false } } @@ -1984,6 +1966,13 @@ const COMMAND_CANDIDATES: &[CommandCandidate] = &[ aliases: &["/", "search", "find"], action: CommandCandidateAction::BeginSearch, }, + CommandCandidate { + replacement: "table", + label: "table", + detail: "Insert a Markdown table", + aliases: &["table", "grid"], + action: CommandCandidateAction::Command("table"), + }, ]; fn command_sheet_items( @@ -2500,7 +2489,7 @@ fn list_continuation_after_enter(line: &str, column: usize) -> ListContinuation } let prefix = match item.kind { - ListItemKind::Checkbox => format!("{leading_ws}- [ ] "), + ListItemKind::Checkbox => format!("{leading_ws}[ ] "), ListItemKind::Bullet(marker) => format!("{leading_ws}{marker} "), ListItemKind::Numbered(number) => format!("{leading_ws}{}. ", number + 1), }; @@ -2522,6 +2511,22 @@ struct ListItem<'a> { } fn parse_list_item(content: &str) -> Option> { + if let Some(rest) = content + .strip_prefix("[ ]") + .or_else(|| content.strip_prefix("[x]")) + { + if let Some(item_content) = rest + .strip_prefix(' ') + .or_else(|| rest.is_empty().then_some("")) + { + return Some(ListItem { + kind: ListItemKind::Checkbox, + marker_len: content.len() - item_content.len(), + content: item_content, + }); + } + } + if let Some(rest) = content .strip_prefix("- [ ]") .or_else(|| content.strip_prefix("- [x]")) @@ -2546,7 +2551,7 @@ fn parse_list_item(content: &str) -> Option> { }); } - for marker in ['-', '*', '+'] { + for marker in ['•', '◦', '-', '*', '+'] { let prefix = [marker, ' '].iter().collect::(); if let Some(item_content) = content.strip_prefix(&prefix) { return Some(ListItem { @@ -3468,10 +3473,12 @@ mod tests { app.cursor = Cursor { line: 0, column: 0 }; press(&mut app, KeyCode::Enter); - assert_eq!(app.buffer.as_string(), "- [x] todo"); + assert_eq!(app.buffer.as_string(), "[x] todo"); + assert_eq!(app.buffer.markdown_string(), "- [x] todo"); press(&mut app, KeyCode::Char('u')); - assert_eq!(app.buffer.as_string(), "- [ ] todo"); + assert_eq!(app.buffer.as_string(), "[ ] todo"); + assert_eq!(app.buffer.markdown_string(), "- [ ] todo"); } #[test] @@ -3538,11 +3545,11 @@ mod tests { fn enter_continues_checkbox_items_at_end_and_middle() { assert_eq!( list_continuation_after_enter("- [ ] todo", 10), - ListContinuation::Continue("- [ ] ".to_string()) + ListContinuation::Continue("[ ] ".to_string()) ); assert_eq!( list_continuation_after_enter("- [x] todo", 6), - ListContinuation::Continue("- [ ] ".to_string()) + ListContinuation::Continue("[ ] ".to_string()) ); } @@ -3569,13 +3576,15 @@ mod tests { buffer.insert_str(&mut cursor, "- [ ] todo"); insert_newline_with_list_continuation(&mut buffer, &mut cursor); - assert_eq!(buffer.as_string(), "- [ ] todo\n- [ ] "); - assert_eq!(cursor, Cursor { line: 1, column: 6 }); + assert_eq!(buffer.as_string(), "[ ] todo\n[ ] "); + assert_eq!(buffer.markdown_string(), "- [ ] todo\n- [ ] "); + assert_eq!(cursor, Cursor { line: 1, column: 4 }); insert_newline_with_list_continuation(&mut buffer, &mut cursor); buffer.clamp_cursor(&mut cursor); - assert_eq!(buffer.as_string(), "- [ ] todo\n\n"); + assert_eq!(buffer.as_string(), "[ ] todo\n\n"); + assert_eq!(buffer.markdown_string(), "- [ ] todo\n\n"); assert_eq!(cursor, Cursor { line: 1, column: 0 }); } @@ -3588,7 +3597,8 @@ mod tests { insert_newline_with_list_continuation(&mut buffer, &mut cursor); - assert_eq!(buffer.as_string(), "- [ ] todo\n\nafter"); + assert_eq!(buffer.as_string(), "[ ] todo\n\nafter"); + assert_eq!(buffer.markdown_string(), "- [ ] todo\n\nafter"); assert_eq!(cursor, Cursor { line: 1, column: 0 }); } diff --git a/src/document/markdown.rs b/src/document/markdown.rs new file mode 100644 index 0000000..fdc355d --- /dev/null +++ b/src/document/markdown.rs @@ -0,0 +1,508 @@ +use crate::{ + document::model::{Block, Document, Inline, ListMarker, TableAlignment, TableCell, TableRow}, + markdown::inline::{LinkKind, links}, +}; + +pub struct MarkdownCodec; + +impl MarkdownCodec { + pub fn parse(source: &str) -> Document { + let lines = source.lines().collect::>(); + let mut blocks = Vec::new(); + let mut line = 0usize; + + while line < lines.len() { + let current = lines[line]; + + if current.trim().is_empty() { + blocks.push(Block::Blank); + line += 1; + continue; + } + + if current.trim_start().starts_with('<') && current.trim_end().ends_with('>') { + blocks.push(Block::RawMarkdown(current.to_string())); + line += 1; + continue; + } + + if let Some((language, code, next_line)) = parse_code_fence(&lines, line) { + blocks.push(Block::CodeFence { language, code }); + line = next_line; + continue; + } + + if let Some((table, next_line)) = parse_table(&lines, line) { + blocks.push(table); + line = next_line; + continue; + } + + blocks.push(parse_line_block(current)); + line += 1; + } + + if blocks.is_empty() { + blocks.push(Block::Blank); + } + + Document { blocks } + } + + pub fn parse_plain(source: &str) -> Document { + let lines = source.lines().collect::>(); + let mut blocks = Vec::new(); + let mut line = 0usize; + while line < lines.len() { + if let Some((table, next_line)) = parse_table(&lines, line) { + blocks.push(table); + line = next_line; + continue; + } + blocks.push(parse_facade_line_block(lines[line])); + line += 1; + } + if source.ends_with('\n') { + blocks.push(Block::Blank); + } + if blocks.is_empty() { + blocks.push(Block::Blank); + } + Document { blocks } + } + + pub fn serialize(document: &Document) -> String { + let mut out = String::new(); + for (index, block) in document.blocks.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(&block.to_markdown()); + } + out + } +} + +fn parse_line_block(line: &str) -> Block { + let leading = line.len() - line.trim_start().len(); + let trimmed = line.trim_start(); + + if leading == 0 + && let Some((level, rest)) = parse_heading(trimmed) + { + return Block::Heading { + level, + content: parse_inlines(rest), + }; + } + + if leading == 0 && trimmed.starts_with('>') { + let level = trimmed.chars().take_while(|ch| *ch == '>').count() as u8; + let rest = trimmed[level as usize..].trim_start(); + return Block::Quote { + level, + content: parse_inlines(rest), + }; + } + + if let Some((checked, rest)) = parse_markdown_checkbox(trimmed) { + return Block::ChecklistItem { + indent: leading, + checked, + content: parse_inlines(rest), + }; + } + + if let Some((number, rest)) = parse_numbered_marker(trimmed) { + return Block::ListItem { + indent: leading, + marker: ListMarker::Ordered(number), + content: parse_inlines(rest), + }; + } + + if let Some(rest) = parse_bullet_marker(trimmed) { + return Block::ListItem { + indent: leading, + marker: ListMarker::Bullet, + content: parse_inlines(rest), + }; + } + + Block::Paragraph(parse_inlines(line)) +} + +fn parse_facade_line_block(line: &str) -> Block { + let leading = line.len() - line.trim_start().len(); + let trimmed = line.trim_start(); + if trimmed.is_empty() { + return Block::Blank; + } + + if let Some((level, rest)) = parse_heading(trimmed) { + return Block::Heading { + level, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed.strip_prefix("> ") { + return Block::Quote { + level: 1, + content: parse_inlines(rest), + }; + } + + if let Some((checked, rest)) = parse_markdown_checkbox(trimmed) { + return Block::ChecklistItem { + indent: leading, + checked, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed.strip_prefix("[ ] ") { + return Block::ChecklistItem { + indent: leading, + checked: false, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed.strip_prefix("[x] ") { + return Block::ChecklistItem { + indent: leading, + checked: true, + content: parse_inlines(rest), + }; + } + + if let Some(rest) = trimmed + .strip_prefix("• ") + .or_else(|| trimmed.strip_prefix("◦ ")) + .or_else(|| parse_bullet_marker(trimmed)) + { + return Block::ListItem { + indent: leading, + marker: ListMarker::Bullet, + content: parse_inlines(rest), + }; + } + + if let Some((number, rest)) = parse_numbered_marker(trimmed) { + return Block::ListItem { + indent: leading, + marker: ListMarker::Ordered(number), + content: parse_inlines(rest), + }; + } + + Block::Paragraph(parse_inlines(line)) +} + +fn parse_heading(trimmed: &str) -> Option<(u8, &str)> { + let level = trimmed.chars().take_while(|ch| *ch == '#').count(); + if !(1..=6).contains(&level) || trimmed.chars().nth(level) != Some(' ') { + return None; + } + Some((level as u8, trimmed[level + 1..].trim_start())) +} + +fn parse_markdown_checkbox(trimmed: &str) -> Option<(bool, &str)> { + trimmed + .strip_prefix("- [ ] ") + .map(|rest| (false, rest)) + .or_else(|| trimmed.strip_prefix("- [x] ").map(|rest| (true, rest))) +} + +fn parse_bullet_marker(trimmed: &str) -> Option<&str> { + trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) +} + +fn parse_numbered_marker(trimmed: &str) -> Option<(usize, &str)> { + let bytes = trimmed.as_bytes(); + let mut i = 0usize; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i == 0 || trimmed.get(i..i + 2) != Some(". ") { + return None; + } + Some((trimmed[..i].parse().unwrap_or(1), &trimmed[i + 2..])) +} + +fn parse_code_fence(lines: &[&str], start: usize) -> Option<(Option, String, usize)> { + let opener = lines.get(start)?.trim_start(); + let language = opener.strip_prefix("```")?.trim(); + let mut code = String::new(); + let mut line = start + 1; + while line < lines.len() { + if lines[line].trim_start().starts_with("```") { + return Some(( + (!language.is_empty()).then(|| language.to_string()), + code.trim_end_matches('\n').to_string(), + line + 1, + )); + } + code.push_str(lines[line]); + code.push('\n'); + line += 1; + } + Some(( + (!language.is_empty()).then(|| language.to_string()), + code.trim_end_matches('\n').to_string(), + line, + )) +} + +fn parse_table(lines: &[&str], start: usize) -> Option<(Block, usize)> { + if start + 1 >= lines.len() { + return None; + } + let header = parse_table_cells(lines[start])?; + let alignments = parse_delimiter(lines[start + 1])?; + if header.len() < 2 || alignments.len() < 2 { + return None; + } + + let mut rows = vec![TableRow { cells: header }]; + let mut line = start + 2; + while line < lines.len() { + let Some(cells) = parse_table_cells(lines[line]) else { + break; + }; + if cells.len() < 2 { + break; + } + rows.push(TableRow { cells }); + line += 1; + } + + Some((Block::Table { alignments, rows }, line)) +} + +fn parse_table_cells(line: &str) -> Option> { + let trimmed = line.trim(); + if !trimmed.contains('|') { + return None; + } + + let mut cells = trimmed + .trim_matches('|') + .split('|') + .map(|cell| TableCell { + content: parse_inlines(cell.trim()), + }) + .collect::>(); + if cells.is_empty() { + None + } else { + Some(std::mem::take(&mut cells)) + } +} + +fn parse_delimiter(line: &str) -> Option> { + let cells = line.trim().trim_matches('|').split('|'); + cells + .map(|cell| { + let value = cell.trim(); + let left = value.starts_with(':'); + let right = value.ends_with(':'); + let dashes = value.trim_matches(':'); + if dashes.len() < 3 || !dashes.chars().all(|ch| ch == '-') { + return None; + } + Some(match (left, right) { + (true, true) => TableAlignment::Center, + (false, true) => TableAlignment::Right, + _ => TableAlignment::Left, + }) + }) + .collect() +} + +pub fn parse_inlines(source: &str) -> Vec { + let mut result = Vec::new(); + let parsed_links = links(source); + let mut index = 0usize; + + for link in parsed_links { + if link.source_start > index { + result.extend(parse_styled_text(&slice_chars( + source, + index, + link.source_start, + ))); + } + + match link.kind { + LinkKind::Markdown => result.push(Inline::Link { + label: parse_styled_text(link.label.as_deref().unwrap_or(&link.target)), + target: link.target, + kind: LinkKind::Markdown, + }), + LinkKind::Wiki => result.push(Inline::Link { + label: vec![Inline::Text( + link.label.clone().unwrap_or_else(|| link.target.clone()), + )], + target: link.target, + kind: LinkKind::Wiki, + }), + LinkKind::Url => result.push(Inline::BareUrl(link.target)), + } + index = link.source_end; + } + + if index < source.chars().count() { + result.extend(parse_styled_text(&slice_chars( + source, + index, + source.chars().count(), + ))); + } + + merge_text(result) +} + +fn parse_styled_text(source: &str) -> Vec { + let chars = source.chars().collect::>(); + let mut result = Vec::new(); + let mut index = 0usize; + + while index < chars.len() { + if chars[index] == '`' + && let Some(end) = find_next(&chars, index + 1, '`') + { + result.push(Inline::Code(chars[index + 1..end].iter().collect())); + index = end + 1; + continue; + } + + if starts_with(&chars, index, "**") + && let Some(end) = find_token(&chars, index + 2, "**") + { + result.push(Inline::Strong(parse_styled_text( + &chars[index + 2..end].iter().collect::(), + ))); + index = end + 2; + continue; + } + + if (chars[index] == '*' || chars[index] == '_') + && let Some(end) = find_next(&chars, index + 1, chars[index]) + { + result.push(Inline::Emphasis(parse_styled_text( + &chars[index + 1..end].iter().collect::(), + ))); + index = end + 1; + continue; + } + + let next = next_special(&chars, index + 1).unwrap_or(chars.len()); + result.push(Inline::Text(chars[index..next].iter().collect())); + index = next; + } + + merge_text(result) +} + +fn merge_text(inlines: Vec) -> Vec { + let mut merged: Vec = Vec::new(); + for inline in inlines { + if let (Some(Inline::Text(left)), Inline::Text(right)) = (merged.last_mut(), &inline) { + left.push_str(right); + } else { + merged.push(inline); + } + } + merged +} + +fn find_next(chars: &[char], start: usize, needle: char) -> Option { + (start..chars.len()).find(|index| chars[*index] == needle) +} + +fn find_token(chars: &[char], start: usize, token: &str) -> Option { + (start..chars.len()).find(|index| starts_with(chars, *index, token)) +} + +fn starts_with(chars: &[char], index: usize, token: &str) -> bool { + token + .chars() + .enumerate() + .all(|(offset, ch)| chars.get(index + offset) == Some(&ch)) +} + +fn next_special(chars: &[char], start: usize) -> Option { + (start..chars.len()) + .find(|index| chars[*index] == '`' || chars[*index] == '*' || chars[*index] == '_') +} + +fn slice_chars(source: &str, start: usize, end: usize) -> String { + source + .chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::model::{inline_markdown, inline_plain_text}; + + #[test] + fn parses_and_serializes_common_blocks() { + let source = "# Title\n\n- [x] done\n\n> quote\n\n[README](README.md)"; + let document = MarkdownCodec::parse(source); + + assert_eq!( + MarkdownCodec::serialize(&document), + "# Title\n\n- [x] done\n\n> quote\n\n[README](README.md)" + ); + assert_eq!( + document.plain_text(), + "Title\n\n[x] done\n\n> quote\n\nREADME" + ); + } + + #[test] + fn parses_and_serializes_tables_canonically() { + let source = "| Name | Role |\n| --- | --- |\n| Ada | Editor |"; + let document = MarkdownCodec::parse(source); + + assert_eq!( + MarkdownCodec::serialize(&document), + "| Name | Role |\n| ---- | ------ |\n| Ada | Editor |" + ); + } + + #[test] + fn preserves_raw_markdown_blocks() { + let source = "
raw
"; + let document = MarkdownCodec::parse(source); + + assert_eq!(MarkdownCodec::serialize(&document), source); + } + + #[test] + fn parses_facade_shortcuts() { + let document = MarkdownCodec::parse_plain("# Heading\n[ ] todo\n• item"); + + assert_eq!( + MarkdownCodec::serialize(&document), + "# Heading\n- [ ] todo\n- item" + ); + } + + #[test] + fn inline_plain_text_hides_syntax() { + let inlines = parse_inlines("a **bold** [link](target.md)"); + + assert_eq!(inline_plain_text(&inlines), "a bold link"); + assert_eq!(inline_markdown(&inlines), "a **bold** [link](target.md)"); + } +} diff --git a/src/document/mod.rs b/src/document/mod.rs new file mode 100644 index 0000000..cd052b5 --- /dev/null +++ b/src/document/mod.rs @@ -0,0 +1,5 @@ +pub mod markdown; +pub mod model; + +pub use markdown::MarkdownCodec; +pub use model::{Block, DocLink, DocRange, Document, Inline, TableAlignment, TableCell, TableRow}; diff --git a/src/document/model.rs b/src/document/model.rs new file mode 100644 index 0000000..38578a0 --- /dev/null +++ b/src/document/model.rs @@ -0,0 +1,486 @@ +use crate::markdown::inline::LinkKind; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Document { + pub blocks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Block { + Blank, + Paragraph(Vec), + Heading { + level: u8, + content: Vec, + }, + Quote { + level: u8, + content: Vec, + }, + ListItem { + indent: usize, + marker: ListMarker, + content: Vec, + }, + ChecklistItem { + indent: usize, + checked: bool, + content: Vec, + }, + CodeFence { + language: Option, + code: String, + }, + Table { + alignments: Vec, + rows: Vec, + }, + RawMarkdown(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListMarker { + Bullet, + Ordered(usize), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Inline { + Text(String), + Emphasis(Vec), + Strong(Vec), + Code(String), + Link { + label: Vec, + target: String, + kind: LinkKind, + }, + BareUrl(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableAlignment { + Left, + Center, + Right, +} + +impl Default for TableAlignment { + fn default() -> Self { + Self::Left + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableRow { + pub cells: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableCell { + pub content: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DocRange { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocLink { + pub target: String, + pub kind: LinkKind, +} + +impl Document { + pub fn plain_text(&self) -> String { + self.blocks + .iter() + .flat_map(Block::plain_lines) + .collect::>() + .join("\n") + } + + pub fn block_for_plain_line(&self, line: usize) -> Option<&Block> { + let mut cursor = 0usize; + for block in &self.blocks { + let count = block.plain_line_count(); + if line < cursor + count { + return Some(block); + } + cursor += count; + } + None + } + + pub fn link_at_plain_position(&self, line: usize, column: usize) -> Option { + let block = self.block_for_plain_line(line)?; + block.link_at_column(column) + } + + pub fn serialize_range(&self, range: DocRange) -> String { + let range_start = range.start.min(range.end); + let range_end = range.end.max(range.start); + if range_start == range_end { + return String::new(); + } + + let mut result = String::new(); + let mut plain_cursor = 0usize; + for block in &self.blocks { + let plain = block.plain_lines().join("\n"); + let block_start = plain_cursor; + let block_end = block_start + plain.chars().count(); + if ranges_overlap(range_start, range_end, block_start, block_end) { + if range_start <= block_start && range_end >= block_end { + if !result.is_empty() { + result.push_str("\n\n"); + } + result.push_str(&block.to_markdown()); + } else { + let local_start = range_start.saturating_sub(block_start); + let local_end = range_end.min(block_end).saturating_sub(block_start); + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&slice_chars(&plain, local_start, local_end)); + } + } + plain_cursor = block_end + 1; + } + + result + } +} + +impl Block { + pub fn plain_line_count(&self) -> usize { + self.plain_lines().len().max(1) + } + + pub fn plain_lines(&self) -> Vec { + match self { + Block::Blank => vec![String::new()], + Block::Paragraph(content) => vec![inline_plain_text(content)], + Block::Heading { content, .. } => vec![inline_plain_text(content)], + Block::Quote { level, content } => { + vec![format!( + "{} {}", + quote_marker(*level), + inline_plain_text(content) + )] + } + Block::ListItem { + indent, + marker, + content, + } => vec![format!( + "{}{} {}", + " ".repeat(*indent), + marker.plain_marker(*indent), + inline_plain_text(content) + )], + Block::ChecklistItem { + indent, + checked, + content, + } => vec![format!( + "{}{} {}", + " ".repeat(*indent), + if *checked { "[x]" } else { "[ ]" }, + inline_plain_text(content) + )], + Block::CodeFence { code, .. } => { + if code.is_empty() { + vec![String::new()] + } else { + code.lines().map(ToOwned::to_owned).collect() + } + } + Block::Table { rows, .. } => rows + .is_empty() + .then(Vec::new) + .unwrap_or_else(|| self.to_markdown().lines().map(ToOwned::to_owned).collect()), + Block::RawMarkdown(markdown) => markdown.lines().map(ToOwned::to_owned).collect(), + } + } + + pub fn to_markdown(&self) -> String { + match self { + Block::Blank => String::new(), + Block::Paragraph(content) => inline_markdown(content), + Block::Heading { level, content } => { + format!( + "{} {}", + "#".repeat(*level as usize), + inline_markdown(content) + ) + } + Block::Quote { level, content } => { + format!( + "{} {}", + ">".repeat(*level as usize), + inline_markdown(content) + ) + } + Block::ListItem { + indent, + marker, + content, + } => format!( + "{}{} {}", + " ".repeat(*indent), + marker.markdown_marker(), + inline_markdown(content) + ), + Block::ChecklistItem { + indent, + checked, + content, + } => format!( + "{}- [{}] {}", + " ".repeat(*indent), + if *checked { "x" } else { " " }, + inline_markdown(content) + ), + Block::CodeFence { language, code } => { + format!( + "```{}\n{}\n```", + language.as_deref().unwrap_or_default(), + code.trim_end_matches('\n') + ) + } + Block::Table { alignments, rows } => table_markdown(alignments, rows), + Block::RawMarkdown(markdown) => markdown.clone(), + } + } + + fn link_at_column(&self, column: usize) -> Option { + match self { + Block::Paragraph(content) + | Block::Heading { content, .. } + | Block::Quote { content, .. } + | Block::ListItem { content, .. } + | Block::ChecklistItem { content, .. } => inline_link_at_column(content, column), + Block::Table { rows, .. } => { + let mut cursor = 0usize; + for row in rows { + for cell in &row.cells { + let text = inline_plain_text(&cell.content); + if column >= cursor && column < cursor + text.chars().count() { + return inline_link_at_column(&cell.content, column - cursor); + } + cursor += text.chars().count() + 2; + } + } + None + } + _ => None, + } + } +} + +impl ListMarker { + fn markdown_marker(self) -> String { + match self { + ListMarker::Bullet => "-".to_string(), + ListMarker::Ordered(number) => format!("{number}."), + } + } + + fn plain_marker(self, indent: usize) -> String { + match self { + ListMarker::Bullet => { + if indent >= 2 { + "◦".to_string() + } else { + "•".to_string() + } + } + ListMarker::Ordered(number) => format!("{number}."), + } + } +} + +pub fn inline_plain_text(inlines: &[Inline]) -> String { + let mut text = String::new(); + for inline in inlines { + match inline { + Inline::Text(value) | Inline::Code(value) | Inline::BareUrl(value) => { + text.push_str(value) + } + Inline::Emphasis(children) | Inline::Strong(children) => { + text.push_str(&inline_plain_text(children)) + } + Inline::Link { + label, + target, + kind, + } => { + if label.is_empty() && !matches!(kind, LinkKind::Wiki) { + text.push_str(target); + } else { + text.push_str(&inline_plain_text(label)); + } + } + } + } + text +} + +pub fn inline_markdown(inlines: &[Inline]) -> String { + let mut text = String::new(); + for inline in inlines { + match inline { + Inline::Text(value) => text.push_str(value), + Inline::Emphasis(children) => { + text.push('*'); + text.push_str(&inline_markdown(children)); + text.push('*'); + } + Inline::Strong(children) => { + text.push_str("**"); + text.push_str(&inline_markdown(children)); + text.push_str("**"); + } + Inline::Code(value) => { + text.push('`'); + text.push_str(value); + text.push('`'); + } + Inline::Link { + label, + target, + kind, + } => match kind { + LinkKind::Markdown => { + text.push('['); + text.push_str(&inline_markdown(label)); + text.push_str("]("); + text.push_str(target); + text.push(')'); + } + LinkKind::Wiki => { + text.push_str("[["); + text.push_str(target); + text.push_str("]]"); + } + LinkKind::Url => text.push_str(target), + }, + Inline::BareUrl(value) => text.push_str(value), + } + } + text +} + +fn inline_link_at_column(inlines: &[Inline], column: usize) -> Option { + let mut cursor = 0usize; + for inline in inlines { + let len = inline_plain_text(std::slice::from_ref(inline)) + .chars() + .count(); + match inline { + Inline::Link { target, kind, .. } => { + if column >= cursor && column < cursor + len { + return Some(DocLink { + target: target.clone(), + kind: kind.clone(), + }); + } + } + Inline::BareUrl(target) => { + if column >= cursor && column < cursor + len { + return Some(DocLink { + target: target.clone(), + kind: LinkKind::Url, + }); + } + } + Inline::Emphasis(children) | Inline::Strong(children) => { + if column >= cursor + && column < cursor + len + && let Some(link) = inline_link_at_column(children, column - cursor) + { + return Some(link); + } + } + _ => {} + } + cursor += len; + } + None +} + +fn table_markdown(alignments: &[TableAlignment], rows: &[TableRow]) -> String { + if rows.is_empty() { + return String::new(); + } + + let column_count = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); + if column_count == 0 { + return String::new(); + } + + let mut widths = vec![3usize; column_count]; + for row in rows { + for (index, cell) in row.cells.iter().enumerate() { + widths[index] = widths[index].max(inline_markdown(&cell.content).chars().count()); + } + } + + let mut lines = Vec::new(); + for (row_index, row) in rows.iter().enumerate() { + lines.push(table_row_markdown(row, &widths)); + if row_index == 0 { + lines.push(table_delimiter_markdown(alignments, &widths)); + } + } + lines.join("\n") +} + +fn table_row_markdown(row: &TableRow, widths: &[usize]) -> String { + let mut line = String::from("|"); + for (index, width) in widths.iter().enumerate() { + let value = row + .cells + .get(index) + .map(|cell| inline_markdown(&cell.content)) + .unwrap_or_default(); + line.push(' '); + line.push_str(&value); + line.push_str(&" ".repeat(width.saturating_sub(value.chars().count()))); + line.push_str(" |"); + } + line +} + +fn table_delimiter_markdown(alignments: &[TableAlignment], widths: &[usize]) -> String { + let mut line = String::from("|"); + for (index, width) in widths.iter().enumerate() { + let marker = match alignments.get(index).copied().unwrap_or_default() { + TableAlignment::Left => format!(" {}", "-".repeat(*width)), + TableAlignment::Center => format!(":{}:", "-".repeat((*width).max(3))), + TableAlignment::Right => format!("{}:", "-".repeat(width.saturating_sub(1).max(3))), + }; + line.push_str(&marker); + line.push_str(" |"); + } + line +} + +fn quote_marker(level: u8) -> String { + ">".repeat(level.max(1) as usize) +} + +fn ranges_overlap(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool { + a_start < b_end && b_start < a_end +} + +fn slice_chars(source: &str, start: usize, end: usize) -> String { + source + .chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() +} diff --git a/src/editor/buffer.rs b/src/editor/buffer.rs index f91dd07..f194dd5 100644 --- a/src/editor/buffer.rs +++ b/src/editor/buffer.rs @@ -3,31 +3,43 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use ropey::Rope; -use crate::{editor::cursor::Cursor, fs::persistence, markdown::parse::parse_markdown}; +use crate::{ + document::{ + Block, DocLink, DocRange, Document, Inline, MarkdownCodec, TableAlignment, TableCell, + TableRow, + }, + editor::cursor::Cursor, + fs::persistence, + markdown::parse::parse_markdown, +}; #[derive(Debug, Clone)] pub struct DocumentBuffer { pub path: Option, + document: Document, text: Rope, pub dirty: bool, - saved_text: Rope, + saved_markdown: String, undo_stack: Vec, } #[derive(Debug, Clone)] struct BufferSnapshot { + document: Document, text: Rope, cursor: Cursor, } impl DocumentBuffer { pub fn empty() -> Self { - let text = Rope::new(); + let document = Document::default(); + let text = Rope::from_str(&document.plain_text()); Self { path: None, - text: text.clone(), - saved_text: text, + document, + text, dirty: false, + saved_markdown: String::new(), undo_stack: Vec::new(), } } @@ -35,12 +47,15 @@ impl DocumentBuffer { pub fn from_path(path: &Path) -> Result { let contents = persistence::load_utf8(path)?; parse_markdown(&contents)?; - let text = Rope::from_str(&contents); + let document = MarkdownCodec::parse(&contents); + let saved_markdown = MarkdownCodec::serialize(&document); + let text = Rope::from_str(&document.plain_text()); Ok(Self { path: Some(path.to_path_buf()), - saved_text: text.clone(), + document, text, dirty: false, + saved_markdown, undo_stack: Vec::new(), }) } @@ -51,9 +66,10 @@ impl DocumentBuffer { } else { Ok(Self { path: Some(path.to_path_buf()), - saved_text: Rope::new(), + document: Document::default(), text: Rope::new(), dirty: false, + saved_markdown: String::new(), undo_stack: Vec::new(), }) } @@ -64,8 +80,9 @@ impl DocumentBuffer { return Ok(()); }; - persistence::save_atomic(path, &self.text.to_string())?; - self.saved_text = self.text.clone(); + let markdown = self.markdown_string(); + persistence::save_atomic(path, &markdown)?; + self.saved_markdown = markdown; self.dirty = false; Ok(()) } @@ -100,7 +117,6 @@ impl DocumentBuffer { fn insert_char_raw(&mut self, cursor: &mut Cursor, ch: char) { let index = self.char_index(*cursor); self.text.insert_char(index, ch); - self.update_dirty(); if ch == '\n' { cursor.line += 1; @@ -108,6 +124,7 @@ impl DocumentBuffer { } else { cursor.column += 1; } + self.sync_document_from_facade(cursor); } pub fn insert_str(&mut self, cursor: &mut Cursor, value: &str) { @@ -116,9 +133,17 @@ impl DocumentBuffer { } self.push_undo_snapshot(*cursor); + let index = self.char_index(*cursor); + self.text.insert(index, value); for ch in value.chars() { - self.insert_char_raw(cursor, ch); + if ch == '\n' { + cursor.line += 1; + cursor.column = 0; + } else { + cursor.column += 1; + } } + self.sync_document_from_facade(cursor); } pub fn delete_previous_char(&mut self, cursor: &mut Cursor) { @@ -139,7 +164,6 @@ impl DocumentBuffer { None }; self.text.remove(previous..end); - self.update_dirty(); if cursor.column > 0 { cursor.column -= 1; @@ -147,6 +171,7 @@ impl DocumentBuffer { cursor.line = cursor.line.saturating_sub(1); cursor.column = previous_line_len.unwrap_or_default(); } + self.sync_document_from_facade(cursor); } pub fn delete_char(&mut self, cursor: &mut Cursor) { @@ -157,7 +182,7 @@ impl DocumentBuffer { self.push_undo_snapshot(*cursor); self.text.remove(start..start + 1); - self.update_dirty(); + self.sync_document_from_facade(cursor); self.clamp_cursor(cursor); } @@ -188,8 +213,8 @@ impl DocumentBuffer { self.push_undo_snapshot(*cursor); } self.text.remove(start..end); - self.update_dirty(); *cursor = self.cursor_from_char_index(start); + self.sync_document_from_facade(cursor); self.clamp_cursor(cursor); } @@ -218,8 +243,8 @@ impl DocumentBuffer { if !replacement.is_empty() { self.text.insert(start, replacement); } - self.update_dirty(); *cursor = self.cursor_from_char_index(start + replacement.chars().count()); + self.sync_document_from_facade(cursor); self.clamp_cursor(cursor); } @@ -269,14 +294,155 @@ impl DocumentBuffer { self.text.to_string() } + pub fn markdown_string(&self) -> String { + MarkdownCodec::serialize(&self.document) + } + + pub fn selected_markdown(&self, start: Cursor, end: Cursor) -> Option { + let start = self.char_index(start); + let end = self.char_index(end); + if start == end { + return None; + } + + Some(self.document.serialize_range(DocRange { start, end })) + } + + pub fn link_at_cursor(&self, cursor: Cursor) -> Option { + self.document + .link_at_plain_position(cursor.line, cursor.column) + } + + pub fn block_for_line(&self, line: usize) -> Option<&Block> { + self.document.block_for_plain_line(line) + } + + pub fn toggle_checkbox_at_line(&mut self, line: usize, cursor: &mut Cursor) -> bool { + let mut plain_line = 0usize; + for index in 0..self.document.blocks.len() { + let block = &self.document.blocks[index]; + let line_count = block.plain_line_count(); + if line >= plain_line && line < plain_line + line_count { + if matches!(block, Block::ChecklistItem { .. }) { + self.push_undo_snapshot(*cursor); + let Block::ChecklistItem { checked, .. } = &mut self.document.blocks[index] + else { + unreachable!(); + }; + *checked = !*checked; + self.rebuild_facade_preserving_cursor(cursor); + self.update_dirty(); + return true; + } + return false; + } + plain_line += line_count; + } + false + } + + pub fn insert_table(&mut self, rows: usize, columns: usize, cursor: &mut Cursor) { + self.push_undo_snapshot(*cursor); + let rows = rows.max(2); + let columns = columns.max(2); + let mut table_rows = Vec::new(); + for row in 0..rows { + table_rows.push(TableRow { + cells: (0..columns) + .map(|column| TableCell { + content: vec![Inline::Text(if row == 0 { + format!("Column {}", column + 1) + } else { + String::new() + })], + }) + .collect(), + }); + } + + let insert_at = self.block_index_for_line(cursor.line); + self.document.blocks.insert( + insert_at, + Block::Table { + alignments: vec![TableAlignment::Left; columns], + rows: table_rows, + }, + ); + self.rebuild_facade_preserving_cursor(cursor); + self.update_dirty(); + } + + pub fn move_table_cell(&self, cursor: &mut Cursor, delta: isize) -> bool { + if delta == 0 || !is_table_content_line(&self.line(cursor.line)) { + return false; + } + + let line = self.line(cursor.line); + let cells = table_cell_ranges(&line); + if cells.len() < 2 { + return false; + } + + let current = cells + .iter() + .position(|(start, end)| cursor.column >= *start && cursor.column <= *end) + .unwrap_or_else(|| { + cells + .iter() + .position(|(start, _)| cursor.column < *start) + .unwrap_or(cells.len() - 1) + }); + let target = current as isize + delta; + if target >= 0 && (target as usize) < cells.len() { + cursor.column = cells[target as usize].0; + return true; + } + + let mut next_line = cursor.line; + loop { + next_line = if delta > 0 { + next_line + 1 + } else { + next_line.saturating_sub(1) + }; + if next_line == cursor.line || next_line >= self.line_count() { + return false; + } + if is_table_content_line(&self.line(next_line)) { + break; + } + if !self.line(next_line).contains('|') { + return false; + } + } + + let next_text = self.line(next_line); + if !is_table_content_line(&next_text) { + return false; + } + let next_cells = table_cell_ranges(&next_text); + if next_cells.is_empty() { + return false; + } + + cursor.line = next_line; + cursor.column = if delta > 0 { + next_cells[0].0 + } else { + next_cells[next_cells.len() - 1].0 + }; + true + } + pub fn undo(&mut self, cursor: &mut Cursor) -> bool { let Some(snapshot) = self.undo_stack.pop() else { return false; }; + self.document = snapshot.document; self.text = snapshot.text; - self.update_dirty(); *cursor = snapshot.cursor; + self.update_dirty(); self.clamp_cursor(cursor); true } @@ -291,13 +457,42 @@ impl DocumentBuffer { } self.undo_stack.push(BufferSnapshot { + document: self.document.clone(), text: self.text.clone(), cursor, }); } fn update_dirty(&mut self) { - self.dirty = self.text != self.saved_text; + self.dirty = self.markdown_string() != self.saved_markdown; + } + + fn sync_document_from_facade(&mut self, cursor: &mut Cursor) { + let old_document = self.document.clone(); + let parsed = MarkdownCodec::parse_plain(&self.text.to_string()); + self.document = reconcile_facade_document(&old_document, parsed); + self.rebuild_facade_preserving_cursor(cursor); + self.update_dirty(); + } + + fn rebuild_facade_preserving_cursor(&mut self, cursor: &mut Cursor) { + let line = cursor.line; + let column = cursor.column; + self.text = Rope::from_str(&self.document.plain_text()); + cursor.line = line.min(self.text.len_lines().saturating_sub(1)); + cursor.column = column.min(self.line_len_chars(cursor.line)); + } + + fn block_index_for_line(&self, line: usize) -> usize { + let mut plain_line = 0usize; + for (index, block) in self.document.blocks.iter().enumerate() { + let next = plain_line + block.plain_line_count(); + if line <= plain_line || line < next { + return index; + } + plain_line = next; + } + self.document.blocks.len() } fn visible_len_lines(&self) -> usize { @@ -315,10 +510,117 @@ impl DocumentBuffer { } } +fn reconcile_facade_document(old: &Document, parsed: Document) -> Document { + if old.blocks.len() != parsed.blocks.len() { + return parsed; + } + + let blocks = parsed + .blocks + .into_iter() + .enumerate() + .map(|(index, parsed_block)| { + let Some(old_block) = old.blocks.get(index) else { + return parsed_block; + }; + reconcile_block(old_block, parsed_block) + }) + .collect(); + + Document { blocks } +} + +fn reconcile_block(old: &Block, parsed: Block) -> Block { + match (&old, parsed) { + (_, semantic @ Block::Heading { .. }) + | (_, semantic @ Block::Quote { .. }) + | (_, semantic @ Block::ListItem { .. }) + | (_, semantic @ Block::ChecklistItem { .. }) + | (_, semantic @ Block::CodeFence { .. }) + | (_, semantic @ Block::Table { .. }) + | (_, semantic @ Block::RawMarkdown(_)) => semantic, + (Block::Heading { level, .. }, Block::Paragraph(content)) => Block::Heading { + level: *level, + content, + }, + (Block::Quote { level, .. }, Block::Paragraph(content)) => Block::Quote { + level: *level, + content, + }, + (Block::ListItem { indent, marker, .. }, Block::Paragraph(content)) => Block::ListItem { + indent: *indent, + marker: *marker, + content, + }, + ( + Block::ChecklistItem { + indent, checked, .. + }, + Block::Paragraph(content), + ) => Block::ChecklistItem { + indent: *indent, + checked: *checked, + content, + }, + (_, block) => block, + } +} + fn trim_line_ending_len(line: &str) -> usize { line.trim_end_matches(['\r', '\n']).chars().count() } +fn is_table_content_line(line: &str) -> bool { + let trimmed = line.trim_end_matches(['\r', '\n']).trim(); + if !trimmed.contains('|') || is_table_delimiter_line(trimmed) { + return false; + } + table_cell_ranges(trimmed).len() >= 2 +} + +fn is_table_delimiter_line(line: &str) -> bool { + let cells = line + .trim_matches('|') + .split('|') + .map(str::trim) + .collect::>(); + !cells.is_empty() + && cells.iter().all(|cell| { + let dashes = cell.trim_matches(':'); + dashes.len() >= 3 && dashes.chars().all(|ch| ch == '-') + }) +} + +fn table_cell_ranges(line: &str) -> Vec<(usize, usize)> { + let chars = line + .trim_end_matches(['\r', '\n']) + .chars() + .collect::>(); + let pipes = chars + .iter() + .enumerate() + .filter_map(|(index, ch)| (*ch == '|').then_some(index)) + .collect::>(); + if pipes.len() < 2 { + return Vec::new(); + } + + pipes + .windows(2) + .filter_map(|window| { + let mut start = window[0] + 1; + let mut end = window[1]; + while start < end && chars[start].is_whitespace() { + start += 1; + } + while end > start && chars[end - 1].is_whitespace() { + end -= 1; + } + Some((start, end)) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -382,15 +684,57 @@ mod tests { buffer.insert_str(&mut cursor, "- [ ] todo"); buffer.undo_stack.clear(); buffer.dirty = false; - cursor = Cursor { line: 0, column: 3 }; - let start = buffer.char_index(cursor); + cursor = Cursor { line: 0, column: 0 }; - buffer.replace_range(start, start + 1, "x", &mut cursor); - assert_eq!(buffer.as_string(), "- [x] todo"); + assert!(buffer.toggle_checkbox_at_line(0, &mut cursor)); + assert_eq!(buffer.as_string(), "[x] todo"); + assert_eq!(buffer.markdown_string(), "- [x] todo"); assert!(buffer.undo(&mut cursor)); - assert_eq!(buffer.as_string(), "- [ ] todo"); - assert_eq!(cursor, Cursor { line: 0, column: 3 }); + assert_eq!(buffer.as_string(), "[ ] todo"); + assert_eq!(buffer.markdown_string(), "- [ ] todo"); + assert_eq!(cursor, Cursor { line: 0, column: 0 }); + } + + #[test] + fn markdown_shortcut_becomes_facade_heading() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + + buffer.insert_str(&mut cursor, "# Heading"); + + assert_eq!(buffer.as_string(), "Heading"); + assert_eq!(buffer.markdown_string(), "# Heading"); + assert_eq!(cursor, Cursor { line: 0, column: 7 }); + } + + #[test] + fn selected_range_serializes_back_to_markdown() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "# Heading"); + + let selected = + buffer.selected_markdown(Cursor { line: 0, column: 0 }, Cursor { line: 0, column: 7 }); + + assert_eq!(selected.as_deref(), Some("# Heading")); + } + + #[test] + fn table_cell_navigation_moves_between_content_cells() { + let mut buffer = DocumentBuffer::empty(); + let mut cursor = Cursor::default(); + buffer.insert_str(&mut cursor, "| A | B |\n| --- | --- |\n| x | y |"); + cursor = Cursor { line: 0, column: 2 }; + + assert!(buffer.move_table_cell(&mut cursor, 1)); + assert_eq!(cursor, Cursor { line: 0, column: 8 }); + + assert!(buffer.move_table_cell(&mut cursor, 1)); + assert_eq!(cursor, Cursor { line: 2, column: 2 }); + + assert!(buffer.move_table_cell(&mut cursor, -1)); + assert_eq!(cursor, Cursor { line: 0, column: 8 }); } #[test] diff --git a/src/editor/commands.rs b/src/editor/commands.rs index 331991f..68b6bdf 100644 --- a/src/editor/commands.rs +++ b/src/editor/commands.rs @@ -6,6 +6,7 @@ pub enum Command { Quit { force: bool }, WriteQuit, Edit(PathBuf), + Table { rows: usize, columns: usize }, Unknown(String), } @@ -17,6 +18,19 @@ pub fn parse_command(input: &str) -> Command { "q" | "quit" => Command::Quit { force: false }, "q!" | "quit!" => Command::Quit { force: true }, "wq" | "x" => Command::WriteQuit, + "table" => Command::Table { + rows: 2, + columns: 2, + }, + _ if trimmed.starts_with("table ") => { + let spec = trimmed + .split_once(' ') + .map(|(_, value)| value.trim()) + .unwrap_or_default(); + parse_table_size(spec) + .map(|(rows, columns)| Command::Table { rows, columns }) + .unwrap_or_else(|| Command::Unknown(trimmed.to_string())) + } _ if trimmed.starts_with("e ") || trimmed.starts_with("edit ") => { let path = trimmed .split_once(' ') @@ -28,6 +42,13 @@ pub fn parse_command(input: &str) -> Command { } } +fn parse_table_size(spec: &str) -> Option<(usize, usize)> { + let (rows, columns) = spec.split_once('x').or_else(|| spec.split_once('X'))?; + let rows = rows.trim().parse().ok()?; + let columns = columns.trim().parse().ok()?; + Some((rows, columns)) +} + #[cfg(test)] mod tests { use super::*; @@ -37,4 +58,22 @@ mod tests { assert_eq!(parse_command("wq"), Command::WriteQuit); assert_eq!(parse_command("q!"), Command::Quit { force: true }); } + + #[test] + fn parses_table_commands() { + assert_eq!( + parse_command("table"), + Command::Table { + rows: 2, + columns: 2 + } + ); + assert_eq!( + parse_command("table 3x4"), + Command::Table { + rows: 3, + columns: 4 + } + ); + } } diff --git a/src/editor/render.rs b/src/editor/render.rs index bdb9db4..bd5faa3 100644 --- a/src/editor/render.rs +++ b/src/editor/render.rs @@ -68,7 +68,8 @@ pub fn visible_rows( } fn is_checked_checkbox(text: &str) -> bool { - text.trim_start().starts_with("- [x] ") + let text = text.trim_start(); + text.starts_with("- [x] ") || text.starts_with("[x] ") } /// Returns the wrap segment index (0-based) that contains the given column. @@ -131,6 +132,9 @@ pub fn detect_list_marker(text: &str) -> usize { if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") { return ws + 6; } + if trimmed.starts_with("[ ] ") || trimmed.starts_with("[x] ") { + return ws + 4; + } if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { return ws + 2; } @@ -226,7 +230,7 @@ mod tests { assert_eq!(rows.len(), 2); assert_eq!(rows[1].line_number, 1); - assert_eq!(rows[1].full_text, "- [ ] "); + assert_eq!(rows[1].full_text, "[ ] "); assert_eq!(rows[1].wrap_index, 0); assert!(!rows[1].completed); } diff --git a/src/main.rs b/src/main.rs index e26cd23..8509dcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod config; +mod document; mod editor; mod fs; mod markdown; diff --git a/src/markdown/inline.rs b/src/markdown/inline.rs index 1a1a4c9..f55486f 100644 --- a/src/markdown/inline.rs +++ b/src/markdown/inline.rs @@ -19,6 +19,7 @@ pub struct InlineLink { } impl InlineLink { + #[cfg(test)] pub fn contains_column(&self, column: usize) -> bool { column >= self.source_start && column < self.source_end } @@ -72,6 +73,7 @@ pub fn links(source: &str) -> Vec { links } +#[cfg(test)] pub fn link_at_column(source: &str, column: usize) -> Option { links(source) .into_iter() diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 322a631..04ff141 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -9,6 +9,7 @@ use ratatui::{ use crate::{ app::{App, Mode, SearchMatch, TextSelection}, config::theme::Theme, + document::Block, editor::render::{ column_in_wrap_segment, detect_list_marker, visible_rows, wrap_index_for_column, wrap_line, }, @@ -72,6 +73,7 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { let wrap_index_of_cursor = wrap_index_for_column(&cursor_line_text, app.cursor.column, text_width); let mut cursor_visual_y: usize = 0; + let mut cursor_visual_x: Option = None; let mut cursor_found = false; let height = page.height as usize; @@ -81,20 +83,27 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { row.line_number == app.cursor.line && row.wrap_index == wrap_index_of_cursor; let active = row.line_number == app.cursor.line; - let table_row = (!active) - .then(|| { - table_layout.render_row_segment( - row.line_number, - &row.full_text, - text_width, - theme, - row.wrap_index, - ) - }) - .flatten(); + let table_row = table_layout.render_row_segment( + row.line_number, + &row.full_text, + text_width, + theme, + row.wrap_index, + ); let (mut line, source_map) = if let Some(rendered) = table_row { (rendered.line, Some(rendered.source_map)) + } else if let Some(block) = app.buffer.block_for_line(row.line_number) { + ( + render_document_segment( + block, + &row.full_text, + row.source_start, + row.source_end, + theme, + ), + None, + ) } else { ( render_markdown_segment_with_completion( @@ -161,6 +170,16 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { if is_cursor_row && !cursor_found && lines.len() < height { cursor_visual_y = lines.len(); + if let Some(source_map) = &source_map { + let source_column = app.cursor.column; + let mapped_column = source_map + .iter() + .position(|source_index| { + source_index.is_some_and(|index| index >= source_column) + }) + .unwrap_or(source_map.len()); + cursor_visual_x = Some(gutter_width + mapped_column as u16); + } cursor_found = true; } @@ -177,14 +196,45 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { } else { 0 }; - let x = column_in_wrap_segment(&cursor_line_text, app.cursor.column, text_width) as u16 - + gutter_width - + cursor_indent as u16; + let x = cursor_visual_x.unwrap_or_else(|| { + column_in_wrap_segment(&cursor_line_text, app.cursor.column, text_width) as u16 + + gutter_width + + cursor_indent as u16 + }); let y = cursor_visual_y as u16; frame.set_cursor_position(Position::new(page.x + x, page.y + y)); } } +fn render_document_segment( + block: &Block, + source: &str, + segment_start: usize, + segment_end: usize, + theme: Theme, +) -> Line<'static> { + let text = char_slice(source, segment_start, segment_end); + match block { + Block::Heading { .. } => Line::from(Span::styled(text, theme.heading)), + Block::Quote { .. } => Line::from(Span::styled(text, theme.quote)), + Block::CodeFence { .. } => Line::from(Span::styled(text, theme.inline_code)), + Block::RawMarkdown(_) => Line::from(Span::styled(text, theme.muted)), + Block::ListItem { .. } | Block::ChecklistItem { .. } => { + let marker_len = text + .find(' ') + .map(|index| index + 1) + .unwrap_or_else(|| text.chars().count()); + let marker = char_slice(&text, 0, marker_len); + let body = char_slice(&text, marker_len, text.chars().count()); + Line::from(vec![ + Span::styled(marker, theme.list_marker), + Span::styled(body, Style::default().fg(theme.text)), + ]) + } + _ => Line::from(Span::styled(text, Style::default().fg(theme.text))), + } +} + fn selected_line(mut line: Line<'static>, theme: Theme) -> Line<'static> { line.style = theme.selection; for span in &mut line.spans { From a76da0766cc5871d702014004e8033648c3ca753 Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Tue, 19 May 2026 21:22:42 +0100 Subject: [PATCH 2/2] feat: split command surfaces --- src/app.rs | 275 ++++++++++++++++++++++++++++++++++++++++------- src/ui/sheet.rs | 71 ++++++++---- src/ui/status.rs | 2 + 3 files changed, 289 insertions(+), 59 deletions(-) diff --git a/src/app.rs b/src/app.rs index e6bb6b6..f82d5c5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,8 @@ pub enum Mode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandPrompt { Command, + File, + Palette, Search, } @@ -253,8 +255,13 @@ impl App { return Ok(()); } - if self.mode != Mode::CommandLine && is_command_sheet_shortcut(key) { - self.enter_command_sheet(CommandPrompt::Command); + if self.mode != Mode::CommandLine && is_file_picker_shortcut(key) { + self.enter_command_sheet(CommandPrompt::File); + return Ok(()); + } + + if self.mode != Mode::CommandLine && is_command_palette_shortcut(key) { + self.enter_command_sheet(CommandPrompt::Palette); return Ok(()); } @@ -984,6 +991,33 @@ impl App { return self.execute_search_query(&input, selected); } + if matches!(self.sheet.prompt, CommandPrompt::File) { + if let Some(item) = selected { + return self.execute_sheet_item(item); + } + if let Some(path) = resolve_command_path_input(&input, &self.notes_dir) { + self.finish_command_sheet(); + return self.open_path(&path); + } + self.finish_command_sheet(); + self.set_status(format!("No file match: {input}")); + return Ok(()); + } + + if matches!(self.sheet.prompt, CommandPrompt::Palette) { + let command = parse_command(&input); + if !matches!(command, Command::Unknown(_)) { + self.finish_command_sheet(); + return self.execute_command(command); + } + if let Some(item) = selected { + return self.execute_sheet_item(item); + } + self.finish_command_sheet(); + self.set_status(format!("No command match: {input}")); + return Ok(()); + } + if let Some(search_query) = input.strip_prefix('/') { return self.execute_search_query(search_query.trim(), selected); } @@ -1654,16 +1688,29 @@ impl App { fn refresh_sheet_items(&mut self) { let input = self.command_line.trim().to_string(); - let mut items = if matches!(self.sheet.prompt, CommandPrompt::Search) { - self.preview_search(&input); - search_sheet_items(&self.buffer, &input) - } else if let Some(search_query) = input.strip_prefix('/') { - let query = search_query.trim(); - self.preview_search(query); - search_sheet_items(&self.buffer, query) - } else { - self.clear_search(); - command_sheet_items(&input, &self.notes_dir, &self.file_tree) + let mut items = match self.sheet.prompt { + CommandPrompt::Search => { + self.preview_search(&input); + search_sheet_items(&self.buffer, &input) + } + CommandPrompt::File => { + self.clear_search(); + file_sheet_items(&input, &self.notes_dir, &self.file_tree) + } + CommandPrompt::Palette => { + self.clear_search(); + palette_sheet_items(&input) + } + CommandPrompt::Command => { + if let Some(search_query) = input.strip_prefix('/') { + let query = search_query.trim(); + self.preview_search(query); + search_sheet_items(&self.buffer, query) + } else { + self.clear_search(); + command_sheet_items(&input, &self.notes_dir, &self.file_tree) + } + } }; items.truncate(128); @@ -1900,17 +1947,30 @@ fn is_text_input_key(key: KeyEvent) -> bool { .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) } -fn is_command_sheet_shortcut(key: KeyEvent) -> bool { +fn is_file_picker_shortcut(key: KeyEvent) -> bool { + is_primary_p_shortcut(key) && !key.modifiers.contains(KeyModifiers::SHIFT) && !is_upper_p(key) +} + +fn is_command_palette_shortcut(key: KeyEvent) -> bool { + is_primary_p_shortcut(key) && (key.modifiers.contains(KeyModifiers::SHIFT) || is_upper_p(key)) +} + +fn is_primary_p_shortcut(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P')) && key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::SUPER) } +fn is_upper_p(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::Char('P')) +} + #[derive(Debug, Clone, Copy)] struct CommandCandidate { replacement: &'static str, label: &'static str, + command_label: &'static str, detail: &'static str, aliases: &'static [&'static str], action: CommandCandidateAction, @@ -1926,51 +1986,58 @@ enum CommandCandidateAction { const COMMAND_CANDIDATES: &[CommandCandidate] = &[ CommandCandidate { replacement: "w", - label: "write", + label: "Save File", + command_label: ":w", detail: "Save current file", aliases: &["w", "write", "save"], action: CommandCandidateAction::Command("w"), }, CommandCandidate { replacement: "q", - label: "quit", + label: "Quit", + command_label: ":q", detail: "Quit if there are no unsaved changes", aliases: &["q", "quit", "close"], action: CommandCandidateAction::Command("q"), }, CommandCandidate { replacement: "q!", - label: "quit!", + label: "Force Quit", + command_label: ":q!", detail: "Quit and discard unsaved changes", aliases: &["q!", "quit!", "force quit"], action: CommandCandidateAction::Command("q!"), }, CommandCandidate { replacement: "wq", - label: "write quit", + label: "Save And Quit", + command_label: ":wq", detail: "Save current file and quit", aliases: &["wq", "x", "write quit", "save quit"], action: CommandCandidateAction::Command("wq"), }, CommandCandidate { replacement: "e ", - label: "edit", + label: "Open File", + command_label: ":edit", detail: "Open a file path", aliases: &["e", "edit", "open", "file"], action: CommandCandidateAction::Complete("e "), }, CommandCandidate { replacement: "/", - label: "search", + label: "Find In Document", + command_label: "/", detail: "Find text in the current document", aliases: &["/", "search", "find"], action: CommandCandidateAction::BeginSearch, }, CommandCandidate { replacement: "table", - label: "table", - detail: "Insert a Markdown table", - aliases: &["table", "grid"], + label: "Insert Table", + command_label: ":table", + detail: "Insert a 2x2 table", + aliases: &["table", "insert table", "grid"], action: CommandCandidateAction::Command("table"), }, ]; @@ -1981,34 +2048,95 @@ fn command_sheet_items( file_tree: &FileTree, ) -> Vec<(usize, SheetItem)> { let mut items = Vec::new(); + let is_editing_path = is_edit_command_input(input); + + if !is_editing_path { + for candidate in COMMAND_CANDIDATES { + if let Some(score) = score_command_candidate(input, candidate) { + items.push((score, command_sheet_item(candidate, false))); + } + } + } + + let file_query = file_query_for_command_input(input); + if is_editing_path { + for (score, mut item) in file_sheet_items(file_query, notes_dir, file_tree) { + item.replacement = edit_replacement(input, &item.replacement); + items.push((score, item)); + } + } + + items.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.label.cmp(&right.1.label)) + .then_with(|| left.1.detail.cmp(&right.1.detail)) + }); + items +} +fn palette_sheet_items(input: &str) -> Vec<(usize, SheetItem)> { + let mut items = Vec::new(); for candidate in COMMAND_CANDIDATES { + if matches!(candidate.action, CommandCandidateAction::Complete(_)) { + continue; + } if let Some(score) = score_command_candidate(input, candidate) { - items.push((1_000 + score, command_sheet_item(candidate))); + items.push((score, command_sheet_item(candidate, true))); } } - let file_query = file_query_for_command_input(input); + items.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| left.1.label.cmp(&right.1.label)) + }); + items +} + +fn file_sheet_items( + input: &str, + notes_dir: &Path, + file_tree: &FileTree, +) -> Vec<(usize, SheetItem)> { + let mut items = Vec::new(); + let query = input.trim(); + for entry in file_tree.entries.iter().filter(|entry| !entry.is_dir) { - if let Some(score) = score_file_entry(file_query, notes_dir, entry) { + if let Some(score) = score_file_entry(query, notes_dir, entry) { let relative = relative_path_label(notes_dir, &entry.path); items.push(( score, SheetItem { kind: SheetItemKind::File, label: entry.display_name.clone(), - detail: relative.clone(), - replacement: if input.starts_with("e ") || input.starts_with("edit ") { - format!("e {relative}") - } else { - relative - }, + detail: file_detail(notes_dir, entry, &relative), + replacement: relative, action: SheetAction::File(entry.path.clone()), }, )); } } + if let Some(path) = resolve_command_path_input(query, notes_dir) { + let already_listed = items + .iter() + .any(|(_, item)| item.action == SheetAction::File(path.clone())); + if !already_listed { + let relative = relative_path_label(notes_dir, &path); + items.push(( + 0, + SheetItem { + kind: SheetItemKind::File, + label: relative.clone(), + detail: "Create new file".to_string(), + replacement: relative, + action: SheetAction::File(path), + }, + )); + } + } + items.sort_by(|left, right| { left.0 .cmp(&right.0) @@ -2018,7 +2146,7 @@ fn command_sheet_items( items } -fn command_sheet_item(candidate: &CommandCandidate) -> SheetItem { +fn command_sheet_item(candidate: &CommandCandidate, palette: bool) -> SheetItem { let action = match candidate.action { CommandCandidateAction::Command(command) => SheetAction::Command(command.to_string()), CommandCandidateAction::Complete(value) => SheetAction::Complete(value.to_string()), @@ -2027,7 +2155,12 @@ fn command_sheet_item(candidate: &CommandCandidate) -> SheetItem { SheetItem { kind: SheetItemKind::Command, - label: candidate.label.to_string(), + label: if palette { + candidate.label + } else { + candidate.command_label + } + .to_string(), detail: candidate.detail.to_string(), replacement: candidate.replacement.to_string(), action, @@ -2057,6 +2190,37 @@ fn file_query_for_command_input(input: &str) -> &str { input } +fn is_edit_command_input(input: &str) -> bool { + let input = input.trim(); + if input == "e" || input == "edit" { + return true; + } + + input + .split_once(' ') + .is_some_and(|(command, _)| matches!(command, "e" | "edit")) +} + +fn edit_replacement(input: &str, path: &str) -> String { + let command = input + .trim() + .split_once(' ') + .map(|(command, _)| command) + .filter(|command| matches!(*command, "e" | "edit")) + .unwrap_or("e"); + format!("{command} {path}") +} + +fn file_detail(notes_dir: &Path, entry: &crate::fs::tree::TreeEntry, relative: &str) -> String { + entry + .path + .parent() + .and_then(|parent| parent.strip_prefix(notes_dir).ok()) + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.display().to_string()) + .unwrap_or_else(|| relative.to_string()) +} + fn resolve_command_path_input(input: &str, notes_dir: &Path) -> Option { let input = input.trim(); if input.is_empty() || input.contains(' ') { @@ -3200,16 +3364,15 @@ mod tests { } #[test] - fn primary_p_opens_command_sheet_and_esc_closes_it() { + fn primary_p_opens_file_picker_and_esc_closes_it() { let mut app = test_app("text"); app.status_message = "ready".to_string(); press_modified(&mut app, KeyCode::Char('p'), KeyModifiers::SUPER); assert_eq!(app.mode, Mode::CommandLine); - assert_eq!(app.sheet.prompt, CommandPrompt::Command); + assert_eq!(app.sheet.prompt, CommandPrompt::File); assert_eq!(app.command_line, ""); - assert!(!app.sheet.items.is_empty()); press(&mut app, KeyCode::Esc); @@ -3219,7 +3382,27 @@ mod tests { } #[test] - fn command_sheet_filters_matches_and_opens_selected_file() { + fn shifted_primary_p_opens_command_palette() { + let mut app = test_app("text"); + + press_modified( + &mut app, + KeyCode::Char('P'), + KeyModifiers::SUPER | KeyModifiers::SHIFT, + ); + + assert_eq!(app.mode, Mode::CommandLine); + assert_eq!(app.sheet.prompt, CommandPrompt::Palette); + assert!( + app.sheet + .items + .iter() + .any(|item| item.label == "Insert Table") + ); + } + + #[test] + fn file_picker_filters_matches_and_opens_selected_file() { let mut app = test_app("text"); app.notes_dir = PathBuf::from("/notes"); app.file_tree.entries = vec![ @@ -3248,7 +3431,7 @@ mod tests { } #[test] - fn command_sheet_lists_files_before_commands() { + fn command_line_lists_files_only_after_edit_command() { let mut app = test_app("text"); app.notes_dir = PathBuf::from("/notes"); app.file_tree.entries = vec![TreeEntry { @@ -3259,6 +3442,16 @@ mod tests { press(&mut app, KeyCode::Char(':')); + assert!( + app.sheet + .items + .iter() + .all(|item| item.kind == SheetItemKind::Command) + ); + + press(&mut app, KeyCode::Char('e')); + press(&mut app, KeyCode::Char(' ')); + assert_eq!(app.sheet.items[0].kind, SheetItemKind::File); assert_eq!(app.sheet.items[0].label, "CHANGELOG.md"); } @@ -3317,7 +3510,7 @@ mod tests { } #[test] - fn command_sheet_can_complete_selected_files_into_the_input() { + fn command_line_can_complete_selected_files_after_edit_command() { let mut app = test_app("text"); app.notes_dir = PathBuf::from("/notes"); app.file_tree.entries = vec![TreeEntry { @@ -3327,12 +3520,14 @@ mod tests { }]; press(&mut app, KeyCode::Char(':')); + press(&mut app, KeyCode::Char('e')); + press(&mut app, KeyCode::Char(' ')); for ch in "gla".chars() { press(&mut app, KeyCode::Char(ch)); } press(&mut app, KeyCode::Right); - assert_eq!(app.command_line, "projects/glass.md"); + assert_eq!(app.command_line, "e projects/glass.md"); } #[test] diff --git a/src/ui/sheet.rs b/src/ui/sheet.rs index ad4955a..61e313c 100644 --- a/src/ui/sheet.rs +++ b/src/ui/sheet.rs @@ -6,7 +6,7 @@ use ratatui::{ }; use crate::{ - app::{App, SheetItemKind}, + app::{App, CommandPrompt, SheetItemKind}, config::theme::Theme, }; @@ -40,25 +40,53 @@ fn sheet_items(app: &App, theme: Theme, start: usize, end: usize) -> Vec "CMD", - SheetItemKind::File => "FILE", - SheetItemKind::Search => "FIND", + let line = match app.sheet.prompt { + CommandPrompt::File => file_item_line(item, theme), + CommandPrompt::Palette => action_item_line(item, theme), + CommandPrompt::Search => search_item_line(item, theme), + CommandPrompt::Command => command_item_line(item, theme), }; - let action = match item.kind { - SheetItemKind::Command => item.label.clone(), - SheetItemKind::File => "navigate".to_string(), - SheetItemKind::Search => item.label.clone(), - }; - ListItem::new(Line::from(vec![ - Span::styled(format!("{kind:<4} "), theme.status), - Span::styled(action, theme.status), - Span::styled(format!(" {}", item.detail), theme.status), - ])) + ListItem::new(line) }) .collect() } +fn file_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + if item.detail.is_empty() || item.detail == item.label { + return Line::from(Span::styled(item.label.clone(), theme.status)); + } + + Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]) +} + +fn action_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]) +} + +fn search_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]) +} + +fn command_item_line(item: &crate::app::SheetItem, theme: Theme) -> Line<'static> { + match item.kind { + SheetItemKind::Command => Line::from(vec![ + Span::styled(item.label.clone(), theme.status), + Span::styled(format!(" {}", item.detail), theme.status), + ]), + SheetItemKind::File => file_item_line(item, theme), + SheetItemKind::Search => search_item_line(item, theme), + } +} + fn sheet_window(app: &App, visible_height: usize) -> (usize, usize) { let len = app.sheet.items.len(); if len == 0 { @@ -76,9 +104,14 @@ fn sheet_window(app: &App, visible_height: usize) -> (usize, usize) { } fn empty_message(app: &App) -> &'static str { - if app.command_line.trim().is_empty() { - "Type a command, file, or search query" - } else { - "No matches" + if !app.command_line.trim().is_empty() { + return "No matches"; + } + + match app.sheet.prompt { + CommandPrompt::Command => "Type a command", + CommandPrompt::File => "Type a file name", + CommandPrompt::Palette => "Type an action", + CommandPrompt::Search => "Type a search query", } } diff --git a/src/ui/status.rs b/src/ui/status.rs index acec863..37eaeef 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -72,6 +72,8 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, app: &App, theme: Theme) { fn command_prompt(app: &App) -> (&'static str, u16) { match app.sheet.prompt { CommandPrompt::Command => (":", 1), + CommandPrompt::File => ("Open ", 5), + CommandPrompt::Palette => ("> ", 2), CommandPrompt::Search => ("/", 1), } }