Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-markdown/src/lib.rs

//! **facett-markdown** — a Markdown editor + preview facet for ordning.
//!
//! Edits case descriptions and comments. A pragmatic, dependency-free renderer
//! covers the markdown a case actually uses — headings, lists, blockquotes, code
//! fences, and inline **bold** / `code` / [links](url) — plus **@mention** and
//! **#tag** tokens, which are extracted (for the host to link/notify) and
//! highlighted. Edit/Preview toggle.
//!
//! Design: `ordning/.nornir/widgets/facett-markdown.md`. Built in ordning;
//! **destined for facett**. Pure egui; no markdown crate (the subset is small and
//! hand-rolling keeps it lean — the lean-Rust stance).

use egui::text::LayoutJob;
use egui::{Color32, FontId, TextFormat, Ui};
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Mode {
    Edit,
    Preview,
}

pub struct Markdown {
    title: String,
    source: String,
    mode: Mode,
}

const ACCENT: Color32 = Color32::from_rgb(247, 198, 0); // Tillsynia yellow (links/headings)
const MENTION: Color32 = Color32::from_rgb(110, 170, 230);
const TAG: Color32 = Color32::from_rgb(120, 190, 140);
const CODE_BG: Color32 = Color32::from_rgb(40, 42, 50);
const TEXT: Color32 = Color32::from_rgb(214, 219, 228);

impl Markdown {
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), source: String::new(), mode: Mode::Edit }
    }
    pub fn with_text(mut self, text: impl Into<String>) -> Self {
        self.source = text.into();
        self
    }
    pub fn set_text(&mut self, text: impl Into<String>) {
        self.source = text.into();
    }
    pub fn text(&self) -> &str {
        &self.source
    }
    pub fn set_mode(&mut self, mode: Mode) {
        self.mode = mode;
    }

    /// All `@mentions` in the source (unique, in first-seen order).
    pub fn mentions(&self) -> Vec<String> {
        extract(&self.source, '@')
    }
    /// All `#tags` in the source (unique, in first-seen order).
    pub fn tags(&self) -> Vec<String> {
        extract(&self.source, '#')
    }

    /// The **shared demo fixture** — a note exercising every render branch: a
    /// heading, a bullet list, `@mentions`, `#tags` and inline code, so both the
    /// Edit and the Preview lane paint something. Consumed by the robot and
    /// matrix tests (L5: one fixture, not one per test file).
    pub fn local() -> Self {
        Self::new("Anteckning").with_text(
            "# Bygglov BL-12\n\nHandläggare @maria, granskare @johan.\n\n\
             - [x] Ansökan komplett\n- [ ] Grannhörande #frist\n- [ ] Beslut #bygglov\n\n\
             Se `karta.geojson` för tomtgränsen.\n",
        )
    }

    // ── facett Facet contract ────────────────────────────────────────────────

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "mode": format!("{:?}", self.mode),
            "chars": self.source.chars().count(),
            "mentions": self.mentions(),
            "tags": self.tags(),
        })
    }

    pub fn ui(&mut self, ui: &mut Ui) {
        ui.horizontal(|ui| {
            ui.selectable_value(&mut self.mode, Mode::Edit, "✎ Edit");
            ui.selectable_value(&mut self.mode, Mode::Preview, "👁 Preview");
            ui.separator();
            let m = self.mentions();
            let t = self.tags();
            if !m.is_empty() {
                ui.label(egui::RichText::new(format!("@{}", m.join(" @"))).small().color(MENTION));
            }
            if !t.is_empty() {
                ui.label(egui::RichText::new(format!("#{}", t.join(" #"))).small().color(TAG));
            }
        });
        ui.separator();
        match self.mode {
            Mode::Edit => {
                ui.add(
                    egui::TextEdit::multiline(&mut self.source)
                        .desired_width(f32::INFINITY)
                        .desired_rows(10)
                        .hint_text("Markdown — # heading, - bullet, **bold**, `code`, @mention, #tag"),
                );
            }
            Mode::Preview => render(ui, &self.source),
        }
    }
}

/// Extract unique `@word` / `#word` tokens (word = alphanumerics, `_`, `-`, and
/// Swedish letters), in first-seen order.
fn extract(src: &str, sigil: char) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let chars: Vec<char> = src.chars().collect();
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == sigil && (i == 0 || !is_word(chars[i - 1])) {
            let mut j = i + 1;
            while j < chars.len() && is_word(chars[j]) {
                j += 1;
            }
            if j > i + 1 {
                let w: String = chars[(i + 1)..j].iter().collect();
                if !out.contains(&w) {
                    out.push(w);
                }
            }
            i = j;
        } else {
            i += 1;
        }
    }
    out
}

