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");
}
}