use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::store::NodeKind;
use super::app::{FACT_WORLD_TAG, WorldbuilderApp};
use super::focus::{Focus, RightPane};
fn border(app: &WorldbuilderApp, pane: Focus) -> Style {
if app.focus == pane {
Style::new().fg(app.theme.border_focused).bold()
} else {
Style::new().fg(app.theme.border_unfocused)
}
}
pub(super) fn render(frame: &mut Frame, app: &WorldbuilderApp) {
let area = frame.area();
if area.width < 40 || area.height < 10 {
frame.render_widget(
Paragraph::new("Terminal too small — needs at least 40×10."),
area,
);
return;
}
let hints_h = if app.show_hints { 1 } else { 0 };
let outer = Layout::vertical([
Constraint::Fill(1), Constraint::Length(hints_h), Constraint::Length(4), Constraint::Length(1), ])
.split(area);
let split = app.split_ratio.clamp(2, 8) as u32;
let main = Layout::horizontal([
Constraint::Ratio(split, 10),
Constraint::Ratio(10 - split, 10),
])
.split(outer[0]);
match app.zoom {
Some(Focus::FactsPane) => render_left_tree(frame, app, main[0], Focus::FactsPane),
Some(Focus::WorldPane) => render_left_tree(frame, app, main[0], Focus::WorldPane),
_ => {
let ls = app.left_split.clamp(2, 8) as u32;
let left = Layout::vertical([
Constraint::Ratio(ls, 10),
Constraint::Ratio(10 - ls, 10),
])
.split(main[0]);
render_left_tree(frame, app, left[0], Focus::FactsPane);
render_left_tree(frame, app, left[1], Focus::WorldPane);
}
}
render_right_pane(frame, app, main[1]);
if app.show_hints {
render_hints(frame, app, outer[1]);
}
render_query(frame, app, outer[2]);
render_status(frame, app, outer[3]);
if app.hjson_preview.is_some() {
render_delta_preview(frame, app, area);
}
if app.map_input.is_some() {
render_map_input(frame, app, area);
}
}
fn render_map_input(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let Some(mi) = app.map_input.as_ref() else { return };
let where_ = match &mi.placement {
super::app::MapPlacement::Landmark { x, y, .. } => format!("({x},{y})"),
super::app::MapPlacement::River { from, to } => {
format!("({},{}) → ({},{})", from.0, from.1, to.0, to.1)
}
super::app::MapPlacement::Region { x, y, biome } => format!("({x},{y}) · {biome}"),
};
let w = (area.width * 6 / 10).clamp(30, 70);
let modal = Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height / 3,
width: w,
height: 4,
};
frame.render_widget(Clear, modal);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} {} ", mi.label, where_))
.border_style(Style::new().fg(app.theme.border_focused).bold());
let inner = block.inner(modal);
frame.render_widget(block, modal);
let lines = vec![
Line::from(vec![
Span::raw("› "),
Span::styled(mi.buffer.clone(), Style::new().bold()),
Span::styled("▌", Style::new().fg(app.theme.border_focused)),
]),
Line::from(Span::styled("Enter place · Esc cancel", Style::new().dim())),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_delta_preview(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let Some((label, ops)) = &app.hjson_preview else { return };
let w = (area.width as f32 * 0.7) as u16;
let h = ((ops.len() as u16) + 6).min(area.height);
let modal = Rect {
x: area.x + area.width.saturating_sub(w.max(30)) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w.max(30),
height: h.max(6),
};
frame.render_widget(Clear, modal);
let block = Block::default()
.borders(Borders::ALL)
.title(" Confirm delta → world.hjson ")
.border_style(Style::new().fg(app.theme.border_focused).bold());
let inner = block.inner(modal);
frame.render_widget(block, modal);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(label.clone(), Style::new().bold()))];
lines.push(Line::from(""));
for op in ops {
lines.push(Line::from(Span::styled(
format!(" {}", op.preview()),
Style::new().fg(app.theme.ai_scope_fg),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"y accept (into pending) · n/Esc discard · then /write to commit",
Style::new().dim(),
)));
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn render_left_tree(frame: &mut Frame, app: &WorldbuilderApp, area: Rect, pane: Focus) {
let is_facts = pane == Focus::FactsPane;
let (tree, pins, title) = if is_facts {
(&app.facts_tree, &app.facts_pins, " Facts ")
} else {
(&app.world_tree, &app.world_pins, " World ")
};
let title = if is_facts && app.facts_filter_world {
" Facts · ◎ only "
} else {
title
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border(app, pane));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
for (i, row) in tree.rows().iter().enumerate() {
let node = app.hierarchy.get(row.id);
let name = node.map(|n| n.title.clone()).unwrap_or_else(|| "?".to_string());
let fold = if row.has_children {
if row.expanded { "▾" } else { "▸" }
} else {
" "
};
let mut style = Style::new();
let kglyph = if is_facts {
match node {
Some(n) if n.kind == NodeKind::Paragraph => {
if n.tags.iter().any(|t| t == FACT_WORLD_TAG) {
"◎"
} else {
if app.facts_filter_world {
style = style.dim();
}
"·"
}
}
_ => " ",
}
} else if app.is_world_compiler_owned(row.id) {
style = style.dim();
"⊙"
} else {
" "
};
let pin = if pins.contains(&row.id) { "⬡" } else { " " };
let indent = " ".repeat(row.depth);
let text = format!("{pin}{indent}{fold} {kglyph} {name}");
if i == tree.cursor {
style = style.add_modifier(Modifier::REVERSED);
}
lines.push(Line::from(Span::styled(text, style)));
}
if lines.is_empty() {
let empty = if is_facts {
"(no Facts book yet)"
} else {
"(no World book — run the interview or /compile)"
};
lines.push(Line::from(Span::styled(empty, Style::new().dim())));
}
frame.render_widget(
Paragraph::new(lines).scroll((tree.scroll as u16, 0)),
inner,
);
}
fn render_right_pane(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let title = format!(" {} ", app.right_pane.title());
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.title(Line::from(Span::styled(" Ctrl+R cycles ", Style::new().dim())).right_aligned())
.border_style(border(app, Focus::RightPane));
let inner = block.inner(area);
frame.render_widget(block, area);
match app.right_pane {
RightPane::Chat => render_chat(frame, app, inner),
RightPane::Research => render_research(frame, app, inner),
RightPane::Map => {
app.map_pane_rect.set(Some(inner));
if let (false, Some(cell)) = (app.map_edit, app.map_raster.as_ref()) {
let widget = ratatui_image::StatefulImage::new();
frame.render_stateful_widget(widget, inner, &mut cell.borrow_mut());
} else {
super::map::render_map(frame, app, inner);
}
}
RightPane::Ledger => render_ledger(frame, app, inner),
}
}
fn render_chat(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
for turn in &app.chat {
lines.push(Line::from(Span::styled(
"[You]",
Style::new().fg(app.theme.ai_scope_fg).bold(),
)));
for l in turn.prompt.lines() {
lines.push(Line::from(l.to_string()));
}
lines.push(Line::from(""));
let hdr = if turn.streaming {
"[World Builder — …]"
} else {
"[World Builder]"
};
lines.push(Line::from(Span::styled(hdr, Style::new().bold())));
for l in turn.response.lines() {
lines.push(Line::from(l.to_string()));
}
lines.push(Line::from(""));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Ask the World Builder a question, or shape the world with / commands (WB-P4).",
Style::new().dim(),
)));
}
let total = lines.len() as u16;
let scroll = if app.chat_scroll == u16::MAX {
total.saturating_sub(area.height)
} else {
app.chat_scroll
};
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }).scroll((scroll, 0)),
area,
);
}
fn render_research(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
match &app.research_query {
None => {
lines.push(Line::from(Span::styled(
"/research <query> retrieves related Facts here.",
Style::new().dim(),
)));
lines.push(Line::from(Span::styled(
"/wfact <statement> records an author-decided fact:world.",
Style::new().dim(),
)));
}
Some(q) => {
lines.push(Line::from(vec![
Span::styled("query: ", Style::new().dim()),
Span::styled(q.clone(), Style::new().bold()),
]));
lines.push(Line::from(""));
if app.research_hits.is_empty() {
lines.push(Line::from(Span::styled("(no matching Facts)", Style::new().dim())));
}
for (i, p) in app.research_hits.iter().enumerate() {
let is_world = app
.hierarchy
.get(p.id)
.map(|n| n.tags.iter().any(|t| t == FACT_WORLD_TAG))
.unwrap_or(false);
let glyph = if is_world { "◎" } else { "·" };
let on_cursor = i == app.research_cursor;
let cursor = if on_cursor { "▸ " } else { " " };
let mut breadcrumb = Style::new().bold();
if on_cursor {
breadcrumb = breadcrumb.add_modifier(Modifier::REVERSED);
}
lines.push(Line::from(vec![
Span::styled(format!("{cursor}{glyph} "), Style::new().fg(app.theme.ai_scope_fg)),
Span::styled(p.breadcrumb.clone(), breadcrumb),
Span::styled(format!(" {:.2}", p.score), Style::new().dim()),
]));
let body: String = p.body.trim().chars().take(200).collect();
for l in body.lines() {
lines.push(Line::from(Span::raw(format!(" {l}"))));
}
lines.push(Line::from(""));
}
if !app.research_hits.is_empty() {
lines.push(Line::from(Span::styled(
"j/k move · a promote to ◎ world fact",
Style::new().dim(),
)));
}
}
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}
fn render_ledger(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
match &app.ledger_snapshot {
None => {
lines.push(Line::from(Span::styled(
"No magic ledger. This world runs on physics alone.",
Style::new().dim(),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"/rule <kind> <category,category> [description] declares an exception",
Style::new().dim(),
)));
lines.push(Line::from(Span::styled(
"/magic on|off toggles the ledger",
Style::new().dim(),
)));
}
Some(ledger) => {
let state = if ledger.enabled { "enabled" } else { "disabled" };
lines.push(Line::from(vec![
Span::styled("Magic ledger · ", Style::new().bold()),
Span::styled(
state,
Style::new().fg(if ledger.enabled {
app.theme.ai_scope_fg
} else {
app.theme.border_unfocused
}),
),
Span::styled(format!(" · {} rule(s)", ledger.rules.len()), Style::new().dim()),
]));
lines.push(Line::from(""));
for (i, r) in ledger.rules.iter().enumerate() {
let kind = if r.kind.trim().is_empty() { "(no kind)" } else { r.kind.trim() };
lines.push(Line::from(vec![
Span::styled(format!("{}. ", i + 1), Style::new().dim()),
Span::styled(kind.to_string(), Style::new().bold()),
Span::styled(
format!(" covers: {}", if r.covers.is_empty() { "—".into() } else { r.covers.join(", ") }),
Style::new().fg(app.theme.ai_scope_fg),
),
]));
if !r.description.trim().is_empty() {
lines.push(Line::from(Span::raw(format!(" {}", r.description.trim()))));
}
let ap = &r.applicable_to;
let facet = |label: &str, v: &Option<Vec<String>>| {
v.as_ref().filter(|l| !l.is_empty()).map(|l| format!("{label} {}", l.join("/")))
};
let facets: Vec<String> = [
facet("roles", &ap.roles),
facet("regions", &ap.regions),
facet("seasons", &ap.seasons),
]
.into_iter()
.flatten()
.collect();
if !facets.is_empty() {
lines.push(Line::from(Span::styled(
format!(" applies: {}", facets.join(" · ")),
Style::new().dim(),
)));
}
}
let lint = ledger.lint();
if !lint.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("lint:", Style::new().bold())));
for w in &lint {
lines.push(Line::from(Span::styled(
format!(" ! {}", w.text),
Style::new().fg(ratatui::style::Color::Yellow),
)));
}
}
}
}
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}
fn render_query(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Query ")
.border_style(border(app, Focus::QueryPrompt));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(&app.query, inner);
}
fn render_hints(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let hint = match app.focus {
Focus::FactsPane => {
" j/k·move h/l·fold Ctrl+P·pin Ctrl+T·◎tag Shift+F·filter z·zoom Tab·cycle"
}
Focus::WorldPane => {
" j/k·move h/l·fold Ctrl+P·pin z·zoom (⊙ chapters are compiler-owned) Tab·cycle"
}
Focus::QueryPrompt => {
" /interview · /roll · ask · /wfact · /compile /validate · /set… · /write · Tab"
}
Focus::RightPane => {
" Ctrl+R·cycle pane · Map: e·edit (hjkl move) · /map raster · /compile ASCII · Ctrl+Q"
}
_ => " Tab·cycle Ctrl+R·right pane { }·rows [ ]·cols ?·hints Ctrl+Q·quit",
};
frame.render_widget(Paragraph::new(Span::styled(hint, Style::new().dim())), area);
}
fn render_status(frame: &mut Frame, app: &WorldbuilderApp, area: Rect) {
let star = match app.plausibility_score {
Some(s) => {
let d = app.plausibility_delta_chip();
if d.is_empty() {
format!(" · ★ {s}")
} else {
format!(" · ★ {s} {d}")
}
}
None => String::new(),
};
let left = format!(" worldbuilder · {}{star} · s:{} ", app.world_name(), app.session.slug);
let right = format!("{} ", app.status);
let cols = Layout::horizontal([Constraint::Length(left.len() as u16 + 1), Constraint::Fill(1)])
.split(area);
frame.render_widget(
Paragraph::new(Span::styled(left, Style::new().fg(app.theme.ai_scope_fg))),
cols[0],
);
frame.render_widget(
Paragraph::new(Span::styled(right, Style::new().dim())).right_aligned(),
cols[1],
);
}