15d7f5e873
- 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>
167 lines
6.1 KiB
Rust
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 })
|
|
}
|