use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
};
use crate::tui::theme::Theme;
const DEFAULT_ACTIVE_COLOR: Color = Color::Rgb(245, 158, 11);
const DEFAULT_ACTIVE_GLOW_COLOR: Color = Color::Rgb(251, 191, 36);
const DEFAULT_INACTIVE_COLOR: Color = Color::DarkGray;
const DEFAULT_PREVIEW_COLOR: Color = Color::Rgb(107, 114, 128);
const DEFAULT_BINDING_COLOR: Color = Color::Rgb(139, 92, 246);
const FLOW_FRAMES_V: &[char] = &['╽', '┃', '╿', '│'];
const FLOW_FRAMES_H: &[char] = &['╼', '━', '╾', '─'];
const ARROW_DOWN: &str = "▼";
const ARROW_RIGHT: &str = "▶";
const ARROW_LEFT: &str = "◀";
const ARROW_UP: &str = "▲";
const CORNER_TL_SMOOTH: &str = "╭";
const CORNER_TR_SMOOTH: &str = "╮";
const CORNER_BL_SMOOTH: &str = "╰";
const CORNER_BR_SMOOTH: &str = "╯";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EdgeStyle {
#[default]
Sharp,
Smooth,
}
#[derive(Debug, Clone)]
pub struct DagEdge<'a> {
pub from: (u16, u16),
pub to: (u16, u16),
pub binding: Option<String>,
pub preview: Option<String>,
pub active: bool,
pub frame: u8,
pub style: EdgeStyle,
theme: Option<&'a Theme>,
}
impl<'a> DagEdge<'a> {
pub fn new(from: (u16, u16), to: (u16, u16)) -> Self {
Self {
from,
to,
binding: None,
preview: None,
active: false,
frame: 0,
style: EdgeStyle::default(),
theme: None,
}
}
pub fn with_theme(mut self, theme: &'a Theme) -> Self {
self.theme = Some(theme);
self
}
pub fn with_binding(mut self, binding: impl Into<String>) -> Self {
self.binding = Some(binding.into());
self
}
pub fn with_preview(mut self, preview: impl Into<String>) -> Self {
self.preview = Some(preview.into());
self
}
pub fn with_active(mut self, active: bool) -> Self {
self.active = active;
self
}
pub fn with_frame(mut self, frame: u8) -> Self {
self.frame = frame;
self
}
pub fn with_style(mut self, style: EdgeStyle) -> Self {
self.style = style;
self
}
fn animated_v_char(&self, y: u16) -> char {
if self.active && self.frame > 0 {
let idx = ((y as usize) + (self.frame as usize / 4)) % FLOW_FRAMES_V.len();
FLOW_FRAMES_V[idx]
} else if self.active {
'┃'
} else {
'│'
}
}
fn animated_h_char(&self, x: u16) -> char {
if self.active && self.frame > 0 {
let idx = ((x as usize) + (self.frame as usize / 4)) % FLOW_FRAMES_H.len();
FLOW_FRAMES_H[idx]
} else if self.active {
'━'
} else {
'─'
}
}
fn edge_style(&self) -> Style {
let active_color = self
.theme
.map(|t| t.status_running)
.unwrap_or(DEFAULT_ACTIVE_COLOR);
let active_glow_color = self
.theme
.map(|t| t.highlight)
.unwrap_or(DEFAULT_ACTIVE_GLOW_COLOR);
let inactive_color = self
.theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_INACTIVE_COLOR);
if self.active {
if self.frame > 0 && (self.frame / 8) % 2 == 0 {
Style::default()
.fg(active_glow_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(active_color)
}
} else {
Style::default().fg(inactive_color)
}
}
pub fn render(&self, buf: &mut Buffer, area: Rect) {
let style = self.edge_style();
let from_x = self.from.0;
let from_y = self.from.1;
let to_x = self.to.0;
let to_y = self.to.1;
if !self.is_in_bounds(from_x, from_y, &area) && !self.is_in_bounds(to_x, to_y, &area) {
return;
}
if from_x == to_x {
self.render_vertical_edge(buf, area, from_x, from_y, to_y, style);
} else if from_y == to_y {
self.render_horizontal_edge(buf, area, from_x, to_x, from_y, style);
} else {
self.render_l_edge(buf, area, style);
}
}
fn is_in_bounds(&self, x: u16, y: u16, area: &Rect) -> bool {
x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height
}
fn render_vertical_edge(
&self,
buf: &mut Buffer,
area: Rect,
x: u16,
from_y: u16,
to_y: u16,
style: Style,
) {
let (start_y, end_y) = if from_y < to_y {
(from_y, to_y)
} else {
(to_y, from_y)
};
let mid_y = start_y.saturating_add(end_y.saturating_sub(start_y) / 2);
for y in start_y..=end_y {
if self.is_in_bounds(x, y, &area) {
let has_label = self.binding.is_some() || self.preview.is_some();
let is_label_area =
has_label && (y >= mid_y.saturating_sub(1) && y <= mid_y.saturating_add(1));
if !is_label_area {
let line_char = self.animated_v_char(y);
buf.set_string(x, y, line_char.to_string(), style);
}
}
}
if self.is_in_bounds(x, end_y, &area) {
buf.set_string(x, end_y, ARROW_DOWN, style);
}
self.render_labels(buf, area, x, mid_y);
}
fn render_horizontal_edge(
&self,
buf: &mut Buffer,
area: Rect,
from_x: u16,
to_x: u16,
y: u16,
style: Style,
) {
let (start_x, end_x) = if from_x < to_x {
(from_x, to_x)
} else {
(to_x, from_x)
};
for x in start_x..=end_x {
if self.is_in_bounds(x, y, &area) {
let line_char = self.animated_h_char(x);
buf.set_string(x, y, line_char.to_string(), style);
}
}
}
fn render_l_edge(&self, buf: &mut Buffer, area: Rect, style: Style) {
let from_x = self.from.0;
let from_y = self.from.1;
let to_x = self.to.0;
let to_y = self.to.1;
let going_down = to_y > from_y;
let going_right = to_x > from_x;
let corner_y = to_y;
let corner_x = from_x;
let (v_start, v_end) = if going_down {
(from_y, corner_y)
} else {
(corner_y, from_y)
};
let mid_y = v_start.saturating_add(v_end.saturating_sub(v_start) / 2);
for y in v_start..v_end {
if self.is_in_bounds(corner_x, y, &area) {
let has_label = self.binding.is_some() || self.preview.is_some();
let is_label_area =
has_label && (y >= mid_y.saturating_sub(1) && y <= mid_y.saturating_add(1));
if !is_label_area {
let line_char = self.animated_v_char(y);
buf.set_string(corner_x, y, line_char.to_string(), style);
}
}
}
let corner_char = match (self.style, going_down, going_right) {
(EdgeStyle::Smooth, true, true) => CORNER_BL_SMOOTH, (EdgeStyle::Smooth, true, false) => CORNER_BR_SMOOTH, (EdgeStyle::Smooth, false, true) => CORNER_TL_SMOOTH, (EdgeStyle::Smooth, false, false) => CORNER_TR_SMOOTH, (EdgeStyle::Sharp, true, true) => "└", (EdgeStyle::Sharp, true, false) => "┘", (EdgeStyle::Sharp, false, true) => "┌", (EdgeStyle::Sharp, false, false) => "┐", };
if self.is_in_bounds(corner_x, corner_y, &area) {
buf.set_string(corner_x, corner_y, corner_char, style);
}
let (h_start, h_end) = if going_right {
(corner_x.saturating_add(1), to_x)
} else {
(to_x, corner_x.saturating_sub(1))
};
for x in h_start..=h_end {
if self.is_in_bounds(x, corner_y, &area) {
if x == to_x {
let arrow = if going_right { ARROW_RIGHT } else { ARROW_LEFT };
buf.set_string(x, corner_y, arrow, style);
} else {
let line_char = self.animated_h_char(x);
buf.set_string(x, corner_y, line_char.to_string(), style);
}
}
}
self.render_labels(buf, area, corner_x, mid_y);
}
fn render_labels(&self, buf: &mut Buffer, area: Rect, x: u16, y: u16) {
let binding_color = self
.theme
.map(|t| t.trait_authored)
.unwrap_or(DEFAULT_BINDING_COLOR);
let preview_color = self
.theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_PREVIEW_COLOR);
if let Some(binding) = &self.binding {
let label = binding.as_str();
let label_width = label.len() as u16;
let label_x = x.saturating_add(2);
let label_y = y;
if self.is_in_bounds(label_x, label_y, &area) {
let available_width = area.x + area.width - label_x;
let display_label = if label_width > available_width {
let truncate_at = available_width.saturating_sub(3) as usize;
if truncate_at > 0 {
format!("{}...", &label[..truncate_at.min(label.len())])
} else {
String::new()
}
} else {
label.to_string()
};
if !display_label.is_empty() {
buf.set_string(
label_x,
label_y,
&display_label,
Style::default().fg(binding_color),
);
}
}
}
if let Some(preview) = &self.preview {
let preview_y = y.saturating_add(1);
let preview_x = x.saturating_add(2);
if self.is_in_bounds(preview_x, preview_y, &area) {
let formatted = format!("{} {}", char::from_u32(0x2591).unwrap_or(' '), preview);
let available_width = (area.x + area.width).saturating_sub(preview_x) as usize;
let display_preview = if formatted.len() > available_width {
let truncate_at = available_width.saturating_sub(4);
if truncate_at > 0 {
format!("{}...", &formatted[..truncate_at.min(formatted.len())])
} else {
String::new()
}
} else {
formatted
};
if !display_preview.is_empty() {
buf.set_string(
preview_x,
preview_y,
&display_preview,
Style::default().fg(preview_color),
);
}
}
}
}
}
pub fn render_merge(
sources: &[(u16, u16)],
target: (u16, u16),
buf: &mut Buffer,
area: Rect,
active: bool,
theme: Option<&Theme>,
) {
if sources.is_empty() {
return;
}
let active_color = theme
.map(|t| t.status_running)
.unwrap_or(DEFAULT_ACTIVE_COLOR);
let inactive_color = theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_INACTIVE_COLOR);
let edge_color = if active { active_color } else { inactive_color };
let style = Style::default().fg(edge_color);
let line_h = if active { "━" } else { "─" };
let line_v = if active { "┃" } else { "│" };
let target_x = target.0;
let target_y = target.1;
let mut sorted_sources = sources.to_vec();
sorted_sources.sort_by_key(|(x, _)| *x);
let merge_y = target_y.saturating_sub(1);
if merge_y < target_y {
for y in merge_y..target_y {
if is_in_bounds(target_x, y, &area) {
buf.set_string(target_x, y, line_v, style);
}
}
}
if is_in_bounds(target_x, target_y, &area) {
buf.set_string(target_x, target_y, "▼", style);
}
if is_in_bounds(target_x, merge_y, &area) {
let merge_char = if sources.len() > 1 { "┬" } else { "│" };
buf.set_string(target_x, merge_y, merge_char, style);
}
for (src_x, src_y) in sorted_sources.iter() {
let src_x = *src_x;
let src_y = *src_y;
if src_x == target_x {
for y in src_y..merge_y {
if is_in_bounds(src_x, y, &area) {
buf.set_string(src_x, y, line_v, style);
}
}
} else {
for y in src_y..merge_y {
if is_in_bounds(src_x, y, &area) {
buf.set_string(src_x, y, line_v, style);
}
}
let corner = if src_x < target_x { "└" } else { "┘" };
if is_in_bounds(src_x, merge_y, &area) {
buf.set_string(src_x, merge_y, corner, style);
}
let (h_start, h_end) = if src_x < target_x {
(src_x + 1, target_x)
} else {
(target_x + 1, src_x)
};
for x in h_start..h_end {
if is_in_bounds(x, merge_y, &area) {
buf.set_string(x, merge_y, line_h, style);
}
}
}
}
}
fn is_in_bounds(x: u16, y: u16, area: &Rect) -> bool {
x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_edge_creation() {
let edge = DagEdge::new((10, 5), (10, 15));
assert_eq!(edge.from, (10, 5));
assert_eq!(edge.to, (10, 15));
assert_eq!(edge.binding, None);
assert_eq!(edge.preview, None);
assert!(!edge.active);
}
#[test]
fn test_edge_with_binding() {
let edge = DagEdge::new((5, 0), (5, 10)).with_binding("{{with.data}}");
assert_eq!(edge.binding, Some("{{with.data}}".to_string()));
}
#[test]
fn test_edge_with_preview() {
let edge = DagEdge::new((5, 0), (5, 10)).with_preview("some data...");
assert_eq!(edge.preview, Some("some data...".to_string()));
}
#[test]
fn test_edge_active_state() {
let inactive_edge = DagEdge::new((5, 0), (5, 10));
assert!(!inactive_edge.active);
let active_edge = DagEdge::new((5, 0), (5, 10)).with_active(true);
assert!(active_edge.active);
}
#[test]
fn test_edge_builder_chain() {
let edge = DagEdge::new((0, 0), (10, 10))
.with_binding("{{with.result}}")
.with_preview("preview text")
.with_active(true);
assert_eq!(edge.from, (0, 0));
assert_eq!(edge.to, (10, 10));
assert_eq!(edge.binding, Some("{{with.result}}".to_string()));
assert_eq!(edge.preview, Some("preview text".to_string()));
assert!(edge.active);
}
#[test]
fn test_edge_render_vertical_does_not_panic() {
let edge = DagEdge::new((5, 2), (5, 8));
let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 15));
edge.render(&mut buffer, Rect::new(0, 0, 20, 15));
let cell = buffer.cell((5, 8)).unwrap();
assert_eq!(cell.symbol(), "▼");
}
#[test]
fn test_edge_render_active_uses_amber_color() {
let edge = DagEdge::new((5, 2), (5, 8)).with_active(true);
let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 15));
edge.render(&mut buffer, Rect::new(0, 0, 20, 15));
let cell = buffer.cell((5, 3)).unwrap();
assert_eq!(cell.symbol(), "┃");
}
#[test]
fn test_edge_render_inactive_uses_thin_line() {
let edge = DagEdge::new((5, 2), (5, 8));
let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 15));
edge.render(&mut buffer, Rect::new(0, 0, 20, 15));
let cell = buffer.cell((5, 3)).unwrap();
assert_eq!(cell.symbol(), "│");
}
#[test]
fn test_edge_render_with_binding_label() {
let edge = DagEdge::new((5, 2), (5, 10)).with_binding("{{with.ctx}}");
let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 15));
edge.render(&mut buffer, Rect::new(0, 0, 30, 15));
let cell = buffer.cell((7, 6)).unwrap();
assert_eq!(cell.symbol(), "{");
}
#[test]
fn test_merge_render_does_not_panic() {
let sources = vec![(5, 2), (10, 2), (15, 2)];
let target = (10, 10);
let mut buffer = Buffer::empty(Rect::new(0, 0, 25, 15));
render_merge(
&sources,
target,
&mut buffer,
Rect::new(0, 0, 25, 15),
false,
None,
);
let cell = buffer.cell((10, 9)).unwrap();
assert_eq!(cell.symbol(), "┬");
}
#[test]
fn test_merge_single_source() {
let sources = vec![(10, 2)];
let target = (10, 10);
let mut buffer = Buffer::empty(Rect::new(0, 0, 25, 15));
render_merge(
&sources,
target,
&mut buffer,
Rect::new(0, 0, 25, 15),
false,
None,
);
let cell = buffer.cell((10, 9)).unwrap();
assert_eq!(cell.symbol(), "│");
}
#[test]
fn test_merge_empty_sources() {
let sources: Vec<(u16, u16)> = vec![];
let target = (10, 10);
let mut buffer = Buffer::empty(Rect::new(0, 0, 25, 15));
render_merge(
&sources,
target,
&mut buffer,
Rect::new(0, 0, 25, 15),
false,
None,
);
let cell = buffer.cell((10, 10)).unwrap();
assert_eq!(cell.symbol(), " ");
}
#[test]
fn test_merge_active_state() {
let sources = vec![(5, 2), (15, 2)];
let target = (10, 10);
let mut buffer = Buffer::empty(Rect::new(0, 0, 25, 15));
render_merge(
&sources,
target,
&mut buffer,
Rect::new(0, 0, 25, 15),
true,
None,
);
let cell = buffer.cell((10, 9)).unwrap();
assert_eq!(cell.symbol(), "┬");
let h_cell = buffer.cell((8, 9)).unwrap();
assert_eq!(h_cell.symbol(), "━");
}
#[test]
fn test_is_in_bounds() {
let area = Rect::new(5, 5, 10, 10);
assert!(is_in_bounds(5, 5, &area));
assert!(is_in_bounds(10, 10, &area));
assert!(is_in_bounds(14, 14, &area));
assert!(!is_in_bounds(4, 5, &area));
assert!(!is_in_bounds(5, 4, &area));
assert!(!is_in_bounds(15, 5, &area));
assert!(!is_in_bounds(5, 15, &area));
}
#[test]
fn test_edge_l_shaped_down_right() {
let edge = DagEdge::new((5, 2), (15, 8));
let mut buffer = Buffer::empty(Rect::new(0, 0, 25, 15));
edge.render(&mut buffer, Rect::new(0, 0, 25, 15));
let cell = buffer.cell((5, 8)).unwrap();
assert_eq!(cell.symbol(), "└");
}
#[test]
fn test_edge_l_shaped_down_left() {
let edge = DagEdge::new((15, 2), (5, 8));
let mut buffer = Buffer::empty(Rect::new(0, 0, 25, 15));
edge.render(&mut buffer, Rect::new(0, 0, 25, 15));
let cell = buffer.cell((15, 8)).unwrap();
assert_eq!(cell.symbol(), "┘");
}
#[test]
fn test_edge_out_of_bounds_does_not_panic() {
let edge = DagEdge::new((100, 100), (200, 200));
let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 15));
edge.render(&mut buffer, Rect::new(0, 0, 20, 15));
let cell = buffer.cell((0, 0)).unwrap();
assert_eq!(cell.symbol(), " ");
}
}