use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CanvasStyle {
Plain,
Dim,
Taken,
ActiveEdge,
Back,
NodeText,
NodeDim,
NodeFocusText,
NodeHeader,
NodeBorderDim,
NodeBorderActive,
NodeBorderReplay,
NodeBorderOk,
NodeBorderFail,
NodeBorderTimedOut,
NodeBorderWarn,
NodeBorderCancelled,
Active,
Replay,
Ok,
Fail,
TimedOut,
Warn,
Cancelled,
Branch,
BranchFocus,
Agent,
AgentFocus,
Compute,
ComputeFocus,
Action,
ActionFocus,
Checkpoint,
CheckpointFocus,
}
impl CanvasStyle {
fn priority(self) -> u8 {
match self {
CanvasStyle::Plain => 0,
CanvasStyle::Dim => 1,
CanvasStyle::Back => 2,
CanvasStyle::Taken => 3,
CanvasStyle::ActiveEdge => 4,
CanvasStyle::NodeText
| CanvasStyle::NodeDim
| CanvasStyle::NodeFocusText
| CanvasStyle::NodeHeader => 5,
CanvasStyle::NodeBorderDim
| CanvasStyle::NodeBorderActive
| CanvasStyle::NodeBorderReplay
| CanvasStyle::NodeBorderOk
| CanvasStyle::NodeBorderFail
| CanvasStyle::NodeBorderTimedOut
| CanvasStyle::NodeBorderWarn
| CanvasStyle::NodeBorderCancelled
| CanvasStyle::Warn
| CanvasStyle::Cancelled => 6,
CanvasStyle::Ok => 7,
CanvasStyle::Fail | CanvasStyle::TimedOut => 8,
CanvasStyle::Branch
| CanvasStyle::BranchFocus
| CanvasStyle::Agent
| CanvasStyle::AgentFocus
| CanvasStyle::Compute
| CanvasStyle::ComputeFocus
| CanvasStyle::Action
| CanvasStyle::ActionFocus
| CanvasStyle::Checkpoint
| CanvasStyle::CheckpointFocus => 9,
CanvasStyle::Replay => 10,
CanvasStyle::Active => 11,
}
}
}
fn merge_styles(a: CanvasStyle, b: CanvasStyle) -> CanvasStyle {
if a.priority() >= b.priority() {
a
} else {
b
}
}
pub const UP: u8 = 1;
pub const DOWN: u8 = 2;
pub const LEFT: u8 = 4;
pub const RIGHT: u8 = 8;
pub fn char_to_mask(char: char) -> Option<u8> {
Some(match char {
'─' => LEFT | RIGHT,
'│' => UP | DOWN,
'┌' => DOWN | RIGHT,
'┐' => DOWN | LEFT,
'└' => UP | RIGHT,
'┘' => UP | LEFT,
'├' => UP | DOWN | RIGHT,
'┤' => UP | DOWN | LEFT,
'┬' => DOWN | LEFT | RIGHT,
'┴' => UP | LEFT | RIGHT,
'┼' => UP | DOWN | LEFT | RIGHT,
_ => return None,
})
}
fn mask_to_char(mask: u8) -> Option<char> {
Some(match mask {
m if m == (LEFT | RIGHT) => '─',
m if m == (UP | DOWN) => '│',
m if m == (DOWN | RIGHT) => '┌',
m if m == (DOWN | LEFT) => '┐',
m if m == (UP | RIGHT) => '└',
m if m == (UP | LEFT) => '┘',
m if m == (UP | DOWN | RIGHT) => '├',
m if m == (UP | DOWN | LEFT) => '┤',
m if m == (DOWN | LEFT | RIGHT) => '┬',
m if m == (UP | LEFT | RIGHT) => '┴',
m if m == (UP | DOWN | LEFT | RIGHT) => '┼',
_ => return None,
})
}
#[derive(Debug, Clone, Copy)]
struct CanvasChar {
char: char,
style: CanvasStyle,
}
pub type StyledRun = (String, CanvasStyle);
#[derive(Default)]
pub struct CharCanvas {
cells: HashMap<i64, HashMap<i64, CanvasChar>>,
max_x: i64,
max_y: i64,
}
impl CharCanvas {
pub fn new() -> Self {
Self::default()
}
fn row(&mut self, y: i64) -> &mut HashMap<i64, CanvasChar> {
self.max_y = self.max_y.max(y);
self.cells.entry(y).or_default()
}
pub fn put(&mut self, x: i64, y: i64, char: char, style: CanvasStyle) {
if x < 0 || y < 0 {
return;
}
self.max_y = self.max_y.max(y);
self.max_x = self.max_x.max(x);
let row = self.cells.entry(y).or_default();
if char == ' ' {
return;
}
if let Some(existing) = row.get(&x).copied() {
if existing.char == ' ' {
row.insert(x, CanvasChar { char, style });
return;
}
let existing_mask = char_to_mask(existing.char);
let incoming_mask = char_to_mask(char);
if let (Some(existing_mask), Some(incoming_mask)) = (existing_mask, incoming_mask) {
row.insert(
x,
CanvasChar {
char: mask_to_char(existing_mask | incoming_mask).unwrap_or(char),
style: merge_styles(existing.style, style),
},
);
return;
}
if existing_mask.is_none() && incoming_mask.is_some() {
return;
}
}
row.insert(x, CanvasChar { char, style });
}
pub fn text(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) {
for (index, char) in value.chars().enumerate() {
self.put(x + index as i64, y, char, style);
}
}
pub fn text_if_empty(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) -> bool {
if x < 0 || y < 0 {
return false;
}
let width = value.chars().count() as i64;
if let Some(row) = self.cells.get(&y) {
for index in 0..width {
if row.contains_key(&(x + index)) {
return false;
}
}
}
self.text(x, y, value, style);
true
}
pub fn text_over_run(&mut self, x: i64, y: i64, value: &str, style: CanvasStyle) -> bool {
if x < 1 || y < 0 {
return false;
}
let chars: Vec<char> = value.chars().collect();
let row = self.cells.get(&y);
for index in -1..=(chars.len() as i64) {
let is_dash = row
.and_then(|row| row.get(&(x + index)))
.is_some_and(|cell| cell.char == '─');
if !is_dash {
return false;
}
}
let row = self.row(y);
for (index, &char) in chars.iter().enumerate() {
row.insert(x + index as i64, CanvasChar { char, style });
}
self.max_x = self.max_x.max(x + chars.len() as i64 - 1);
true
}
pub fn fill_rect(&mut self, x: i64, y: i64, width: i64, height: i64, style: CanvasStyle) {
if x < 0 || y < 0 || width <= 0 || height <= 0 {
return;
}
self.max_x = self.max_x.max(x + width - 1);
self.max_y = self.max_y.max(y + height - 1);
for row_y in y..y + height {
let row = self.cells.entry(row_y).or_default();
for column_x in x..x + width {
row.insert(column_x, CanvasChar { char: ' ', style });
}
}
}
pub fn hline(&mut self, y: i64, x1: i64, x2: i64, style: CanvasStyle) {
let (start, end) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
for x in start..=end {
self.put(x, y, '─', style);
}
}
pub fn vline(&mut self, x: i64, y1: i64, y2: i64, style: CanvasStyle) {
let (start, end) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
for y in start..=end {
self.put(x, y, '│', style);
}
}
pub fn render_runs(&self) -> Vec<Vec<StyledRun>> {
let mut lines = Vec::new();
for y in 0..=self.max_y {
let Some(row) = self.cells.get(&y).filter(|row| !row.is_empty()) else {
lines.push(Vec::new());
continue;
};
let last_x = row.keys().copied().max().unwrap_or(-1);
let mut runs: Vec<StyledRun> = Vec::new();
let mut run_text = String::new();
let mut run_style = CanvasStyle::Plain;
for x in 0..=last_x.min(self.max_x) {
let (char, style) = match row.get(&x) {
Some(cell) => (cell.char, cell.style),
None => (' ', CanvasStyle::Plain),
};
if style != run_style {
if !run_text.is_empty() {
runs.push((std::mem::take(&mut run_text), run_style));
}
run_style = style;
}
run_text.push(char);
}
if !run_text.is_empty() {
runs.push((run_text, run_style));
}
lines.push(runs);
}
lines
}
pub fn render_plain(&self) -> Vec<String> {
self.render_runs()
.into_iter()
.map(|runs| {
let line: String = runs.into_iter().map(|(text, _)| text).collect();
line.trim_end().to_string()
})
.collect()
}
}