use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use crate::tui::tokens::compat;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ChatPosition {
pub x: u16,
pub y: u16,
}
impl ChatPosition {
pub fn new(x: u16, y: u16) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone)]
pub struct ChatEdgeLine {
from: ChatPosition,
to: ChatPosition,
label: Option<String>,
active: bool,
animation_tick: u8,
color: Color,
}
impl ChatEdgeLine {
pub fn new(from: ChatPosition, to: ChatPosition) -> Self {
Self {
from,
to,
label: None,
active: false,
animation_tick: 0,
color: compat::SLATE_600,
}
}
pub fn with_label(mut self, label: &str) -> Self {
self.label = Some(label.to_string());
self
}
pub fn with_active(mut self, active: bool) -> Self {
self.active = active;
if active {
self.color = compat::CYAN_500;
} else {
self.color = compat::SLATE_600;
}
self
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn from(&self) -> ChatPosition {
self.from
}
pub fn to(&self) -> ChatPosition {
self.to
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn active(&self) -> bool {
self.active
}
pub fn is_vertical(&self) -> bool {
self.from.x == self.to.x
}
pub fn is_horizontal(&self) -> bool {
self.from.y == self.to.y
}
pub fn length(&self) -> u16 {
if self.is_vertical() {
self.to.y.abs_diff(self.from.y)
} else if self.is_horizontal() {
self.to.x.abs_diff(self.from.x)
} else {
self.to.y.abs_diff(self.from.y) + self.to.x.abs_diff(self.from.x)
}
}
pub fn tick(&mut self) {
if self.active {
self.animation_tick = self.animation_tick.wrapping_add(1);
}
}
pub fn flow_position(&self) -> Option<ChatPosition> {
if !self.active {
return None;
}
let len = self.length();
if len == 0 {
return None;
}
let cycle_len = len.saturating_mul(2);
if cycle_len == 0 {
return None;
}
let offset = self.animation_tick as u16 % cycle_len;
let offset = if offset > len {
cycle_len - offset
} else {
offset
};
if self.is_vertical() {
let y = if self.from.y < self.to.y {
self.from.y.saturating_add(offset)
} else {
self.from.y.saturating_sub(offset)
};
Some(ChatPosition::new(self.from.x, y))
} else if self.is_horizontal() {
let x = if self.from.x < self.to.x {
self.from.x.saturating_add(offset)
} else {
self.from.x.saturating_sub(offset)
};
Some(ChatPosition::new(x, self.from.y))
} else {
let mid_x = (self.from.x + self.to.x) / 2;
let mid_y = (self.from.y + self.to.y) / 2;
Some(ChatPosition::new(mid_x, mid_y))
}
}
}
impl Widget for ChatEdgeLine {
fn render(self, area: Rect, buf: &mut Buffer) {
let style = Style::default().fg(self.color);
let in_bounds = |p: ChatPosition| {
p.x >= area.x
&& p.x < area.x + area.width
&& p.y >= area.y
&& p.y < area.y + area.height
};
if self.is_vertical() {
let x = self.from.x;
let (start_y, end_y) = if self.from.y < self.to.y {
(self.from.y, self.to.y)
} else {
(self.to.y, self.from.y)
};
for y in start_y..end_y {
if in_bounds(ChatPosition::new(x, y)) {
buf[(x, y)].set_symbol("│").set_style(style);
}
}
let arrow_pos = ChatPosition::new(x, end_y);
if in_bounds(arrow_pos) {
let arrow = if self.from.y < self.to.y {
"▼"
} else {
"▲"
};
buf[(x, end_y)].set_symbol(arrow).set_style(style);
}
} else if self.is_horizontal() {
let y = self.from.y;
let (start_x, end_x) = if self.from.x < self.to.x {
(self.from.x, self.to.x)
} else {
(self.to.x, self.from.x)
};
for x in start_x..end_x {
if in_bounds(ChatPosition::new(x, y)) {
buf[(x, y)].set_symbol("─").set_style(style);
}
}
let arrow_pos = ChatPosition::new(end_x, y);
if in_bounds(arrow_pos) {
let arrow = if self.from.x < self.to.x {
"▶"
} else {
"◀"
};
buf[(end_x, y)].set_symbol(arrow).set_style(style);
}
} else {
let mid_y = self.to.y;
let (v_start, v_end) = if self.from.y < mid_y {
(self.from.y, mid_y)
} else {
(mid_y, self.from.y)
};
for y in v_start..=v_end {
if in_bounds(ChatPosition::new(self.from.x, y)) {
buf[(self.from.x, y)].set_symbol("│").set_style(style);
}
}
if in_bounds(ChatPosition::new(self.from.x, mid_y)) {
let corner = if self.from.x < self.to.x && self.from.y < mid_y {
"└"
} else if self.from.x < self.to.x && self.from.y > mid_y {
"┌"
} else if self.from.x > self.to.x && self.from.y < mid_y {
"┘"
} else {
"┐"
};
buf[(self.from.x, mid_y)]
.set_symbol(corner)
.set_style(style);
}
let (h_start, h_end) = if self.from.x < self.to.x {
(self.from.x, self.to.x)
} else {
(self.to.x, self.from.x)
};
for x in h_start..h_end {
if x != self.from.x && in_bounds(ChatPosition::new(x, mid_y)) {
buf[(x, mid_y)].set_symbol("─").set_style(style);
}
}
if in_bounds(ChatPosition::new(self.to.x, mid_y)) {
let arrow = if self.from.x < self.to.x {
"▶"
} else {
"◀"
};
buf[(self.to.x, mid_y)].set_symbol(arrow).set_style(style);
}
}
if let Some(label) = &self.label {
let mid = self.length() / 2;
let (lx, ly) = if self.is_vertical() {
let y = if self.from.y < self.to.y {
self.from.y.saturating_add(mid)
} else {
self.from.y.saturating_sub(mid)
};
(self.from.x.saturating_add(1), y)
} else {
let x = if self.from.x < self.to.x {
self.from.x.saturating_add(mid)
} else {
self.from.x.saturating_sub(mid)
};
(x, self.from.y.saturating_sub(1))
};
if in_bounds(ChatPosition::new(lx, ly)) {
let label_style = Style::default().fg(compat::AMBER_500);
buf.set_string(lx, ly, label, label_style);
}
}
if let Some(pos) = self.flow_position() {
if in_bounds(pos) {
let flow_style = Style::default()
.fg(compat::CYAN_500)
.add_modifier(Modifier::BOLD);
buf[(pos.x, pos.y)].set_symbol("●").set_style(flow_style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chat_edge_line_creation() {
let edge = ChatEdgeLine::new(ChatPosition::new(10, 5), ChatPosition::new(10, 10));
assert_eq!(edge.from().x, 10);
assert_eq!(edge.to().y, 10);
assert!(edge.is_vertical());
}
#[test]
fn test_chat_edge_line_horizontal() {
let edge = ChatEdgeLine::new(ChatPosition::new(5, 10), ChatPosition::new(15, 10));
assert!(edge.is_horizontal());
assert!(!edge.is_vertical());
}
#[test]
fn test_chat_edge_line_with_label() {
let edge = ChatEdgeLine::new(ChatPosition::new(10, 5), ChatPosition::new(10, 10))
.with_label("with.ctx");
assert_eq!(edge.label(), Some("with.ctx"));
}
#[test]
fn test_chat_edge_line_length_vertical() {
let edge = ChatEdgeLine::new(ChatPosition::new(10, 0), ChatPosition::new(10, 10));
assert_eq!(edge.length(), 10);
}
#[test]
fn test_chat_edge_line_length_horizontal() {
let edge = ChatEdgeLine::new(ChatPosition::new(0, 5), ChatPosition::new(20, 5));
assert_eq!(edge.length(), 20);
}
#[test]
fn test_chat_edge_line_diagonal() {
let edge = ChatEdgeLine::new(ChatPosition::new(0, 0), ChatPosition::new(5, 5));
assert!(!edge.is_vertical());
assert!(!edge.is_horizontal());
assert_eq!(edge.length(), 10);
}
#[test]
fn test_chat_edge_line_flow_position() {
let mut edge = ChatEdgeLine::new(ChatPosition::new(10, 0), ChatPosition::new(10, 10))
.with_active(true);
let pos0 = edge.flow_position().unwrap();
assert!(pos0.y <= 10);
for _ in 0..5 {
edge.tick();
}
let pos1 = edge.flow_position().unwrap();
assert!(pos1.y <= 10);
}
#[test]
fn test_chat_edge_line_no_flow_when_inactive() {
let edge = ChatEdgeLine::new(ChatPosition::new(10, 0), ChatPosition::new(10, 10));
assert!(edge.flow_position().is_none());
}
#[test]
fn test_chat_edge_line_tick() {
let mut edge = ChatEdgeLine::new(ChatPosition::new(10, 0), ChatPosition::new(10, 10))
.with_active(true);
let initial = edge.animation_tick;
edge.tick();
assert_eq!(edge.animation_tick, initial.wrapping_add(1));
}
#[test]
fn test_chat_edge_line_tick_only_when_active() {
let mut edge = ChatEdgeLine::new(ChatPosition::new(10, 0), ChatPosition::new(10, 10));
let initial = edge.animation_tick;
edge.tick();
assert_eq!(edge.animation_tick, initial);
}
#[test]
fn test_chat_edge_line_render_vertical() {
let edge = ChatEdgeLine::new(ChatPosition::new(5, 1), ChatPosition::new(5, 5));
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
edge.render(buf.area, &mut buf);
assert_eq!(buf.cell((5, 2)).unwrap().symbol(), "│");
assert_eq!(buf.cell((5, 3)).unwrap().symbol(), "│");
assert_eq!(buf.cell((5, 4)).unwrap().symbol(), "│");
assert_eq!(buf.cell((5, 5)).unwrap().symbol(), "▼");
}
#[test]
fn test_chat_edge_line_render_horizontal() {
let edge = ChatEdgeLine::new(ChatPosition::new(1, 5), ChatPosition::new(8, 5));
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
edge.render(buf.area, &mut buf);
assert_eq!(buf.cell((2, 5)).unwrap().symbol(), "─");
assert_eq!(buf.cell((3, 5)).unwrap().symbol(), "─");
assert_eq!(buf.cell((8, 5)).unwrap().symbol(), "▶");
}
#[test]
fn test_chat_edge_line_render_with_label() {
let edge =
ChatEdgeLine::new(ChatPosition::new(5, 1), ChatPosition::new(5, 10)).with_label("ctx");
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 15));
edge.render(buf.area, &mut buf);
let content = buffer_to_string(&buf);
assert!(
content.contains("ctx"),
"Label 'ctx' should appear in output"
);
}
#[test]
fn test_chat_edge_line_render_active() {
let edge =
ChatEdgeLine::new(ChatPosition::new(5, 1), ChatPosition::new(5, 5)).with_active(true);
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 10));
edge.render(buf.area, &mut buf);
let content = buffer_to_string(&buf);
assert!(content.contains("●"), "Flow dot should appear when active");
}
#[test]
fn test_chat_edge_line_exported() {
let _ = ChatEdgeLine::new(ChatPosition::new(0, 0), ChatPosition::new(0, 5));
}
#[test]
fn test_chat_position_default() {
let pos = ChatPosition::default();
assert_eq!(pos.x, 0);
assert_eq!(pos.y, 0);
}
fn buffer_to_string(buf: &Buffer) -> String {
buf.content.iter().map(|c| c.symbol()).collect()
}
}