git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/doc_editor/preview.rs (39.2K)

   1 //! Live preview: how one line of Markdown shows. Pure: text in, styled
   2 //! segments out.
   3 //!
   4 //! Every segment covers a byte range of the line and shows exactly those
   5 //! bytes (a tab shows as one space), so caret and click positions map 1:1
   6 //! between screen and source. Markup is never replaced, only hidden: on a
   7 //! line the caret is NOT on, markers (`**`, `## `, `[[` and a link's target
   8 //! part, a list's `- [ ] `…) have no segment and are drawn as nothing — or
   9 //! as a decoration the line carries (a bullet, a checkbox, a quote bar, a
  10 //! rule). On the caret's line ("active") the markers come back, dimmed,
  11 //! as Obsidian's live preview does.
  12 //!
  13 //! Block context (fenced code, frontmatter) spans lines, so it is computed
  14 //! for the whole document first ([`contexts`]); everything else is per line.
  15 
  16 use std::ops::Range;
  17 
  18 /// What a line is inside, from the lines before it.
  19 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
  20 pub enum Context {
  21     Normal,
  22     /// Inside a fenced code block (not the fences themselves).
  23     Code,
  24     /// A ``` or ~~~ fence line, opening or closing.
  25     Fence,
  26     /// Inside the frontmatter, or one of its `---` lines.
  27     Frontmatter,
  28 }
  29 
  30 /// A line that is nothing but one embed — `![[pic.png|300]]` or
  31 /// `![alt|300x200](pic%20one.png)` — as (link text, wanted width, wanted
  32 /// height), the size read as Obsidian reads it. `None` for anything else,
  33 /// URLs included.
  34 pub fn standalone_embed(text: &str) -> Option<(String, Option<u32>, Option<u32>)> {
  35     let t = text.trim();
  36     let (target, alias) = if let Some(inner) = t.strip_prefix("![[").and_then(|r| r.strip_suffix("]]")) {
  37         if inner.contains("]]") || inner.contains("[[") {
  38             return None;
  39         }
  40         match inner.split_once('|') {
  41             Some((target, alias)) => (target.to_string(), alias),
  42             None => (inner.to_string(), ""),
  43         }
  44     } else {
  45         let rest = t.strip_prefix("![")?;
  46         let (alt, dest) = rest.split_once("](")?;
  47         let dest = dest.strip_suffix(')')?.trim();
  48         let dest = dest.strip_prefix('<').and_then(|d| d.strip_suffix('>')).unwrap_or(dest);
  49         if dest.contains("://") || dest.contains(')') || alt.contains(']') {
  50             return None;
  51         }
  52         (dest.replace("%20", " "), alt)
  53     };
  54     let target = target.split('#').next().unwrap_or("").trim().to_string();
  55     if target.is_empty() {
  56         return None;
  57     }
  58     let spec = alias.rsplit('|').next().unwrap_or(alias).trim();
  59     let num = |v: &str| v.trim().parse::<u32>().ok().filter(|n| *n > 0);
  60     let (w, h) = match spec.split_once('x') {
  61         Some((w, h)) => match (num(w), num(h)) {
  62             (Some(w), Some(h)) => (Some(w), Some(h)),
  63             _ => (None, None),
  64         },
  65         None => (num(spec), None),
  66     };
  67     Some((target, w, h))
  68 }
  69 
  70 /// Every embed in a line, in order — `![[…]]` and `![…](…)` anywhere in
  71 /// it — read as [`standalone_embed`] reads one.
  72 pub fn inline_embeds(text: &str) -> Vec<(String, Option<u32>, Option<u32>)> {
  73     let mut out = Vec::new();
  74     let mut i = 0;
  75     while let Some(off) = text[i..].find("![") {
  76         let at = i + off;
  77         let rest = &text[at..];
  78         let end = if rest.starts_with("![[") {
  79             rest.find("]]").map(|e| e + 2)
  80         } else {
  81             rest.find("](").and_then(|m| rest[m..].find(')').map(|e| m + e + 1))
  82         };
  83         let Some(end) = end else { break };
  84         if let Some(found) = standalone_embed(&rest[..end]) {
  85             out.push(found);
  86         }
  87         i = at + end;
  88     }
  89     out
  90 }
  91 
  92 /// Whether an embed alias is Obsidian's size form (`300`, `300x200`).
  93 fn embed_size_spec(alias: &str) -> bool {
  94     let num = |v: &str| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit());
  95     match alias.trim().split_once('x') {
  96         Some((w, h)) => num(w.trim()) && num(h.trim()),
  97         None => num(alias.trim()),
  98     }
  99 }
 100 
 101 /// Block context per line: fences toggle code; a `---` first line opens
 102 /// frontmatter, closed by the next `---`.
 103 pub fn contexts(lines: &[String]) -> Vec<Context> {
 104     let mut out = Vec::with_capacity(lines.len());
 105     let mut fence: Option<(char, usize)> = None;
 106     let mut front = lines.first().is_some_and(|l| l.trim_end() == "---");
 107     for (i, line) in lines.iter().enumerate() {
 108         if front {
 109             out.push(Context::Frontmatter);
 110             if i > 0 && line.trim_end() == "---" {
 111                 front = false;
 112             }
 113             continue;
 114         }
 115         let t = line.trim_start();
 116         let run = |c: char| t.chars().take_while(|&x| x == c).count();
 117         match fence {
 118             Some((c, n)) => {
 119                 if run(c) >= n && t.trim_start_matches(c).trim().is_empty() {
 120                     fence = None;
 121                     out.push(Context::Fence);
 122                 } else {
 123                     out.push(Context::Code);
 124                 }
 125             }
 126             None => {
 127                 let (bt, tl) = (run('`'), run('~'));
 128                 if bt >= 3 {
 129                     fence = Some(('`', bt));
 130                     out.push(Context::Fence);
 131                 } else if tl >= 3 {
 132                     fence = Some(('~', tl));
 133                     out.push(Context::Fence);
 134                 } else {
 135                     out.push(Context::Normal);
 136                 }
 137             }
 138         }
 139     }
 140     out
 141 }
 142 
 143 /// A line's block shape.
 144 #[derive(Clone, Debug, PartialEq)]
 145 pub enum Kind {
 146     Plain,
 147     Heading(u8),
 148     /// `> ` nesting depth.
 149     Quote(u8),
 150     /// A list item: indentation level, and its marker.
 151     List { level: u8, marker: Marker },
 152     Rule,
 153     Code,
 154     Fence,
 155     Frontmatter,
 156     Table,
 157     /// A line of the frontmatter shown as the Properties table.
 158     Prop(PropShow),
 159 }
 160 
 161 /// How a frontmatter line draws in the Properties table.
 162 #[derive(Clone, Debug, PartialEq)]
 163 pub enum PropShow {
 164     /// The opening `---`: the table's "Properties" header.
 165     Header,
 166     /// The closing `---`: the rule under the table.
 167     Close,
 168     /// A row: the key in the key column (a list's key is drawn by its first
 169     /// item), the value as segments in the value column; a `true`/`false`
 170     /// value is a checkbox instead (its bytes, and whether it is ticked).
 171     Row { key: Option<String>, check: Option<(Range<usize>, bool)>, empty: bool },
 172     /// Blank, comment, and a list's own key line: no height.
 173     Hidden,
 174 }
 175 
 176 /// The role of each line of a closed leading frontmatter block, `None`
 177 /// outside one (or when it never closes, which shows raw).
 178 #[derive(Clone, Debug, PartialEq)]
 179 pub enum Prop {
 180     Open,
 181     Close,
 182     /// `key: value` on one line (`value` may be empty).
 183     Field { key: Range<usize>, value: Range<usize> },
 184     /// `key:` with list items under it; its first item draws the key.
 185     ListKey,
 186     /// `- item` under a key; the first carries the key line.
 187     Item { value: Range<usize>, key_line: Option<usize> },
 188     Hidden,
 189     /// What the table cannot show (nested maps, block scalars): raw.
 190     Raw,
 191 }
 192 
 193 #[derive(Clone, Debug, PartialEq)]
 194 pub enum Marker {
 195     Bullet,
 196     /// The number as written, with its `.` or `)`.
 197     Number(String),
 198     /// A task's status char, and the byte of that char in the line.
 199     Task(char, usize),
 200 }
 201 
 202 /// A segment's look. `marker` is markup shown on the active line.
 203 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
 204 pub struct Look {
 205     pub bold: bool,
 206     pub italic: bool,
 207     pub code: bool,
 208     pub strike: bool,
 209     pub highlight: bool,
 210     pub link: bool,
 211     pub tag: bool,
 212     pub marker: bool,
 213     pub comment: bool,
 214     /// Code blocks, fences, frontmatter and tables: monospace.
 215     pub mono: bool,
 216     /// The dim text of a done task or a fence line.
 217     pub dim: bool,
 218     /// An item of a list property, drawn as a pill.
 219     pub pill: bool,
 220 }
 221 
 222 #[derive(Clone, Debug, PartialEq)]
 223 pub enum Target {
 224     /// `[[target#sub|…]]` or a relative `[…](path)`.
 225     Note { target: String, subpath: Option<String> },
 226     Url(String),
 227 }
 228 
 229 #[derive(Clone, Debug, PartialEq)]
 230 pub struct Seg {
 231     pub range: Range<usize>,
 232     pub look: Look,
 233     /// The link this segment shows, by index into [`Line::links`].
 234     pub link: Option<usize>,
 235 }
 236 
 237 #[derive(Clone, Debug, PartialEq)]
 238 pub struct Line {
 239     pub kind: Kind,
 240     pub segs: Vec<Seg>,
 241     pub links: Vec<Target>,
 242     /// Where content starts after the hidden prefix (`## `, `> `, a list
 243     /// marker), for a click left of the text and an empty item's caret.
 244     pub content_start: usize,
 245 }
 246 
 247 const BOLD: u16 = 1;
 248 const ITALIC: u16 = 2;
 249 const CODE: u16 = 4;
 250 const STRIKE: u16 = 8;
 251 const HIGHLIGHT: u16 = 16;
 252 const LINK: u16 = 32;
 253 const TAG: u16 = 64;
 254 const MARKER: u16 = 128;
 255 const COMMENT: u16 = 256;
 256 const DIM: u16 = 512;
 257 
 258 /// The fenced code block holding `line` — its opening and closing fence
 259 /// lines (the last line when it never closes) — or `None` outside one.
 260 pub fn fenced_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
 261     if !matches!(ctx.get(line), Some(Context::Code | Context::Fence)) {
 262         return None;
 263     }
 264     let mut open: Option<usize> = None;
 265     for (i, c) in ctx.iter().enumerate() {
 266         match (c, open) {
 267             (Context::Fence, None) => open = Some(i),
 268             (Context::Fence, Some(a)) => {
 269                 if line >= a && line <= i {
 270                     return Some((a, i));
 271                 }
 272                 open = None;
 273             }
 274             _ => {}
 275         }
 276         if i > line && open.is_none() {
 277             break;
 278         }
 279     }
 280     open.map(|a| (a, ctx.len() - 1))
 281 }
 282 
 283 /// The leading frontmatter block (open and close lines) when `line` is
 284 /// in it.
 285 pub fn frontmatter_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
 286     if ctx.get(line) != Some(&Context::Frontmatter) {
 287         return None;
 288     }
 289     let end = ctx.iter().take_while(|c| **c == Context::Frontmatter).count();
 290     Some((0, end - 1))
 291 }
 292 
 293 /// A `key:` at the start of `line`: the key's byte range and where what
 294 /// follows the colon starts.
 295 fn yaml_key(line: &str) -> Option<(Range<usize>, usize)> {
 296     if line.starts_with([' ', '\t', '-', '#']) {
 297         return None;
 298     }
 299     let b = line.as_bytes();
 300     let colon = (0..b.len()).find(|&i| b[i] == b':' && (i + 1 == b.len() || b[i + 1] == b' ' || b[i + 1] == b'\t'))?;
 301     let key = line[..colon].trim_end();
 302     (!key.is_empty()).then_some((0..key.len(), colon + 1))
 303 }
 304 
 305 /// The range of `line[from..]` without surrounding whitespace.
 306 fn trimmed(line: &str, from: usize) -> Range<usize> {
 307     let rest = &line[from..];
 308     let start = from + (rest.len() - rest.trim_start().len());
 309     start..from + rest.trim_end().len()
 310 }
 311 
 312 /// Roles for the frontmatter lines (see [`Prop`]), per line of the
 313 /// document; `None` past the block.
 314 pub fn properties(lines: &[String], ctx: &[Context]) -> Vec<Option<Prop>> {
 315     let mut out = vec![None; lines.len()];
 316     let Some((_, end)) = frontmatter_block(ctx, 0) else { return out };
 317     if end == 0 || lines[end].trim_end() != "---" {
 318         return out; // never closed: raw
 319     }
 320     out[0] = Some(Prop::Open);
 321     out[end] = Some(Prop::Close);
 322     let mut list_key: Option<usize> = None;
 323     let mut raw_block = false;
 324     for i in 1..end {
 325         let line = &lines[i];
 326         let t = line.trim_start();
 327         let indented = t.len() < line.len();
 328         let role = if t.is_empty() || t.starts_with('#') {
 329             Prop::Hidden
 330         } else if raw_block && indented {
 331             Prop::Raw
 332         } else if let Some((key, after)) = yaml_key(line) {
 333             raw_block = false;
 334             let value = trimmed(line, after);
 335             let v = &line[value.clone()];
 336             if v.starts_with(['|', '>']) {
 337                 raw_block = true;
 338                 Prop::Raw
 339             } else if v.is_empty() {
 340                 // A list follows, or a nested map (raw), or nothing.
 341                 let next = lines[i + 1..end].iter().find(|l| !l.trim().is_empty());
 342                 match next.map(|l| l.trim_start()) {
 343                     Some(n) if n == "-" || n.starts_with("- ") => {
 344                         list_key = Some(i);
 345                         Prop::ListKey
 346                     }
 347                     Some(_) if next.is_some_and(|l| l.starts_with([' ', '\t'])) => {
 348                         raw_block = true;
 349                         Prop::Field { key, value }
 350                     }
 351                     _ => Prop::Field { key, value },
 352                 }
 353             } else {
 354                 Prop::Field { key, value }
 355             }
 356         } else if t == "-" || t.starts_with("- ") {
 357             let value = trimmed(line, line.len() - t.len() + 1);
 358             Prop::Item { value, key_line: list_key.take() }
 359         } else {
 360             Prop::Raw
 361         };
 362         out[i] = Some(role);
 363     }
 364     out
 365 }
 366 
 367 /// The key of a `key:` line, for a list item drawing its key.
 368 pub fn prop_key(line: &str) -> Option<&str> {
 369     yaml_key(line).map(|(k, _)| &line[k])
 370 }
 371 
 372 /// A quoted YAML scalar's inside.
 373 fn unquote(line: &str, r: Range<usize>) -> Range<usize> {
 374     let v = &line[r.clone()];
 375     if v.len() >= 2 && ((v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\''))) {
 376         r.start + 1..r.end - 1
 377     } else {
 378         r
 379     }
 380 }
 381 
 382 /// The items of an inline `[a, "b", [[c]]]` list: split on commas outside
 383 /// quotes and `[[…]]`, trimmed and unquoted.
 384 fn flow_items(line: &str, r: Range<usize>) -> Vec<Range<usize>> {
 385     let inner = r.start + 1..r.end - 1;
 386     let b = line.as_bytes();
 387     let (mut out, mut start, mut quote, mut depth) = (Vec::new(), inner.start, None::<u8>, 0i32);
 388     let mut i = inner.start;
 389     while i <= inner.end {
 390         let c = if i < inner.end { b[i] } else { b',' };
 391         match (quote, c) {
 392             (Some(q), _) if c == q => quote = None,
 393             (Some(_), _) => {}
 394             (None, b'"' | b'\'') => quote = Some(c),
 395             (None, b'[') => depth += 1,
 396             (None, b']') => depth -= 1,
 397             (None, b',') if depth <= 0 => {
 398                 let item = unquote(line, trimmed(&line[..i], start));
 399                 if !item.is_empty() {
 400                     out.push(item);
 401                 }
 402                 start = i + 1;
 403             }
 404             _ => {}
 405         }
 406         i += 1;
 407     }
 408     out
 409 }
 410 
 411 /// Inline-style `line[r]` as part of a property value, segments offset into
 412 /// the line; `pill` marks them as list pills.
 413 fn value_segs(line: &str, r: Range<usize>, pill: bool, segs: &mut Vec<Seg>, links: &mut Vec<Target>) {
 414     let sub = &line[r.clone()];
 415     let styled = style_line(sub, Context::Normal, false);
 416     let base = links.len();
 417     if styled.kind != Kind::Plain || styled.content_start != 0 {
 418         segs.push(Seg { range: r, look: Look { pill, ..Look::default() }, link: None });
 419         return;
 420     }
 421     for s in styled.segs {
 422         let look = Look { pill, ..s.look };
 423         segs.push(Seg { range: s.range.start + r.start..s.range.end + r.start, look, link: s.link.map(|k| k + base) });
 424     }
 425     links.extend(styled.links);
 426 }
 427 
 428 /// Style a frontmatter line as its row of the Properties table. `key` is
 429 /// the key an [`Prop::Item`] draws (its key line's), if it is the first.
 430 pub fn style_property(line: &str, prop: &Prop, key: Option<&str>) -> Line {
 431     let mut segs = Vec::new();
 432     let mut links = Vec::new();
 433     let row = |key: Option<String>, check, empty| Kind::Prop(PropShow::Row { key, check, empty });
 434     let (kind, content_start) = match prop {
 435         Prop::Open => (Kind::Prop(PropShow::Header), 0),
 436         Prop::Close => (Kind::Prop(PropShow::Close), 0),
 437         Prop::Hidden | Prop::ListKey => (Kind::Prop(PropShow::Hidden), 0),
 438         Prop::Raw => return style_line(line, Context::Frontmatter, false),
 439         Prop::Field { key: k, value } => {
 440             let v = &line[value.clone()];
 441             let key = Some(line[k.clone()].to_string());
 442             if v == "true" || v == "false" {
 443                 (row(key, Some((value.clone(), v == "true")), false), value.start)
 444             } else if v.starts_with('[') && v.ends_with(']') && !v.starts_with("[[") {
 445                 let items = flow_items(line, value.clone());
 446                 // The separators hide; the layout pads pills apart.
 447                 for it in &items {
 448                     value_segs(line, it.clone(), true, &mut segs, &mut links);
 449                 }
 450                 (row(key, None, items.is_empty()), value.start)
 451             } else {
 452                 if !value.is_empty() {
 453                     value_segs(line, unquote(line, value.clone()), false, &mut segs, &mut links);
 454                 }
 455                 (row(key, None, value.is_empty()), value.start)
 456             }
 457         }
 458         Prop::Item { value, .. } => {
 459             let key = key.map(str::to_string);
 460             let it = unquote(line, value.clone());
 461             if !it.is_empty() {
 462                 value_segs(line, it, true, &mut segs, &mut links);
 463             }
 464             (row(key, None, value.is_empty()), value.start)
 465         }
 466     };
 467     Line { kind, segs, links, content_start }
 468 }
 469 
 470 /// Style one line. `active` shows its markers; `ctx` is its block context.
 471 pub fn style_line(text: &str, ctx: Context, active: bool) -> Line {
 472     let n = text.len();
 473     let whole = |look: Look| Line { kind: Kind::Plain, segs: vec![Seg { range: 0..n, look, link: None }], links: Vec::new(), content_start: 0 };
 474     match ctx {
 475         Context::Code => return Line { kind: Kind::Code, ..whole(Look { mono: true, ..Look::default() }) },
 476         // A fence hides while the caret is outside its block (the editor
 477         // makes the whole block active), as Obsidian's live preview does.
 478         Context::Fence if !active => return Line { kind: Kind::Fence, segs: Vec::new(), links: Vec::new(), content_start: 0 },
 479         Context::Fence => return Line { kind: Kind::Fence, ..whole(Look { mono: true, dim: true, ..Look::default() }) },
 480         Context::Frontmatter => return Line { kind: Kind::Frontmatter, ..whole(Look { mono: true, dim: true, ..Look::default() }) },
 481         Context::Normal => {}
 482     }
 483     let mut flags = vec![0u16; n];
 484     let mut link_of: Vec<Option<usize>> = vec![None; n];
 485     let mut links: Vec<Target> = Vec::new();
 486     let mut kind = Kind::Plain;
 487     let mut prefix = 0usize;
 488 
 489     let trimmed = text.trim_start();
 490     let indent_bytes = n - trimmed.len();
 491     // A thematic break: three or more of - * _ and nothing else.
 492     let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
 493     if compact.len() >= 3 && (compact.chars().all(|c| c == '-') || compact.chars().all(|c| c == '*') || compact.chars().all(|c| c == '_')) && indent_bytes < 4 {
 494         let look = Look { marker: true, ..Look::default() };
 495         return Line { kind: Kind::Rule, segs: if active { vec![Seg { range: 0..n, look, link: None }] } else { Vec::new() }, links, content_start: n };
 496     }
 497     if trimmed.starts_with('|') {
 498         return Line { kind: Kind::Table, ..whole(Look { mono: true, ..Look::default() }) };
 499     }
 500     // Headings.
 501     let hashes = trimmed.chars().take_while(|&c| c == '#').count();
 502     if (1..=6).contains(&hashes) && indent_bytes < 4 && (trimmed.len() == hashes || trimmed[hashes..].starts_with(' ')) {
 503         kind = Kind::Heading(hashes as u8);
 504         prefix = (indent_bytes + hashes + 1).min(n);
 505     } else {
 506         // Quotes, possibly nested, then a list marker inside them.
 507         let mut at = 0usize;
 508         let mut depth = 0u8;
 509         loop {
 510             let rest = &text[at..];
 511             let sp = rest.len() - rest.trim_start().len();
 512             if rest[sp..].starts_with('>') {
 513                 depth += 1;
 514                 at += sp + 1;
 515                 if text[at..].starts_with(' ') {
 516                     at += 1;
 517                 }
 518             } else {
 519                 break;
 520             }
 521         }
 522         if depth > 0 {
 523             kind = Kind::Quote(depth);
 524             prefix = at;
 525         }
 526         if let Some((level, marker, end)) = list_marker(&text[at..]) {
 527             let marker = match marker {
 528                 Marker::Task(c, b) => Marker::Task(c, b + at),
 529                 m => m,
 530             };
 531             if depth == 0 {
 532                 kind = Kind::List { level, marker };
 533             }
 534             prefix = at + end;
 535         }
 536     }
 537     for f in flags.iter_mut().take(prefix) {
 538         *f |= MARKER;
 539     }
 540     let done = matches!(kind, Kind::List { marker: Marker::Task(c, _), .. } if c != ' ');
 541     inline(text, prefix, &mut flags, &mut link_of, &mut links);
 542     if done {
 543         for f in flags.iter_mut().skip(prefix) {
 544             *f |= DIM;
 545         }
 546     }
 547     // Merge bytes into segments at char boundaries.
 548     let mut segs: Vec<Seg> = Vec::new();
 549     for (i, _) in text.char_indices() {
 550         let f = flags[i];
 551         let hidden = !active && f & MARKER != 0;
 552         if hidden {
 553             continue;
 554         }
 555         let end = i + text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
 556         let look = Look {
 557             bold: f & BOLD != 0,
 558             italic: f & ITALIC != 0,
 559             code: f & CODE != 0,
 560             strike: f & STRIKE != 0,
 561             highlight: f & HIGHLIGHT != 0,
 562             link: f & LINK != 0,
 563             tag: f & TAG != 0,
 564             marker: f & MARKER != 0,
 565             comment: f & COMMENT != 0,
 566             mono: false,
 567             dim: f & DIM != 0,
 568             pill: false,
 569         };
 570         let link = link_of[i];
 571         match segs.last_mut() {
 572             Some(s) if s.range.end == i && s.look == look && s.link == link => s.range.end = end,
 573             _ => segs.push(Seg { range: i..end, look, link }),
 574         }
 575     }
 576     Line { kind, segs, links, content_start: prefix }
 577 }
 578 
 579 /// A list marker at the start of `s` (after indentation): its level, the
 580 /// marker, and the byte where the content starts.
 581 fn list_marker(s: &str) -> Option<(u8, Marker, usize)> {
 582     let body = s.trim_start_matches([' ', '\t']);
 583     let ind = &s[..s.len() - body.len()];
 584     // A tab is one level (Obsidian indents with tabs); two spaces are one.
 585     let cols: usize = ind.chars().map(|c| if c == '\t' { 2 } else { 1 }).sum();
 586     let level = (cols / 2).min(12) as u8;
 587     let (marker, mlen) = if body.starts_with("- ") || body.starts_with("* ") || body.starts_with("+ ") {
 588         (Marker::Bullet, 2)
 589     } else if matches!(body, "-" | "*" | "+") {
 590         (Marker::Bullet, 1)
 591     } else {
 592         let digits = body.chars().take_while(char::is_ascii_digit).count();
 593         if digits == 0 || digits > 9 {
 594             return None;
 595         }
 596         let after = &body[digits..];
 597         if after.starts_with(". ") || after.starts_with(") ") {
 598             (Marker::Number(body[..digits + 1].to_string()), digits + 2)
 599         } else {
 600             return None;
 601         }
 602     };
 603     let mut end = ind.len() + mlen;
 604     let rest = &s[end..];
 605     let mut chars = rest.chars();
 606     if let (Some('['), Some(c), Some(']')) = (chars.next(), chars.next(), chars.next()) {
 607         let after = &rest[2 + c.len_utf8()..];
 608         if after.is_empty() || after.starts_with(' ') {
 609             let status_at = end + 1;
 610             end += 2 + c.len_utf8() + usize::from(after.starts_with(' '));
 611             return Some((level, Marker::Task(c, status_at), end));
 612         }
 613     }
 614     Some((level, marker, end))
 615 }
 616 
 617 /// Mark the inline syntax of `text[from..]` in `flags`.
 618 fn inline(text: &str, from: usize, flags: &mut [u16], link_of: &mut [Option<usize>], links: &mut Vec<Target>) {
 619     let b = text.as_bytes();
 620     let n = b.len();
 621     let mut taken = vec![false; n];
 622     let mark = |flags: &mut [u16], r: Range<usize>, f: u16| {
 623         for x in &mut flags[r] {
 624             *x |= f;
 625         }
 626     };
 627     // Code spans first: nothing inside them is syntax.
 628     let mut i = from;
 629     while i < n {
 630         if b[i] == b'`' {
 631             let run = b[i..].iter().take_while(|&&c| c == b'`').count();
 632             let close = find_run(b, i + run, b'`', run);
 633             if let Some(c) = close {
 634                 mark(flags, i..i + run, MARKER | CODE);
 635                 mark(flags, i + run..c, CODE);
 636                 mark(flags, c..c + run, MARKER | CODE);
 637                 for t in &mut taken[i..c + run] {
 638                     *t = true;
 639                 }
 640                 i = c + run;
 641                 continue;
 642             }
 643             i += run;
 644             continue;
 645         }
 646         i += 1;
 647     }
 648     // Comments.
 649     let mut i = from;
 650     while let Some(o) = find_free(text, i, "%%", &taken) {
 651         let Some(c) = find_free(text, o + 2, "%%", &taken) else { break };
 652         mark(flags, o..c + 2, COMMENT);
 653         mark(flags, o..o + 2, MARKER);
 654         mark(flags, c..c + 2, MARKER);
 655         for t in &mut taken[o..c + 2] {
 656             *t = true;
 657         }
 658         i = c + 2;
 659     }
 660     // Wikilinks and embeds: [[target#sub|alias]].
 661     let mut i = from;
 662     while let Some(o) = find_free(text, i, "[[", &taken) {
 663         let Some(c) = find_free(text, o + 2, "]]", &taken) else { break };
 664         let inner = &text[o + 2..c];
 665         let start = if o > 0 && b[o - 1] == b'!' && !taken[o - 1] { o - 1 } else { o };
 666         let (target, alias_at) = match inner.find('|') {
 667             Some(p) => (&inner[..p], Some(o + 2 + p + 1)),
 668             None => (inner, None),
 669         };
 670         let (t, sub) = match target.split_once('#') {
 671             Some((t, s)) => (t.to_string(), Some(s.to_string())),
 672             None => (target.to_string(), None),
 673         };
 674         let idx = links.len();
 675         links.push(Target::Note { target: t.trim().to_string(), subpath: sub.filter(|s| !s.is_empty()) });
 676         // An embed's alias that is only a size (`![[pic.png|300]]`) is not
 677         // a caption: show the file name and hide the `|300`, as for links.
 678         let size_alias = start < o && alias_at.is_some_and(|a| embed_size_spec(&text[a..c]));
 679         let shown = match alias_at {
 680             Some(a) if size_alias => o + 2..a - 1,
 681             Some(a) => a..c,
 682             None => o + 2..c,
 683         };
 684         mark(flags, start..shown.start, MARKER);
 685         mark(flags, shown.clone(), LINK);
 686         mark(flags, shown.end..c + 2, MARKER);
 687         for x in &mut link_of[shown] {
 688             *x = Some(idx);
 689         }
 690         for t in &mut taken[start..c + 2] {
 691             *t = true;
 692         }
 693         i = c + 2;
 694     }
 695     // Markdown links: [text](dest).
 696     let mut i = from;
 697     while let Some(o) = find_free(text, i, "[", &taken) {
 698         let Some(c) = find_free(text, o + 1, "](", &taken) else { break };
 699         let Some(e) = find_free(text, c + 2, ")", &taken) else { break };
 700         let dest = text[c + 2..e].trim();
 701         let start = if o > 0 && b[o - 1] == b'!' && !taken[o - 1] { o - 1 } else { o };
 702         let idx = links.len();
 703         let decoded = dest.replace("%20", " ");
 704         links.push(if dest.contains("://") || dest.starts_with("mailto:") {
 705             Target::Url(dest.to_string())
 706         } else {
 707             let (t, s) = match decoded.split_once('#') {
 708                 Some((t, s)) => (t.to_string(), Some(s.to_string())),
 709                 None => (decoded.clone(), None),
 710             };
 711             Target::Note { target: t, subpath: s }
 712         });
 713         mark(flags, start..o + 1, MARKER);
 714         mark(flags, o + 1..c, LINK);
 715         mark(flags, c..e + 1, MARKER);
 716         for x in &mut link_of[o + 1..c] {
 717             *x = Some(idx);
 718         }
 719         for t in &mut taken[start..e + 1] {
 720             *t = true;
 721         }
 722         i = e + 1;
 723     }
 724     // Emphasis: paired delimiters, longest first.
 725     for (delim, flag) in [("**", BOLD), ("__", BOLD), ("~~", STRIKE), ("==", HIGHLIGHT), ("*", ITALIC), ("_", ITALIC)] {
 726         let mut i = from;
 727         while let Some(o) = find_free(text, i, delim, &taken) {
 728             let inner = o + delim.len();
 729             // Opening: not followed by a space; `_` must not sit mid-word.
 730             let next = text[inner..].chars().next();
 731             let prev = text[..o].chars().next_back();
 732             let mid_word = delim.starts_with('_') && prev.is_some_and(char::is_alphanumeric);
 733             if next.is_none_or(char::is_whitespace) || mid_word {
 734                 i = inner;
 735                 continue;
 736             }
 737             let mut search = inner;
 738             let close = loop {
 739                 let Some(c) = find_free(text, search, delim, &taken) else { break None };
 740                 let before = text[..c].chars().next_back();
 741                 let after = text[c + delim.len()..].chars().next();
 742                 let bad_word = delim.starts_with('_') && after.is_some_and(char::is_alphanumeric);
 743                 if c > inner && before.is_some_and(|ch| !ch.is_whitespace()) && !bad_word {
 744                     break Some(c);
 745                 }
 746                 search = c + delim.len();
 747             };
 748             let Some(c) = close else {
 749                 i = inner;
 750                 continue;
 751             };
 752             mark(flags, o..inner, MARKER);
 753             mark(flags, inner..c, flag);
 754             mark(flags, c..c + delim.len(), MARKER);
 755             for t in &mut taken[o..inner] {
 756                 *t = true;
 757             }
 758             for t in &mut taken[c..c + delim.len()] {
 759                 *t = true;
 760             }
 761             i = c + delim.len();
 762         }
 763     }
 764     // Tags: #name at a word start, not all digits.
 765     let mut i = from;
 766     while i < n {
 767         if b[i] == b'#' && !taken[i] && (i == 0 || text[..i].chars().next_back().is_some_and(char::is_whitespace)) {
 768             let rest = &text[i + 1..];
 769             let len: usize = rest.chars().take_while(|&c| c.is_alphanumeric() || "_-/".contains(c)).map(char::len_utf8).sum();
 770             if len > 0 && !rest[..len].chars().all(|c| c.is_ascii_digit() || c == '/') {
 771                 mark(flags, i..i + 1 + len, TAG);
 772                 i += 1 + len;
 773                 continue;
 774             }
 775         }
 776         i += 1;
 777     }
 778 }
 779 
 780 /// The next `pat` at or after `from` that starts on a byte nothing else
 781 /// has taken.
 782 fn find_free(text: &str, from: usize, pat: &str, taken: &[bool]) -> Option<usize> {
 783     let mut at = from;
 784     while at <= text.len() {
 785         let p = at + text.get(at..)?.find(pat)?;
 786         if !taken[p..p + pat.len()].iter().any(|&t| t) {
 787             return Some(p);
 788         }
 789         at = p + 1;
 790         while at < text.len() && !text.is_char_boundary(at) {
 791             at += 1;
 792         }
 793     }
 794     None
 795 }
 796 
 797 /// The next run of exactly `len` `c` bytes at or after `from`.
 798 fn find_run(b: &[u8], from: usize, c: u8, len: usize) -> Option<usize> {
 799     let mut i = from;
 800     while i < b.len() {
 801         if b[i] == c {
 802             let run = b[i..].iter().take_while(|&&x| x == c).count();
 803             if run == len {
 804                 return Some(i);
 805             }
 806             i += run;
 807         } else {
 808             i += 1;
 809         }
 810     }
 811     None
 812 }
 813 
 814 #[cfg(test)]
 815 mod tests {
 816     use super::*;
 817 
 818     #[test]
 819     fn a_size_alias_shows_the_file_name() {
 820         let shown = |t: &str| {
 821             let l = style_line(t, Context::Normal, false);
 822             l.segs.iter().filter(|g| g.look.link).map(|g| &t[g.range.clone()]).collect::<String>()
 823         };
 824         assert_eq!(shown("![[pic.png|300]]"), "pic.png");
 825         assert_eq!(shown("![[pic.png|300x20]]"), "pic.png");
 826         assert_eq!(shown("![[pic.png|a caption]]"), "a caption");
 827         assert_eq!(shown("[[Note|300]]"), "300");
 828     }
 829 
 830     #[test]
 831     fn inline_embeds_in_a_line() {
 832         let found = inline_embeds("see ![[a.png|40]] and ![x](b%20c.png) then ![[N]] end");
 833         assert_eq!(found, vec![
 834             ("a.png".to_string(), Some(40), None),
 835             ("b c.png".to_string(), None, None),
 836             ("N".to_string(), None, None),
 837         ]);
 838         assert!(inline_embeds("no embeds ![ here").is_empty());
 839     }
 840 
 841     #[test]
 842     fn standalone_embeds() {
 843         assert_eq!(standalone_embed("![[a.png]]"), Some(("a.png".into(), None, None)));
 844         assert_eq!(standalone_embed("  ![[Pics/a.png|300]] "), Some(("Pics/a.png".into(), Some(300), None)));
 845         assert_eq!(standalone_embed("![[a.png|300x200]]"), Some(("a.png".into(), Some(300), Some(200))));
 846         assert_eq!(standalone_embed("![cap|120](my%20pic.png)"), Some(("my pic.png".into(), Some(120), None)));
 847         assert_eq!(standalone_embed("![](https://x.y/a.png)"), None);
 848         assert_eq!(standalone_embed("see ![[a.png]]"), None);
 849         assert_eq!(standalone_embed("![[a.png]] and ![[b.png]]"), None);
 850     }
 851 
 852     /// The shown text of each segment, `[marker]` for dimmed markup.
 853     fn shown(text: &str, line: &Line) -> Vec<String> {
 854         line.segs
 855             .iter()
 856             .map(|s| {
 857                 let t = &text[s.range.clone()];
 858                 if s.look.marker { format!("[{t}]") } else { t.to_string() }
 859             })
 860             .collect()
 861     }
 862 
 863     #[test]
 864     fn inactive_lines_hide_markup() {
 865         let t = "## A **bold** and *it* with `code`";
 866         let l = style_line(t, Context::Normal, false);
 867         assert_eq!(l.kind, Kind::Heading(2));
 868         assert_eq!(shown(t, &l), ["A ", "bold", " and ", "it", " with ", "code"]);
 869         assert!(l.segs[1].look.bold && l.segs[3].look.italic && l.segs[5].look.code);
 870         assert_eq!(l.content_start, 3);
 871     }
 872 
 873     #[test]
 874     fn the_active_line_shows_markup_dimmed() {
 875         let t = "## A **bold**";
 876         let l = style_line(t, Context::Normal, true);
 877         assert_eq!(shown(t, &l), ["[## ]", "A ", "[**]", "bold", "[**]"]);
 878         assert!(l.segs[2].look.marker && l.segs[3].look.bold && !l.segs[3].look.marker);
 879         // Every byte is covered, in order: 1:1 with the source.
 880         let mut at = 0;
 881         for s in &l.segs {
 882             assert_eq!(s.range.start, at);
 883             at = s.range.end;
 884         }
 885         assert_eq!(at, t.len());
 886     }
 887 
 888     #[test]
 889     fn links_show_their_alias_and_carry_a_target() {
 890         let t = "see [[Note#Part|the part]] and [web](https://a.b) or [[Plain]]";
 891         let l = style_line(t, Context::Normal, false);
 892         assert_eq!(shown(t, &l), ["see ", "the part", " and ", "web", " or ", "Plain"]);
 893         assert_eq!(l.links[0], Target::Note { target: "Note".into(), subpath: Some("Part".into()) });
 894         assert_eq!(l.links[1], Target::Note { target: "Plain".into(), subpath: None });
 895         assert_eq!(l.links[2], Target::Url("https://a.b".into()));
 896         assert_eq!(l.segs[1].link, Some(0));
 897         assert_eq!(l.segs[3].link, Some(2));
 898     }
 899 
 900     #[test]
 901     fn frontmatter_lines_get_property_roles() {
 902         let src = "---\ntitle: \"Hello\"\ntags: [a, \"b c\"]\ndone: true\n\naliases:\n  - one\n  - \"[[Two]]\"\nmeta:\n  nested: x\nempty:\n---\nbody";
 903         let lines: Vec<String> = src.split('\n').map(String::from).collect();
 904         let ctx = contexts(&lines);
 905         let p = properties(&lines, &ctx);
 906         assert_eq!(p[0], Some(Prop::Open));
 907         assert_eq!(p[11], Some(Prop::Close));
 908         assert_eq!(p[12], None);
 909         assert!(matches!(p[1], Some(Prop::Field { .. })));
 910         assert_eq!(p[4], Some(Prop::Hidden));
 911         assert_eq!(p[5], Some(Prop::ListKey));
 912         assert!(matches!(p[6], Some(Prop::Item { key_line: Some(5), .. })));
 913         assert!(matches!(p[7], Some(Prop::Item { key_line: None, .. })));
 914         assert_eq!(p[9], Some(Prop::Raw), "a nested map shows raw");
 915         assert_eq!(prop_key(&lines[5]), Some("aliases"));
 916 
 917         let shown = |i: usize, key: Option<&str>| {
 918             let l = style_property(&lines[i], p[i].as_ref().unwrap(), key);
 919             (l.kind.clone(), l.segs.iter().map(|s| lines[i][s.range.clone()].to_string()).collect::<Vec<_>>(), l)
 920         };
 921         let (kind, segs, _) = shown(1, None);
 922         assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("title".into()), check: None, empty: false }));
 923         assert_eq!(segs, ["Hello"], "quotes hide");
 924         let (_, segs, l) = shown(2, None);
 925         assert_eq!(segs, ["a", "b c"]);
 926         assert!(l.segs.iter().all(|s| s.look.pill));
 927         let (kind, segs, _) = shown(3, None);
 928         assert!(segs.is_empty());
 929         assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("done".into()), check: Some((6..10, true)), empty: false }));
 930         let (kind, segs, _) = shown(6, Some("aliases"));
 931         assert_eq!(segs, ["one"]);
 932         assert!(matches!(kind, Kind::Prop(PropShow::Row { key: Some(k), .. }) if k == "aliases"));
 933         let (_, segs, l) = shown(7, None);
 934         assert_eq!(segs, ["Two"], "a link item shows its name");
 935         assert_eq!(l.links, [Target::Note { target: "Two".into(), subpath: None }]);
 936         let (kind, _, _) = shown(10, None);
 937         assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("empty".into()), check: None, empty: true }));
 938 
 939         // Never closed: no table.
 940         let open: Vec<String> = ["---", "a: b"].iter().map(|s| s.to_string()).collect();
 941         assert!(properties(&open, &contexts(&open)).iter().all(Option::is_none));
 942     }
 943 
 944     #[test]
 945     fn fenced_blocks_are_found_and_fences_hide() {
 946         let lines: Vec<String> = ["a", "```", "x", "```", "```", "y"].iter().map(|s| s.to_string()).collect();
 947         let ctx = contexts(&lines);
 948         assert_eq!(fenced_block(&ctx, 0), None);
 949         assert_eq!(fenced_block(&ctx, 2), Some((1, 3)));
 950         assert_eq!(fenced_block(&ctx, 3), Some((1, 3)));
 951         assert_eq!(fenced_block(&ctx, 4), Some((4, 5)));
 952         assert!(style_line("```rust", Context::Fence, false).segs.is_empty());
 953         assert_eq!(style_line("```rust", Context::Fence, true).segs.len(), 1);
 954     }
 955 
 956     #[test]
 957     fn lists_tasks_quotes_and_rules() {
 958         let t = "\t- [x] done *thing*";
 959         let l = style_line(t, Context::Normal, false);
 960         assert_eq!(l.kind, Kind::List { level: 1, marker: Marker::Task('x', 4) });
 961         assert_eq!(shown(t, &l), ["done ", "thing"]);
 962         assert!(l.segs[0].look.dim);
 963         let l = style_line("1. first", Context::Normal, false);
 964         assert_eq!(l.kind, Kind::List { level: 0, marker: Marker::Number("1.".into()) });
 965         let t = "> > quoted";
 966         let l = style_line(t, Context::Normal, false);
 967         assert_eq!((l.kind.clone(), shown(t, &l)), (Kind::Quote(2), vec!["quoted".to_string()]));
 968         assert_eq!(style_line("---", Context::Normal, false).kind, Kind::Rule);
 969         assert!(style_line("---", Context::Normal, false).segs.is_empty());
 970         assert_eq!(style_line("- ", Context::Normal, false).kind, Kind::List { level: 0, marker: Marker::Bullet });
 971     }
 972 
 973     #[test]
 974     fn emphasis_needs_real_pairs() {
 975         let t = "a * b * c snake_case_word 2*3*4 ==hi== ~~no~~";
 976         let l = style_line(t, Context::Normal, false);
 977         let styled: Vec<(String, bool, bool, bool)> = l
 978             .segs
 979             .iter()
 980             .map(|s| (t[s.range.clone()].to_string(), s.look.italic, s.look.highlight, s.look.strike))
 981             .collect();
 982         assert!(styled.iter().any(|(s, _, h, _)| s == "hi" && *h));
 983         assert!(styled.iter().any(|(s, _, _, k)| s == "no" && *k));
 984         assert!(styled.iter().all(|(s, i, _, _)| !(s.contains("snake") && *i)), "{styled:?}");
 985         assert!(!styled.iter().any(|(s, i, _, _)| s.contains(" b ") && *i), "spaced * is not emphasis");
 986     }
 987 
 988     #[test]
 989     fn tags_and_code_protect() {
 990         let t = "#tag and `#not **x**` #2026 #a/b";
 991         let l = style_line(t, Context::Normal, false);
 992         let tags: Vec<&str> = l.segs.iter().filter(|s| s.look.tag).map(|s| &t[s.range.clone()]).collect();
 993         assert_eq!(tags, ["#tag", "#a/b"]);
 994         assert!(l.segs.iter().any(|s| s.look.code && &t[s.range.clone()] == "#not **x**"));
 995     }
 996 
 997     #[test]
 998     fn block_contexts() {
 999         let lines: Vec<String> = ["---", "a: 1", "---", "text", "```rust", "let x;", "```", "after"].iter().map(|s| s.to_string()).collect();
1000         assert_eq!(
1001             contexts(&lines),
1002             [
1003                 Context::Frontmatter,
1004                 Context::Frontmatter,
1005                 Context::Frontmatter,
1006                 Context::Normal,
1007                 Context::Fence,
1008                 Context::Code,
1009                 Context::Fence,
1010                 Context::Normal
1011             ]
1012         );
1013         assert!(style_line("let x;", Context::Code, false).segs[0].look.mono);
1014     }
1015 }