use std::ops::Range;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::config::schema::GlyphConfig;
use crate::input::mode::Mode;
use crate::text::cursor::Cursor;
use super::indent;
use super::markdown::block::{BlockKind, Marker};
use super::markdown::inline::InlineSpan;
use super::theme::{Attrs, Color, Style, Theme};
use super::StyledSpan;
#[derive(Clone, Debug, PartialEq)]
pub enum Conceal {
Hide(Range<usize>),
Replace {
range: Range<usize>,
text: String,
style: Option<Style>,
},
}
impl Conceal {
pub fn range(&self) -> &Range<usize> {
match self {
Conceal::Hide(r) => r,
Conceal::Replace { range, .. } => range,
}
}
fn plain(range: Range<usize>, text: String) -> Self {
Conceal::Replace {
range,
text,
style: None,
}
}
}
pub struct ConcealCtx<'a> {
pub glyphs: &'a GlyphConfig,
pub theme: &'a Theme,
pub measure: u16,
pub tab_width: usize,
}
#[derive(Default)]
pub struct ConcealMap {
pub ops: Vec<Conceal>,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ActiveSet {
pub start: usize,
pub end: usize,
}
impl ActiveSet {
pub fn none() -> Self {
ActiveSet { start: usize::MAX, end: usize::MAX }
}
pub fn compute(mode: &Mode, cursor: &Cursor, selection_anchor: Option<Cursor>) -> Self {
match (mode, selection_anchor) {
(Mode::Visual | Mode::VisualLine, Some(anchor)) => ActiveSet {
start: anchor.line.min(cursor.line),
end: anchor.line.max(cursor.line),
},
_ => ActiveSet {
start: cursor.line,
end: cursor.line,
},
}
}
pub fn contains(&self, line: usize) -> bool {
line >= self.start && line <= self.end
}
}
impl ConcealMap {
pub fn build(line: &str, block: &BlockKind, spans: &[InlineSpan], ctx: &ConcealCtx) -> Self {
let chars: Vec<char> = line.chars().collect();
let mut ops: Vec<Conceal> = Vec::new();
block_ops(&chars, block, ctx, &mut ops);
for sp in spans {
if sp.inner.start > sp.outer.start {
ops.push(Conceal::Hide(sp.outer.start..sp.inner.start));
}
if sp.outer.end > sp.inner.end {
ops.push(Conceal::Hide(sp.inner.end..sp.outer.end));
}
}
ops.sort_by_key(|o| o.range().start);
let mut clean: Vec<Conceal> = Vec::with_capacity(ops.len());
let mut last_end = 0usize;
for op in ops {
if op.range().start >= last_end {
last_end = op.range().end;
clean.push(op);
}
}
ConcealMap { ops: clean }
}
pub fn source_col(&self, source: &str, display_col: u16) -> usize {
let chars: Vec<char> = source.chars().collect();
let cell = |c: char| UnicodeWidthChar::width(c).unwrap_or(0) as u16;
let mut disp: u16 = 0;
let mut src = 0usize;
for op in &self.ops {
let r = op.range();
while src < r.start {
let w = cell(*chars.get(src).unwrap_or(&' '));
if display_col < disp + w.max(1) {
return src;
}
disp += w;
src += 1;
}
match op {
Conceal::Hide(rr) => src = rr.end,
Conceal::Replace { range, text, .. } => {
let w = UnicodeWidthStr::width(text.as_str()) as u16;
if display_col < disp + w {
return range.start;
}
disp += w;
src = range.end;
}
}
}
while src < chars.len() {
let w = cell(chars[src]);
if display_col < disp + w.max(1) {
return src;
}
disp += w;
src += 1;
}
src
}
pub fn render(&self, source: &str, spans: &[StyledSpan]) -> (String, Vec<StyledSpan>) {
let chars: Vec<char> = source.chars().collect();
let style_at = |src: usize| -> Style {
spans
.iter()
.find(|s| s.range.start <= src && src < s.range.end)
.map(|s| s.style)
.unwrap_or(Style {
fg: Color::None,
bg: Color::None,
attrs: Attrs::default(),
})
};
let mut text = String::new();
let mut out: Vec<StyledSpan> = Vec::new();
let mut disp = 0usize;
let mut push = |ch: char, style: Style, text: &mut String, disp: &mut usize| {
text.push(ch);
match out.last_mut() {
Some(last) if last.style == style && last.range.end == *disp => {
last.range.end = *disp + 1;
}
_ => out.push(StyledSpan {
range: *disp..*disp + 1,
style,
}),
}
*disp += 1;
};
let mut i = 0usize;
let mut op_idx = 0usize;
while i < chars.len() {
match self.ops.get(op_idx) {
Some(op) if op.range().start == i => {
match op {
Conceal::Hide(r) => i = r.end,
Conceal::Replace { range, text: s, style } => {
let style = style.unwrap_or_else(|| style_at(range.start));
for ch in s.chars() {
push(ch, style, &mut text, &mut disp);
}
i = range.end;
}
}
op_idx += 1;
}
_ => {
push(chars[i], style_at(i), &mut text, &mut disp);
i += 1;
}
}
}
(text, out)
}
pub fn leading_shift(&self, source: &str) -> u16 {
let chars: Vec<char> = source.chars().collect();
let width_of = |c: &char| UnicodeWidthChar::width(*c).unwrap_or(0);
let mut shift = 0usize;
let mut src = 0usize;
for op in &self.ops {
let r = op.range();
if chars[src..r.start].iter().any(|c| !c.is_whitespace()) {
break; }
let hidden: usize = chars[r.clone()].iter().map(width_of).sum();
let shown = match op {
Conceal::Hide(_) => 0,
Conceal::Replace { text, .. } => UnicodeWidthStr::width(text.as_str()),
};
shift += hidden.saturating_sub(shown);
src = r.end;
}
shift as u16
}
pub fn display_text(&self, source: &str) -> String {
if self.ops.is_empty() {
return source.to_string();
}
let chars: Vec<char> = source.chars().collect();
let mut text = String::with_capacity(source.len());
let mut i = 0usize;
let mut op_idx = 0usize;
while i < chars.len() {
match self.ops.get(op_idx) {
Some(op) if op.range().start == i => {
match op {
Conceal::Hide(r) => i = r.end,
Conceal::Replace { range, text: s, .. } => {
text.push_str(s);
i = range.end;
}
}
op_idx += 1;
}
_ => {
text.push(chars[i]);
i += 1;
}
}
}
text
}
}
fn bullet_for(depth: u8, ctx: &ConcealCtx) -> String {
let level = depth as usize / ctx.tab_width.max(1);
if level.is_multiple_of(2) {
ctx.glyphs.bullet.clone()
} else {
ctx.glyphs.bullet_alt.clone()
}
}
fn indent_guide_ops(chars: &[char], block: &BlockKind, ctx: &ConcealCtx, ops: &mut Vec<Conceal>) {
if ctx.glyphs.indent_guide.is_empty() {
return;
}
let line: String = chars.iter().collect();
for (col, _depth) in indent::guides_for(&line, block, ctx.tab_width).marks {
if chars.get(col) == Some(&' ') {
ops.push(Conceal::plain(col..col + 1, ctx.glyphs.indent_guide.clone()));
}
}
}
fn block_ops(chars: &[char], block: &BlockKind, ctx: &ConcealCtx, ops: &mut Vec<Conceal>) {
let len = chars.len();
let glyphs = ctx.glyphs;
match block {
BlockKind::Heading(_) => {
let mut m = chars.iter().take_while(|c| **c == '#').count();
while m < len && (chars[m] == ' ' || chars[m] == '\t') {
m += 1;
}
if m > 0 {
ops.push(Conceal::Hide(0..m));
}
}
BlockKind::Quote(depth) => {
let m = chars
.iter()
.take_while(|c| matches!(**c, ' ' | '\t' | '>'))
.count();
if m > 0 {
let bars = format!("{} ", glyphs.quote_bar).repeat((*depth as usize).max(1));
ops.push(Conceal::Replace {
range: 0..m,
text: bars,
style: Some(Style::fg(ctx.theme.quote_bar)),
});
}
}
BlockKind::ListItem { marker, checked, depth } => {
let indent = chars.iter().take_while(|c| **c == ' ' || **c == '\t').count();
indent_guide_ops(chars, block, ctx, ops);
let mut p = indent;
match marker {
Marker::Ordered => {
while p < len && chars[p].is_ascii_digit() {
p += 1;
}
p += 1; }
_ => p += 1,
}
p = p.min(len);
let mut q = p;
if q < len && (chars[q] == ' ' || chars[q] == '\t') {
q += 1;
}
let box_end = (q + 3 <= len && chars[q] == '[').then_some(q + 3);
match (checked, box_end) {
(Some(done), Some(end)) => {
let (glyph, color) = if *done {
(&glyphs.task_done, ctx.theme.task_done)
} else {
(&glyphs.task_todo, ctx.theme.list_bullet)
};
let mut text: String = match marker {
Marker::Ordered => chars[indent..q].iter().collect(),
_ => String::new(),
};
text.push_str(glyph);
ops.push(Conceal::Replace {
range: indent..end,
text,
style: Some(Style::fg(color)),
});
}
(_, _) if !matches!(marker, Marker::Ordered) && p > indent => {
let bullet = bullet_for(*depth, ctx);
if bullet.is_empty() {
ops.push(Conceal::plain(indent..p, " ".into()));
} else {
ops.push(Conceal::Replace {
range: indent..p,
text: bullet,
style: Some(Style::fg(ctx.theme.list_bullet)),
});
}
}
_ => {}
}
}
BlockKind::FenceOpen(_) | BlockKind::FenceClose | BlockKind::Rule if len > 0 => {
let color = if matches!(block, BlockKind::Rule) {
ctx.theme.rule
} else {
ctx.theme.fence_bar
};
ops.push(Conceal::Replace {
range: 0..len,
text: rule(&glyphs.rule, ctx.measure),
style: Some(Style::fg(color)),
});
}
_ => {}
}
}
fn rule(glyph: &str, width: u16) -> String {
let unit = UnicodeWidthStr::width(glyph).max(1);
glyph.repeat((width as usize / unit).max(1))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::MarkdownConfig;
use crate::render::markdown::block::{classify, Carry};
use crate::render::markdown::inline;
const MEASURE: u16 = 20;
fn map(line: &str) -> ConcealMap {
map_in(line, &Carry::None)
}
fn map_in(line: &str, carry: &Carry) -> ConcealMap {
let (block, _) = classify(line, carry, false);
let spans = if block.allows_inline() {
inline::scan(line, &MarkdownConfig::default())
} else {
Vec::new()
};
let glyphs = GlyphConfig::default();
let theme = Theme::default();
ConcealMap::build(
line,
&block,
&spans,
&ConcealCtx {
glyphs: &glyphs,
theme: &theme,
measure: MEASURE,
tab_width: 2,
},
)
}
fn shown(line: &str) -> String {
shown_in(line, &Carry::None)
}
fn shown_in(line: &str, carry: &Carry) -> String {
let styled = vec![StyledSpan {
range: 0..line.chars().count(),
style: Style {
fg: Color::None,
bg: Color::None,
attrs: Attrs::default(),
},
}];
map_in(line, carry).render(line, &styled).0
}
#[test]
fn inline_markers_vanish() {
assert_eq!(shown("a **bold** b"), "a bold b");
assert_eq!(shown("some *italic* here"), "some italic here");
assert_eq!(shown("call `code` now"), "call code now");
assert_eq!(shown("a [label](http://x) b"), "a label b");
assert_eq!(shown("a [[Wiki]] b"), "a Wiki b");
assert_eq!(shown("tag #foo bar"), "tag foo bar");
assert_eq!(shown(r"an escaped \* star"), "an escaped * star");
}
#[test]
fn heading_marker_hidden() {
assert_eq!(shown("## Title"), "Title");
assert_eq!(shown("# A **bold** head"), "A bold head");
}
#[test]
fn quote_becomes_bar() {
assert_eq!(shown("> quoted"), "▎ quoted");
}
#[test]
fn nested_quotes_show_one_bar_per_level() {
assert_eq!(shown("> > deep"), "▎ ▎ deep");
assert_eq!(shown(">>> deeper"), "▎ ▎ ▎ deeper");
}
#[test]
fn fence_delimiters_become_a_rule() {
let open = shown("```rust");
assert_eq!(UnicodeWidthStr::width(open.as_str()), MEASURE as usize);
assert!(!open.contains('`'));
let close = shown_in(
"```",
&Carry::InFence { fence: "```".into(), lang: None, cont: Default::default() },
);
assert_eq!(UnicodeWidthStr::width(close.as_str()), MEASURE as usize);
}
#[test]
fn replacement_glyphs_carry_their_own_color() {
let theme = Theme::default();
let styled = |line: &str| {
let spans = vec![StyledSpan {
range: 0..line.chars().count(),
style: Style::fg(Color::None),
}];
map(line).render(line, &spans).1
};
assert_eq!(styled("> quoted")[0].style.fg, theme.quote_bar);
assert_eq!(styled("- [x] done")[0].style.fg, theme.task_done);
}
#[test]
fn list_marker_becomes_a_bullet() {
assert_eq!(shown("- item"), "• item");
assert_eq!(shown("* item"), "• item");
assert_eq!(shown("+ item"), "• item");
assert_eq!(shown(" - nested"), "│ ◦ nested");
assert_eq!(shown(" - deeper"), "│ │ • deeper");
assert_eq!(shown("1. item"), "1. item");
assert_eq!(shown("12) item"), "12) item");
assert_eq!(shown("- [ ] todo"), "☐ todo");
for line in ["- item", " - nested", "1. item"] {
assert_eq!(shown(line).chars().count(), line.chars().count(), "{line:?}");
}
}
#[test]
fn an_empty_bullet_glyph_still_holds_the_column() {
let glyphs = GlyphConfig {
bullet: String::new(),
..GlyphConfig::default()
};
let theme = Theme::default();
let map = ConcealMap::build(
"- item",
&BlockKind::ListItem { depth: 0, marker: Marker::Dash, checked: None },
&[],
&ConcealCtx { glyphs: &glyphs, theme: &theme, measure: MEASURE, tab_width: 2 },
);
assert_eq!(map.display_text("- item"), " item");
}
#[test]
fn indent_guides_replace_a_space_per_level() {
assert_eq!(shown(" - nested"), "│ │ • nested");
assert_eq!(shown(" - one level"), "│ ◦ one level");
assert_eq!(shown("- flush"), "• flush", "no indent, no guides");
for line in [" - nested", " - one level"] {
assert_eq!(shown(line).chars().count(), line.chars().count());
}
}
#[test]
fn indent_guides_leave_tabs_alone() {
assert_eq!(shown("\t\t- tabbed"), "\t\t◦ tabbed");
}
#[test]
fn task_checkbox_becomes_a_box_or_tick() {
assert_eq!(shown("- [ ] todo"), "☐ todo");
assert_eq!(shown("- [x] done"), "☑ done");
assert_eq!(shown("* [ ] starred"), "☐ starred");
assert_eq!(shown("1. [ ] first"), "1. ☐ first");
}
#[test]
fn leading_shift_covers_the_whole_marker_run() {
let shift = |line: &str| map(line).leading_shift(line);
assert_eq!(shift("## Title"), 3, "`## ` is hidden outright");
assert_eq!(shift("- item"), 0, "a bullet swaps one char for one");
assert_eq!(shift("> quoted"), 0, "`> ` becomes a bar of the same width");
assert_eq!(shift("- [ ] todo"), 4);
assert_eq!(shift(" - [ ] nested"), 4);
assert_eq!(shift("**bold** and more"), 2);
}
#[test]
fn source_col_inverts_the_display() {
let line = "## Title";
let m = map(line);
assert_eq!(m.source_col(line, 0), 3); assert_eq!(m.source_col(line, 1), 4); let line = "a **bold** b";
let m = map(line);
assert_eq!(m.source_col(line, 0), 0); assert_eq!(m.source_col(line, 2), 4); }
#[test]
fn source_col_measures_wide_characters() {
let line = "**b** 世界x";
let m = map(line);
assert_eq!(m.source_col(line, 0), 2); assert_eq!(m.source_col(line, 2), 6); assert_eq!(m.source_col(line, 3), 6); assert_eq!(m.source_col(line, 4), 7); assert_eq!(m.source_col(line, 6), 8); }
#[test]
fn ops_are_sorted_and_non_overlapping() {
let m = map("## a **b** `c` [d](e) #f");
let mut last = 0;
for op in &m.ops {
assert!(op.range().start >= last, "overlap in {:?}", m.ops);
last = op.range().end;
}
}
}