fn is_word(c: char) -> bool {
    c.is_alphanumeric() || c == '_' || c == '-' || "åäöÅÄÖ".contains(c)
}

/// Render the markdown subset into `ui`.
///
/// **Public so read-only hosts (e.g. `facett-manual`'s embedded Manual pane) can
/// reuse the exact same renderer** the Preview lane uses, without dragging in the
/// Edit/Preview toggle chrome of [`Markdown::ui`]. L5: one markdown renderer, not a
/// second hand-rolled copy per host.
pub fn render(ui: &mut Ui, src: &str) {
    let mut in_code = false;
    let mut code_buf = String::new();
    for raw in src.lines() {
        let line = raw;
        if line.trim_start().starts_with("```") {
            if in_code {
                // close: emit the code block
                let job = mono_block(&code_buf);
                egui::Frame::default().fill(CODE_BG).inner_margin(6.0).show(ui, |ui| {
                    ui.label(job);
                });
                code_buf.clear();
                in_code = false;
            } else {
                in_code = true;
            }
            continue;
        }
        if in_code {
            code_buf.push_str(line);
            code_buf.push('\n');
            continue;
        }
        // headings
        if let Some(h) = heading_level(line) {
            let text = line.trim_start_matches('#').trim();
            let size = match h {
                1 => 20.0,
                2 => 16.5,
                _ => 14.5,
            };
            ui.label(egui::RichText::new(text).size(size).strong().color(ACCENT));
            continue;
        }
        // blockquote
        if let Some(rest) = line.strip_prefix("> ") {
            ui.horizontal(|ui| {
                ui.add_space(6.0);
                ui.label(egui::RichText::new("▍").color(Color32::DARK_GRAY));
                ui.label(inline(rest, true));
            });
            continue;
        }
        // bullets
        if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
            ui.horizontal_wrapped(|ui| {
                ui.add_space(8.0);
                ui.label("•");
                ui.label(inline(rest, false));
            });
            continue;
        }
        // numbered list (e.g. "1. ")
        if let Some(pos) = line.find(". ") && pos > 0 && line[..pos].chars().all(|c| c.is_ascii_digit()) {
            ui.horizontal_wrapped(|ui| {
                ui.add_space(8.0);
                ui.label(format!("{}.", &line[..pos]));
                ui.label(inline(&line[pos + 2..], false));
            });
            continue;
        }
        if line.trim().is_empty() {
            ui.add_space(4.0);
            continue;
        }
        // normal paragraph
        ui.label(inline(line, false));
    }
    if in_code && !code_buf.is_empty() {
        ui.label(mono_block(&code_buf));
    }
}

fn heading_level(line: &str) -> Option<u8> {
    let hashes = line.chars().take_while(|&c| c == '#').count();
    if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
        Some(hashes as u8)
    } else {
        None
    }
}

fn mono_block(code: &str) -> LayoutJob {
    let mut job = LayoutJob::default();
    job.append(
        code.trim_end_matches('\n'),
        0.0,
        TextFormat { font_id: FontId::monospace(13.0), color: Color32::from_rgb(200, 220, 200), ..Default::default() },
    );
    job
}

/// Inline rendering: **bold**, `code`, [text](url), @mention, #tag → a LayoutJob.
fn inline(line: &str, quote: bool) -> LayoutJob {
    let base = if quote { Color32::from_rgb(170, 175, 185) } else { TEXT };
    let mut job = LayoutJob::default();
    let chars: Vec<char> = line.chars().collect();
    let mut i = 0;
    let n = chars.len();
    let push = |job: &mut LayoutJob, s: &str, fmt: TextFormat| {
        if !s.is_empty() {
            job.append(s, 0.0, fmt);
        }
    };
    let plain = |c: Color32| TextFormat { font_id: FontId::proportional(14.0), color: c, ..Default::default() };
    while i < n {
        let c = chars[i];
        // **bold**
        if c == '*' && i + 1 < n && chars[i + 1] == '*' && let Some(end) = find_seq(&chars, i + 2, "**") {
            let s: String = chars[(i + 2)..end].iter().collect();
            push(&mut job, &s, TextFormat { font_id: FontId::proportional(14.0), color: base, ..Default::default() });
            // egui has no bold flag on proportional without a bold font; emulate with brighter color
            // (kept simple; the move-to-facett version uses the theme's strong font).
            i = end + 2;
            continue;
        }
        // `code`
        if c == '`' && let Some(end) = find_seq(&chars, i + 1, "`") {
            let s: String = chars[(i + 1)..end].iter().collect();
            push(&mut job, &s, TextFormat { font_id: FontId::monospace(13.0), color: Color32::from_rgb(210, 225, 210), background: CODE_BG, ..Default::default() });
            i = end + 1;
            continue;
        }
        // [text](url)
        if c == '['
            && let Some(close) = find_seq(&chars, i + 1, "]")
            && chars.get(close + 1) == Some(&'(')
            && let Some(paren) = find_seq(&chars, close + 2, ")")
        {
            let text: String = chars[(i + 1)..close].iter().collect();
            push(&mut job, &text, TextFormat { font_id: FontId::proportional(14.0), color: ACCENT, underline: egui::Stroke::new(1.0_f32, ACCENT), ..Default::default() });
            i = paren + 1;
            continue;
        }
        // @mention / #tag
        if (c == '@' || c == '#') && (i == 0 || !is_word(chars[i - 1])) {
            let mut j = i + 1;
            while j < n && is_word(chars[j]) {
                j += 1;
            }
            if j > i + 1 {
                let s: String = chars[i..j].iter().collect();
                let color = if c == '@' { MENTION } else { TAG };
                push(&mut job, &s, plain(color));
                i = j;
                continue;
            }
        }
        // plain run until the next special char
        let mut j = i;
        while j < n && !matches!(chars[j], '*' | '`' | '[' | '@' | '#') {
            j += 1;
        }
        if j == i {
            j += 1; // a lone special char we didn't consume
        }
        let s: String = chars[i..j].iter().collect();
        push(&mut job, &s, plain(base));
        i = j;
    }
    job
}

