Files
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity
- Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys
- Visual view: tables, note embeds, %% comments, wrapping property chips
- Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin
- Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:36:00 -03:00

167 lines
6.1 KiB
Rust

//! Inline marks (bold, italic, ...) as pure toggles on a text selection.
use std::ops::Range;
use crate::{Error, Result};
/// An inline mark the toolbar can toggle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mark {
/// `**text**`
Bold,
/// `*text*`
Italic,
/// `~~text~~`
Strike,
/// `` `text` ``
Code,
/// `[text](url)`
Link,
/// `==text==`
Highlight,
}
impl Mark {
/// Every mark, in toolbar order.
pub const ALL: [Self; 6] = [Self::Bold, Self::Italic, Self::Strike, Self::Code, Self::Link, Self::Highlight];
fn marker(self) -> &'static str {
match self {
Self::Bold => "**",
Self::Italic => "*",
Self::Strike => "~~",
Self::Code => "`",
Self::Highlight => "==",
Self::Link => "[",
}
}
}
/// Result of [`toggle_mark`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toggled {
/// Whole text after the toggle.
pub text: String,
/// Selection after the toggle (collapsed when a link waits for its url).
pub sel: Range<usize>,
/// Byte range of the OLD text replaced, and its replacement: one edit.
pub replace: (Range<usize>, String),
}
/// The marker bytes around a wrapped selection.
struct Wrap {
open: Range<usize>,
close: Range<usize>,
}
fn check(text: &str, sel: &Range<usize>) -> Result<()> {
let ok = sel.start <= sel.end && text.is_char_boundary(sel.start) && text.is_char_boundary(sel.end) && sel.end <= text.len();
if ok { Ok(()) } else { Err(Error::CharBoundary { start: sel.start, end: sel.end }) }
}
fn same_line(s: &str) -> bool {
!s.contains('\n')
}
fn link_wrap(text: &str, sel: &Range<usize>) -> Option<Wrap> {
let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?);
let picked = text.get(sel.clone())?;
// selection includes the whole `[text](url)`
if let Some(p) = picked.rfind("](").filter(|_| picked.starts_with('[') && picked.ends_with(')')) {
return Some(Wrap { open: sel.start..sel.start + 1, close: sel.start + p..sel.end });
}
// selection is the text between `[` and `](url)`
if before.ends_with('[') && after.starts_with("](") {
let end = after.find(')').filter(|e| same_line(after.get(..*e).unwrap_or("")))?;
return Some(Wrap { open: sel.start - 1..sel.start, close: sel.end..sel.end + end + 1 });
}
// caret or selection inside the url
let p = before.rfind("](").filter(|p| same_line(before.get(*p..).unwrap_or("")) && !before.get(*p..).unwrap_or("").contains(')'))?;
let lb = before.get(..p)?.rfind('[').filter(|b| same_line(before.get(*b..p).unwrap_or("")))?;
let end = after.find(')').filter(|e| same_line(after.get(..*e).unwrap_or("")))?;
Some(Wrap { open: lb..lb + 1, close: p..sel.end + end + 1 })
}
fn star_wrap(text: &str, sel: &Range<usize>, mark: Mark) -> Option<Wrap> {
let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?);
let picked = text.get(sel.clone())?;
let run = |it: &mut dyn Iterator<Item = char>| it.take_while(|c| *c == '*').count();
let (a, b) = (run(&mut before.chars().rev()), run(&mut after.chars()));
let k = a.min(b);
let (lead, trail) = (run(&mut picked.chars()), run(&mut picked.chars().rev()));
let (k_in, inside) = if k > 0 { (k, true) } else { (lead.min(trail), false) };
if !inside && picked.len() <= lead + trail {
return None;
}
let (active, take) = match mark {
Mark::Bold => (k_in >= 2, 2),
_ => (k_in % 2 == 1, 1),
};
if !active {
return None;
}
Some(if inside {
Wrap { open: sel.start - take..sel.start, close: sel.end..sel.end + take }
} else {
Wrap { open: sel.start..sel.start + take, close: sel.end - take..sel.end }
})
}
fn find_wrap(text: &str, sel: &Range<usize>, mark: Mark) -> Option<Wrap> {
match mark {
Mark::Link => link_wrap(text, sel),
Mark::Bold | Mark::Italic => star_wrap(text, sel, mark),
_ => {
let m = mark.marker();
let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?);
let picked = text.get(sel.clone())?;
if before.ends_with(m) && after.starts_with(m) {
return Some(Wrap { open: sel.start - m.len()..sel.start, close: sel.end..sel.end + m.len() });
}
let ok = picked.len() >= 2 * m.len() && picked.starts_with(m) && picked.ends_with(m);
ok.then(|| Wrap { open: sel.start..sel.start + m.len(), close: sel.end - m.len()..sel.end })
}
}
}
/// Marks active around `sel` (wrapped inside or including the markers).
///
/// Out-of-range or non char boundary selections report nothing.
#[must_use]
pub fn active_marks(text: &str, sel: Range<usize>) -> Vec<Mark> {
if check(text, &sel).is_err() {
return Vec::new();
}
Mark::ALL.into_iter().filter(|m| find_wrap(text, &sel, *m).is_some()).collect()
}
/// Wrap `sel` in `mark`, or unwrap it when it is already wrapped.
///
/// A link wraps as `[text]()` with the selection collapsed in the url.
///
/// # Errors
/// [`Error::CharBoundary`] if `sel` is out of range or splits a character.
pub fn toggle_mark(text: &str, sel: Range<usize>, mark: Mark) -> Result<Toggled> {
check(text, &sel)?;
let inner_of = |r: Range<usize>| text.get(r).unwrap_or("");
if let Some(w) = find_wrap(text, &sel, mark) {
let inner = inner_of(w.open.end..w.close.start).to_owned();
let range = w.open.start..w.close.end;
let sel = range.start..range.start + inner.len();
return finish(text, range, inner, sel);
}
let inner = inner_of(sel.clone());
let m = mark.marker();
if mark == Mark::Link {
let at = sel.start + 1 + inner.len() + 2;
return finish(text, sel.clone(), format!("[{inner}]()"), at..at);
}
let new_sel = sel.start + m.len()..sel.end + m.len();
finish(text, sel.clone(), format!("{m}{inner}{m}"), new_sel)
}
fn finish(text: &str, range: Range<usize>, new: String, sel: Range<usize>) -> Result<Toggled> {
let e = crate::blocks::splice(text, range, new, sel.end)?;
Ok(Toggled { text: e.text, sel, replace: e.replace })
}