//! 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, /// Byte range of the OLD text replaced, and its replacement: one edit. pub replace: (Range, String), } /// The marker bytes around a wrapped selection. struct Wrap { open: Range, close: Range, } fn check(text: &str, sel: &Range) -> 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) -> Option { 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, mark: Mark) -> Option { let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?); let picked = text.get(sel.clone())?; let run = |it: &mut dyn Iterator| 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, mark: Mark) -> Option { 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) -> Vec { 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, mark: Mark) -> Result { check(text, &sel)?; let inner_of = |r: Range| 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, new: String, sel: Range) -> Result { let e = crate::blocks::splice(text, range, new, sel.end)?; Ok(Toggled { text: e.text, sel, replace: e.replace }) }