use std::collections::HashSet;
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use unicode_width::UnicodeWidthChar;
use super::{
layout::{Layout, LayoutEdge, LayoutNode},
types::{Direction, EdgeType, NodeShape},
};
use crate::theme::RichTextTheme;
const HLINE: char = '─';
const VLINE: char = '│';
const TLC: char = '┌';
const TRC: char = '┐';
const BLC: char = '└';
const BRC: char = '┘';
const RTLC: char = '╭';
const RTRC: char = '╮';
const RBLC: char = '╰';
const RBRC: char = '╯';
#[derive(Clone)]
struct Cell {
ch: char,
style: Style,
is_edge: bool,
}
pub fn render_layout(
layout: &Layout,
direction: &Direction,
theme: &impl RichTextTheme,
) -> Vec<Line<'static>> {
if layout.nodes.is_empty() {
return vec![Line::from(Span::styled(
"(empty diagram)",
Style::default().fg(theme.get_muted_text_color()),
))];
}
let gw = layout.grid_width;
let gh = layout.grid_height;
if gw == 0 || gh == 0 {
return Vec::new();
}
let blank = Cell {
ch: ' ',
style: Style::default(),
is_edge: false,
};
let mut grid = vec![vec![blank; gw]; gh];
for node in &layout.nodes {
draw_node(&mut grid, node, theme);
}
let is_vertical = matches!(direction, Direction::TopDown | Direction::BottomUp);
draw_all_edges(&mut grid, &layout.edges, is_vertical, theme);
let mut lines = Vec::new();
for row in grid.iter() {
let spans: Vec<Span<'static>> = row
.iter()
.map(|cell| Span::styled(cell.ch.to_string(), cell.style))
.collect();
lines.push(Line::from(spans));
}
lines
}
fn draw_node(grid: &mut [Vec<Cell>], node: &LayoutNode, theme: &impl RichTextTheme) {
let x = node.x;
let y = node.y;
let w = node.width;
let h = node.height;
if node.label.contains('\n') {
draw_multiline_node(grid, node, theme);
return;
}
let (tl, tr, bl, br) = match node.shape {
NodeShape::Rounded | NodeShape::Circle | NodeShape::Diamond => (RTLC, RTRC, RBLC, RBRC),
NodeShape::Rect => (TLC, TRC, BLC, BRC),
};
let border_style = Style::default().fg(theme.get_muted_text_color());
let text_style = Style::default().fg(theme.get_text_color());
if y < grid.len() && x + w <= grid[0].len() {
let row = &mut grid[y];
row[x] = Cell {
ch: tl,
style: border_style,
is_edge: false,
};
row[x + w - 1] = Cell {
ch: tr,
style: border_style,
is_edge: false,
};
for cell in row.iter_mut().take(x + w - 1).skip(x + 1) {
*cell = Cell {
ch: HLINE,
style: border_style,
is_edge: false,
};
}
}
let text_row = y + h / 2;
if text_row < grid.len() && x + w <= grid[0].len() {
let row = &mut grid[text_row];
row[x] = Cell {
ch: VLINE,
style: border_style,
is_edge: false,
};
row[x + w - 1] = Cell {
ch: VLINE,
style: border_style,
is_edge: false,
};
let inner_w = w.saturating_sub(2);
let label_chars: Vec<char> = node.label.chars().collect();
let label_w = unicode_width::UnicodeWidthStr::width(node.label.as_str());
let pad = if label_w < inner_w {
(inner_w - label_w) / 2
} else {
0
};
let mut cx = x + 1;
for _ in 0..pad {
if cx < x + w - 1 {
row[cx] = Cell {
ch: ' ',
style: text_style,
is_edge: false,
};
cx += 1;
}
}
for ch in &label_chars {
if cx < x + w - 1 {
row[cx] = Cell {
ch: *ch,
style: text_style,
is_edge: false,
};
cx += ch.width().unwrap_or(1);
}
}
while cx < x + w - 1 {
row[cx] = Cell {
ch: ' ',
style: text_style,
is_edge: false,
};
cx += 1;
}
}
for vy in (y + 1)..(y + h - 1) {
if vy == text_row {
continue;
}
if vy < grid.len() && x + w <= grid[0].len() {
let row = &mut grid[vy];
row[x] = Cell {
ch: VLINE,
style: border_style,
is_edge: false,
};
row[x + w - 1] = Cell {
ch: VLINE,
style: border_style,
is_edge: false,
};
for cell in row.iter_mut().take(x + w - 1).skip(x + 1) {
*cell = Cell {
ch: ' ',
style: text_style,
is_edge: false,
};
}
}
}
let bottom_row = y + h - 1;
if bottom_row < grid.len() && x + w <= grid[0].len() {
let row = &mut grid[bottom_row];
row[x] = Cell {
ch: bl,
style: border_style,
is_edge: false,
};
row[x + w - 1] = Cell {
ch: br,
style: border_style,
is_edge: false,
};
for cell in row.iter_mut().take(x + w - 1).skip(x + 1) {
*cell = Cell {
ch: HLINE,
style: border_style,
is_edge: false,
};
}
}
}
fn draw_multiline_node(grid: &mut [Vec<Cell>], node: &LayoutNode, theme: &impl RichTextTheme) {
let x = node.x;
let y = node.y;
let w = node.width;
let border_style = Style::default().fg(theme.get_muted_text_color());
let grid_w = if !grid.is_empty() {
grid[0].len()
} else {
return;
};
for (row_idx, line) in node.label.lines().enumerate() {
let ry = y + row_idx;
if ry >= grid.len() {
break;
}
if x >= grid_w {
break;
}
let row = &mut grid[ry];
let mut cx = x;
for ch in line.chars() {
if cx >= x + w || cx >= grid_w {
break;
}
let cw = ch.width().unwrap_or(1);
if cx + cw > x + w {
break;
}
let style = if is_box_drawing_char(ch) {
border_style
} else {
Style::default().fg(theme.get_text_color())
};
row[cx] = Cell {
ch,
style,
is_edge: false,
};
cx += cw;
}
}
}
fn is_box_drawing_char(ch: char) -> bool {
matches!(
ch,
'─' | '│'
| '┌'
| '┐'
| '└'
| '┘'
| '├'
| '┤'
| '┬'
| '┴'
| '┼'
| '╭'
| '╮'
| '╰'
| '╯'
| '═'
| '║'
| '╔'
| '╗'
| '╚'
| '╝'
| '╠'
| '╣'
| '╦'
| '╩'
| '╬'
)
}
fn draw_all_edges(
grid: &mut [Vec<Cell>],
edges: &[LayoutEdge],
is_vertical: bool,
theme: &impl RichTextTheme,
) {
if edges.is_empty() || grid.is_empty() {
return;
}
let gh = grid.len();
let gw = grid[0].len();
let edge_style = Style::default().fg(theme.get_secondary_color());
let arrow_style = Style::default()
.fg(theme.get_primary_color())
.add_modifier(Modifier::BOLD);
let label_style = Style::default()
.fg(theme.get_info_color())
.add_modifier(Modifier::ITALIC);
let mut global_cells: HashSet<(usize, usize)> = HashSet::new();
struct EdgeMeta {
waypoints: Vec<(usize, usize)>,
has_arrow: bool,
label: Option<String>,
}
let mut meta: Vec<EdgeMeta> = Vec::with_capacity(edges.len());
for edge in edges {
let wp = &edge.waypoints;
if wp.len() < 2 {
meta.push(EdgeMeta {
waypoints: wp.clone(),
has_arrow: false,
label: None,
});
continue;
}
meta.push(EdgeMeta {
waypoints: wp.clone(),
has_arrow: edge.edge_type == EdgeType::Arrow,
label: edge.label.clone(),
});
for i in 0..wp.len().saturating_sub(1) {
let (x1, y1) = wp[i];
let (x2, y2) = wp[i + 1];
if x1 == x2 || y1 == y2 {
rasterize_segment(&mut global_cells, x1, y1, x2, y2);
} else {
rasterize_segment(&mut global_cells, x1, y1, x1, y2);
rasterize_segment(&mut global_cells, x1, y2, x2, y2);
}
}
for &pt in wp {
global_cells.insert(pt);
}
}
let stray: Vec<(usize, usize)> = global_cells
.iter()
.copied()
.filter(|&(cx, cy)| {
let has_ortho = (cy > 0 && global_cells.contains(&(cx, cy.saturating_sub(1))))
|| (cy + 1 < gh && global_cells.contains(&(cx, cy + 1)))
|| (cx > 0 && global_cells.contains(&(cx.saturating_sub(1), cy)))
|| (cx + 1 < gw && global_cells.contains(&(cx + 1, cy)));
!has_ortho
})
.collect();
for s in stray {
global_cells.remove(&s);
}
global_cells.retain(|&(cx, cy)| {
if cy >= gh || cx >= gw {
return false;
}
grid[cy][cx].is_edge || grid[cy][cx].ch == ' '
});
for &(cx, cy) in &global_cells {
if cy >= gh || cx >= gw {
continue;
}
if !grid[cy][cx].is_edge && grid[cy][cx].ch != ' ' {
continue;
}
let up = cy > 0 && global_cells.contains(&(cx, cy.saturating_sub(1)));
let down = cy + 1 < gh && global_cells.contains(&(cx, cy + 1));
let left = cx > 0 && global_cells.contains(&(cx.saturating_sub(1), cy));
let right = cx + 1 < gw && global_cells.contains(&(cx + 1, cy));
let ch = resolve_edge_char(up, down, left, right);
grid[cy][cx] = Cell {
ch,
style: edge_style,
is_edge: true,
};
}
for m in &meta {
if !m.has_arrow || m.waypoints.len() < 2 {
continue;
}
let wp = &m.waypoints;
let last = wp[wp.len() - 1];
let prev = wp[wp.len() - 2];
let arrow_ch = if is_vertical {
if last.1 > prev.1 {
ARROW_DOWN
} else {
ARROW_UP
}
} else if last.0 > prev.0 {
ARROW_RIGHT
} else {
ARROW_LEFT
};
if last.1 < gh && last.0 < gw {
grid[last.1][last.0] = Cell {
ch: arrow_ch,
style: arrow_style,
is_edge: true,
};
}
}
for m in &meta {
if let Some(ref label) = m.label {
let wp = &m.waypoints;
if wp.len() >= 2 {
let mid = wp.len() / 2;
let (mx, my) = wp[mid];
let lw = unicode_width::UnicodeWidthStr::width(label.as_str());
let lx = mx.saturating_sub(lw / 2);
let ly = my.saturating_sub(1);
let mut cx = lx;
for ch in label.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
place_label_char(grid, cx, ly, ch, label_style);
cx += cw;
}
}
}
}
}
fn rasterize_segment(
cells: &mut HashSet<(usize, usize)>,
x1: usize,
y1: usize,
x2: usize,
y2: usize,
) {
if x1 == x2 && y1 == y2 {
cells.insert((x1, y1));
return;
}
if y1 == y2 {
let (lo, hi) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
for x in lo..hi {
cells.insert((x, y1));
}
return;
}
if x1 == x2 {
let (lo, hi) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
for y in lo..hi {
cells.insert((x1, y));
}
return;
}
let dx = x2.abs_diff(x1);
let dy = y2.abs_diff(y1);
let steps = dx.max(dy);
for i in 0..=steps {
let t = if steps > 0 {
i as f64 / steps as f64
} else {
0.0
};
let x = x1 as f64 + (x2 as f64 - x1 as f64) * t;
let y = y1 as f64 + (y2 as f64 - y1 as f64) * t;
cells.insert((x.round() as usize, y.round() as usize));
}
}
#[allow(dead_code)]
const TEE_UP: char = '┴'; const TEE_DOWN: char = '┬'; #[allow(dead_code)]
const TEE_LEFT: char = '┤'; const TEE_RIGHT: char = '├'; const CROSS: char = '┼'; const ARROW_DOWN: char = '▼';
const ARROW_UP: char = '▲';
const ARROW_RIGHT: char = '►';
const ARROW_LEFT: char = '◄';
#[rustfmt::skip]
fn resolve_edge_char(up: bool, down: bool, left: bool, right: bool) -> char {
match (up, down, left, right) {
(true, true, true, true ) => CROSS,
(true, true, true, false) => TEE_LEFT,
(true, true, false, true ) => TEE_RIGHT,
(true, false, true, true ) => TEE_UP,
(false, true, true, true ) => TEE_DOWN,
(true, true, false, false) => VLINE,
(false, false, true, true ) => HLINE,
(true, false, true, false) => BRC, (true, false, false, true ) => BLC, (false, true, true, false) => TRC, (false, true, false, true ) => TLC,
(true, false, false, false) |
(false, true, false, false) => VLINE,
(false, false, true, false) |
(false, false, false, true ) => HLINE,
(false, false, false, false) => HLINE,
}
}
fn place_label_char(grid: &mut [Vec<Cell>], x: usize, y: usize, ch: char, style: Style) {
if y < grid.len() && x < grid[0].len() {
let cell = &mut grid[y][x];
if cell.ch == ' ' || cell.is_edge {
cell.ch = ch;
cell.style = style;
}
}
}