/// Find the index where `needle` starts at-or-after `from` in `chars`.
fn find_seq(chars: &[char], from: usize, needle: &str) -> Option<usize> {
    let nd: Vec<char> = needle.chars().collect();
    let n = chars.len();
    let mut i = from;
    while i + nd.len() <= n {
        if chars[i..(i + nd.len())] == nd[..] {
            return Some(i);
        }
        i += 1;
    }
    None
}

// ── The host-facing `Facet` contract ───────────────────────────────────────────
//
// The inherent `title`/`state_json`/`ui` trio above already IS the contract; this
// thin adapter makes `Markdown` an object-safe `dyn Facet` so it composes into a
// `FacetDeck` and can be handed to `facett_core::testmatrix::probe_and_emit` for
// its robot matrix cell in ONE call.
impl facett_core::Facet for Markdown {
    fn title(&self) -> &str {
        Markdown::title(self)
    }
    fn ui(&mut self, ui: &mut Ui) {
        Markdown::ui(self, ui)
    }
    fn state_json(&self) -> serde_json::Value {
        Markdown::state_json(self)
    }
    /// The **robot drive seam**: `{"set_text":"…"}` retypes the source (the
    /// headless twin of the editor), `"preview"` / `"edit"` flip the mode toggle.
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        match v.as_str() {
            Some("preview") => return self.set_mode(Mode::Preview),
            Some("edit") => return self.set_mode(Mode::Edit),
            _ => {}
        }
        if let Some(t) = v.get("set_text").and_then(|s| s.as_str()) {
            self.set_text(t);
        }
    }
    fn caps(&self) -> facett_core::FacetCaps {
        facett_core::FacetCaps::NONE.copyable()
    }
    fn copy(&mut self) -> Option<String> {
        Some(self.source.clone())
    }
    fn paste(&mut self, text: &str) -> bool {
        self.set_text(text);
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_mentions_and_tags_unique_order() {
        let md = Markdown::new("c").with_text("hej @maria och @john, se #regression och @maria igen #regression #flaky");
        assert_eq!(md.mentions(), vec!["maria", "john"]);
        assert_eq!(md.tags(), vec!["regression", "flaky"]);
    }

    #[test]
    fn handles_swedish_letters_in_tokens() {
        let md = Markdown::new("c").with_text("@åsa pratar om #miljöärende");
        assert_eq!(md.mentions(), vec!["åsa"]);
        assert_eq!(md.tags(), vec!["miljöärende"]);
    }

    #[test]
    fn sigil_must_start_a_word() {
        // email@host is not a mention; foo#bar is not a tag
        let md = Markdown::new("c").with_text("mail till user@example.com och kod foo#bar");
        assert!(md.mentions().is_empty());
        assert!(md.tags().is_empty());
    }

    #[test]
    fn heading_level_detects() {
        assert_eq!(heading_level("# Title"), Some(1));
        assert_eq!(heading_level("### Sub"), Some(3));
        assert_eq!(heading_level("#nospace"), None);
        assert_eq!(heading_level("plain"), None);
    }

    #[test]
    fn state_json_reports_counts() {
        let s = Markdown::new("Desc").with_text("# H\n@a #b").state_json();
        assert_eq!(s["mentions"], serde_json::json!(["a"]));
        assert_eq!(s["tags"], serde_json::json!(["b"]));
        assert_eq!(s["mode"], "Edit");
    }
}