use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use super::{centered, list_height_reserving};
pub struct EntityRow {
pub id: String,
pub name: String,
pub node_type: String,
pub aliases: Vec<String>,
pub interactions: Option<i64>,
pub facts: Vec<String>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum EditKind {
Rename,
Alias,
NewPerson,
}
impl EditKind {
pub fn title(self) -> &'static str {
match self {
EditKind::Rename => " rename to — enter confirms · esc cancels ",
EditKind::Alias => " add alias — enter confirms · esc cancels ",
EditKind::NewPerson => " new person, their name — enter confirms · esc cancels ",
}
}
pub fn verb(self) -> &'static str {
match self {
EditKind::Rename => "rename",
EditKind::Alias => "alias",
EditKind::NewPerson => "new-person",
}
}
}
pub struct EntityModal {
pub merge_keep: Option<String>,
pub merge_confirm: Option<(String, String, String, String)>,
pub query: String,
pub rows: Vec<EntityRow>,
pub selected: usize,
pub edit: Option<(EditKind, String)>,
pub status: Option<String>,
pub fresh: bool,
pub help: bool,
}
impl EntityModal {
pub fn new() -> Self {
Self {
query: String::new(),
rows: Vec::new(),
selected: 0,
edit: None,
merge_keep: None,
merge_confirm: None,
status: None,
fresh: true,
help: false,
}
}
pub fn selected_row(&self) -> Option<&EntityRow> {
self.rows.get(self.selected)
}
pub fn move_sel(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let n = self.rows.len() as isize;
let next = (self.selected as isize + delta).rem_euclid(n);
self.selected = next as usize;
}
pub fn install(&mut self, json: &str) {
self.rows = parse_rows(json);
self.selected = 0;
self.fresh = false;
self.status = Some(match self.rows.len() {
0 => format!(
"nothing matches {:?} — ctrl-n creates a person by that name",
self.query
),
1 => "1 match".to_string(),
n => format!("{n} matches"),
});
}
pub fn draw(&self, frame: &mut Frame) {
if self.help {
self.draw_help(frame);
return;
}
let area = frame.area();
let rows = list_height_reserving(self.body_lines() as u16, area.height, 3);
let box_area = centered(area, area.width.saturating_sub(6).min(110), rows);
frame.render_widget(Clear, box_area);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled(" search ", Style::new().fg(Color::DarkGray)),
Span::styled(
if self.query.is_empty() {
"…".to_string()
} else {
self.query.clone()
},
Style::new().fg(Color::White).bold(),
),
]));
if self.rows.is_empty() {
lines.push(Line::styled(
if self.fresh {
" type a name and press enter"
} else {
" no match"
},
Style::new().fg(Color::DarkGray),
));
}
for (i, row) in self.rows.iter().enumerate() {
let here = i == self.selected;
let keeping = self.merge_keep.as_deref() == Some(row.id.as_str());
let marker = if keeping {
"◆ "
} else if here {
"▸ "
} else {
" "
};
lines.push(Line::from(vec![
Span::styled(
format!("{marker}{:<8} ", row.node_type),
Style::new().fg(if here { Color::Cyan } else { Color::DarkGray }),
),
Span::styled(
format!("{:<34} ", clip(&row.name, 34)),
if here {
Style::new().fg(Color::White).bold()
} else {
Style::new().fg(Color::White)
},
),
Span::styled(
match row.interactions {
Some(n) => format!("{n} interactions"),
None => String::new(),
},
Style::new().fg(Color::DarkGray),
),
]));
if here {
if !row.aliases.is_empty() {
lines.push(Line::styled(
format!(" aka {}", clip(&row.aliases.join(", "), 88)),
Style::new().fg(Color::DarkGray),
));
}
for f in row.facts.iter().take(3) {
lines.push(Line::styled(
format!(" · {}", clip(f, 88)),
Style::new().fg(Color::DarkGray),
));
}
}
}
lines.push(Line::styled(
match (&self.edit, &self.merge_confirm, &self.merge_keep) {
(Some((kind, buf)), ..) => format!(" {} {buf}▌", kind.title()),
(_, Some((_, keep, _, dup)), _) => format!(
" merge {dup:?} INTO {keep:?}? THIS CANNOT BE UNDONE · y confirm · any other key cancels"
),
(_, _, Some(_)) => {
" ◆ keeping this one — move to the duplicate and press m again · esc cancels"
.to_string()
}
(..) if self.rows.is_empty() && self.query.is_empty() => {
" type a name · enter search · ctrl-n CREATE a person · ? help · esc close"
.to_string()
}
_ => " ↑↓ · enter search · r rename · a alias · m merge · ctrl-n CREATE person · esc clear · ? help"
.to_string(),
},
Style::new().fg(if self.merge_confirm.is_some() {
Color::Red
} else if self.edit.is_some() || self.merge_keep.is_some() {
Color::Yellow
} else {
Color::DarkGray
}),
));
lines.push(Line::styled(
match &self.status {
Some(s) => format!(" {s}"),
None => String::new(),
},
Style::new().fg(Color::Yellow),
));
let title = " /entity — who is who in the knowledge graph ".to_string();
let border = if self.merge_confirm.is_some() {
Color::Red
} else if self.edit.is_some() || self.merge_keep.is_some() {
Color::Yellow
} else {
Color::Cyan
};
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border))
.title(title),
)
.wrap(Wrap { trim: false }),
box_area,
);
}
fn body_lines(&self) -> usize {
let mut n = 2 + self.rows.len().max(1);
if let Some(row) = self.selected_row() {
if !row.aliases.is_empty() {
n += 1;
}
n += row.facts.len().min(3);
}
n
}
fn draw_help(&self, frame: &mut Frame) {
let area = frame.area();
let text = "\
/entity — who is who in the knowledge graph
enter look the typed name up
↑ ↓ move through the matches
m merge: press once to mark the node to KEEP, then move to the
duplicate and press m again. Confirms with y/n — it is the only
irreversible action here
r rename the selected node
the old name is kept as an alias, so everything that
reached it by the old name still does
a add an alias to the selected node
ctrl-n CREATE a person, prefilled with what you typed — for someone
who has facts and episodes but no node of their own. This
writes a new entity; it does not start a new search
esc clear the search; again to close the modal
? this
esc back
Every one of these runs `mecha-graph` as a child process, so
anything here is available to a script. The model cannot do any
of it: an identity edit is invisible in a way a fact edit is not,
because every fact about a node keeps reading correctly while
pointing somewhere else.
A name that is already another node's is refused rather than
guessed at — that is a merge question, and `mecha-graph merge`
is the verb that answers it.";
let rows = list_height_reserving(text.lines().count() as u16, area.height, 0);
let box_area = centered(area, area.width.saturating_sub(6).min(78), rows);
frame.render_widget(Clear, box_area);
frame.render_widget(
Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(" /entity — keys · esc back "),
)
.wrap(Wrap { trim: false }),
box_area,
);
}
}
fn parse_rows(json: &str) -> Vec<EntityRow> {
let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(json)
else {
return Vec::new();
};
items
.iter()
.map(|it| EntityRow {
id: it["id"].as_str().unwrap_or_default().to_string(),
name: it["name"].as_str().unwrap_or_default().to_string(),
node_type: it["node_type"].as_str().unwrap_or("?").to_string(),
aliases: it["aliases"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
interactions: it["interactions"].as_i64(),
facts: it["facts"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|f| f["statement"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
})
.collect()
}
fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> &'static str {
r#"[{"id":"person-1","name":"Dana R. Whitfield","node_type":"person",
"aliases":["dana","dana whitfield"],"interactions":1035,
"facts":[{"statement":"Dana co-authored the Trust replication."}]}]"#
}
#[test]
fn a_lookup_answer_becomes_rows() {
let mut m = EntityModal::new();
m.query = "Dana".into();
m.install(sample());
assert_eq!(m.rows.len(), 1);
assert_eq!(m.rows[0].id, "person-1");
assert_eq!(m.rows[0].interactions, Some(1035));
assert_eq!(m.rows[0].facts.len(), 1);
assert!(!m.fresh);
}
#[test]
fn an_empty_list_before_and_after_a_search_read_differently() {
let m = EntityModal::new();
assert!(m.fresh, "a modal that has not searched is not a no-match");
let mut m = m;
m.query = "Nobody".into();
m.install("[]");
assert!(!m.fresh);
assert!(m.status.as_ref().unwrap().contains("nothing matches"));
}
#[test]
fn unreadable_json_is_an_empty_list_not_a_panic() {
assert!(parse_rows("not json").is_empty());
assert!(parse_rows("{}").is_empty());
assert!(parse_rows("[]").is_empty());
let rows = parse_rows(r#"[{"id":"x"}]"#);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].node_type, "?");
assert!(rows[0].interactions.is_none());
}
#[test]
fn selection_wraps_and_survives_an_empty_list() {
let mut m = EntityModal::new();
m.move_sel(1); assert_eq!(m.selected, 0);
m.install(sample());
m.move_sel(1);
assert_eq!(m.selected, 0, "one row wraps to itself");
m.move_sel(-1);
assert_eq!(m.selected, 0);
}
#[test]
fn every_edit_names_its_verb() {
assert_eq!(EditKind::Rename.verb(), "rename");
assert_eq!(EditKind::Alias.verb(), "alias");
assert_eq!(EditKind::NewPerson.verb(), "new-person");
}
fn rendered(m: &EntityModal, w: u16, h: u16) -> String {
let backend = ratatui::backend::TestBackend::new(w, h);
let mut term = ratatui::Terminal::new(backend).unwrap();
term.draw(|f| m.draw(f)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect::<String>()
}
#[test]
fn every_key_the_modal_answers_to_is_visible_on_it() {
let mut m = EntityModal::new();
m.install(sample());
let screen = rendered(&m, 130, 24);
for key in [
"r rename",
"a alias",
"m merge",
"ctrl-n CREATE person",
"esc clear",
"? help",
] {
assert!(
screen.contains(key),
"{key:?} is not shown anywhere:\n{screen}"
);
}
}
#[test]
fn an_empty_modal_says_what_ctrl_n_actually_does() {
let m = EntityModal::new();
let screen = rendered(&m, 130, 24);
assert!(screen.contains("ctrl-n CREATE a person"), "{screen}");
assert!(screen.contains("enter search"), "{screen}");
}
#[test]
fn the_merge_states_explain_themselves() {
let mut m = EntityModal::new();
m.install(sample());
m.merge_keep = Some("person-1".into());
let marked = rendered(&m, 130, 24);
assert!(marked.contains("press m again"), "{marked}");
m.merge_confirm = Some((
"person-1".into(),
"Grace Choi".into(),
"person-2".into(),
"Youn Ji Choi".into(),
));
let confirming = rendered(&m, 130, 24);
assert!(confirming.contains("CANNOT BE UNDONE"), "{confirming}");
assert!(confirming.contains("y confirm"), "{confirming}");
assert!(
confirming.contains("Youn Ji Choi") && confirming.contains("Grace Choi"),
"the confirmation must name both sides: {confirming}"
);
}
#[test]
fn it_draws_at_tiny_sizes() {
let mut m = EntityModal::new();
m.install(sample());
for (w, h) in [(1, 1), (4, 2), (10, 4), (20, 5), (80, 24)] {
let backend = ratatui::backend::TestBackend::new(w, h);
let mut term = ratatui::Terminal::new(backend).unwrap();
term.draw(|f| m.draw(f)).unwrap();
m.help = true;
term.draw(|f| m.draw(f)).unwrap();
m.help = false;
}
}
}