pub use crate::display_profile::{
display_char_width, display_width, graphemes, split_text_to_width_chunks, truncate_to_width,
DisplayProfile, DEFAULT_DISPLAY_PROFILE,
};
pub use crate::spacing::{
BOX_HEIGHT, BOX_MIN_WIDTH, BOX_PADDING, COL_SPACING, CYCLE_GUTTER, EDGE_DROP_HEIGHT,
EDGE_JUNCTION_HEIGHT, MAX_CANVAS_HEIGHT, MAX_CANVAS_WIDTH, MAX_LABEL_WIDTH, ROW_SPACING,
STEM_LENGTH_HORIZONTAL, STEM_LENGTH_VERTICAL, SUBGRAPH_GUTTER,
};
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum BaseStyle {
Ascii,
#[default]
Unicode,
Double,
Rounded,
Heavy,
Dots, Plus, Stars, Blocks, }
#[derive(Debug, Clone, Default)]
pub struct CompositeStyle {
pub corner: Option<BaseStyle>, pub border: Option<BaseStyle>, pub arrow: Option<BaseStyle>, pub edge: Option<BaseStyle>, pub junction: Option<BaseStyle>, pub back: Option<BaseStyle>, pub subgraph: Option<BaseStyle>, }
#[derive(Debug, Clone, Copy)]
pub struct StyleChars {
pub tl: char, pub tr: char, pub bl: char, pub br: char, pub h: char, pub v: char,
pub arrow_down: char,
pub arrow_up: char,
pub arrow_left: char,
pub arrow_right: char,
pub edge_h: char,
pub edge_v: char,
pub corner_dr: char, pub corner_dl: char, pub corner_ur: char, pub corner_ul: char, pub cross: char,
pub junction_down: char, pub junction_up: char, pub junction_right: char, pub junction_left: char,
pub back_h: char,
pub back_v: char,
pub dotted_h: char,
pub dotted_v: char,
pub circle_end: char,
pub cross_end: char,
pub portal_pierce: char,
}
impl BaseStyle {
pub fn chars(&self) -> &'static StyleChars {
match self {
BaseStyle::Ascii => &ASCII_CHARS,
BaseStyle::Unicode => &UNICODE_CHARS,
BaseStyle::Double => &DOUBLE_CHARS,
BaseStyle::Rounded => &ROUNDED_CHARS,
BaseStyle::Heavy => &HEAVY_CHARS,
BaseStyle::Dots => &DOTS_CHARS,
BaseStyle::Plus => &PLUS_CHARS,
BaseStyle::Stars => &STARS_CHARS,
BaseStyle::Blocks => &BLOCKS_CHARS,
}
}
pub fn parse_name(s: &str) -> Option<Self> {
s.parse().ok()
}
}
impl std::str::FromStr for BaseStyle {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"ascii" => Ok(BaseStyle::Ascii),
"unicode" => Ok(BaseStyle::Unicode),
"double" => Ok(BaseStyle::Double),
"rounded" => Ok(BaseStyle::Rounded),
"heavy" => Ok(BaseStyle::Heavy),
"dots" | "dot" => Ok(BaseStyle::Dots),
"plus" => Ok(BaseStyle::Plus),
"stars" | "star" => Ok(BaseStyle::Stars),
"blocks" | "block" => Ok(BaseStyle::Blocks),
_ => Err(()),
}
}
}
impl CompositeStyle {
pub fn from_base(style: BaseStyle) -> Self {
Self {
corner: Some(style),
border: Some(style),
arrow: Some(style),
edge: Some(style),
junction: Some(style),
back: Some(style),
subgraph: Some(style),
}
}
pub fn parse(s: &str) -> Self {
let mut style = CompositeStyle::default();
if !s.contains(':') {
if let Some(border_style) = BaseStyle::parse_name(s) {
style.corner = Some(border_style);
style.border = Some(border_style);
style.arrow = Some(border_style);
style.edge = Some(border_style);
style.junction = Some(border_style);
style.back = Some(border_style);
style.subgraph = Some(border_style);
} else if !s.is_empty() {
eprintln!(
"termiflow: warning: Unknown style '{}', using default (unicode)",
s
);
}
return style;
}
for part in s.split(',') {
let part = part.trim();
if let Some((component, style_name)) = part.split_once(':') {
let border_style = BaseStyle::parse_name(style_name.trim());
match component.trim() {
"box" => {
style.corner = border_style;
style.border = border_style;
}
"corner" => style.corner = border_style,
"border" => style.border = border_style,
"arrow" => style.arrow = border_style,
"edge" => style.edge = border_style,
"junction" => style.junction = border_style,
"back" => style.back = border_style,
"subgraph" => style.subgraph = border_style,
"line" => style.edge = border_style, "box_corner" => style.corner = border_style,
"box_line" | "box_border" => style.border = border_style,
"back_edge" => style.back = border_style,
unknown => {
eprintln!(
"termiflow: warning: Unknown style component '{}', ignoring",
unknown
);
}
}
if border_style.is_none() {
eprintln!(
"termiflow: warning: Unknown style name '{}' for component '{}', using default",
style_name.trim(),
component.trim()
);
}
}
}
style
}
pub fn to_style_chars(&self, fallback: BaseStyle) -> StyleChars {
let corner_chars = self.corner.unwrap_or(fallback).chars();
let border_chars = self.border.unwrap_or(fallback).chars();
let arrow_chars = self.arrow.unwrap_or(fallback).chars();
let edge_chars = self.edge.unwrap_or(fallback).chars();
let junction_chars = self.junction.unwrap_or(fallback).chars();
let back_chars = self.back.unwrap_or(fallback).chars();
StyleChars {
tl: corner_chars.tl,
tr: corner_chars.tr,
bl: corner_chars.bl,
br: corner_chars.br,
h: border_chars.h,
v: border_chars.v,
arrow_down: arrow_chars.arrow_down,
arrow_up: arrow_chars.arrow_up,
arrow_left: arrow_chars.arrow_left,
arrow_right: arrow_chars.arrow_right,
edge_h: edge_chars.edge_h,
edge_v: edge_chars.edge_v,
corner_dr: edge_chars.corner_dr,
corner_dl: edge_chars.corner_dl,
corner_ur: edge_chars.corner_ur,
corner_ul: edge_chars.corner_ul,
cross: edge_chars.cross,
junction_down: junction_chars.junction_down,
junction_up: junction_chars.junction_up,
junction_right: junction_chars.junction_right,
junction_left: junction_chars.junction_left,
back_h: back_chars.back_h,
back_v: back_chars.back_v,
dotted_h: back_chars.dotted_h,
dotted_v: back_chars.dotted_v,
circle_end: arrow_chars.circle_end,
cross_end: arrow_chars.cross_end,
portal_pierce: arrow_chars.circle_end,
}
}
pub fn to_subgraph_chars(&self) -> &'static StyleChars {
self.subgraph.unwrap_or(BaseStyle::Heavy).chars()
}
}
pub static ASCII_CHARS: StyleChars = StyleChars {
tl: '+',
tr: '+',
bl: '+',
br: '+',
h: '-',
v: '|',
arrow_down: 'v',
arrow_up: '^',
arrow_left: '<',
arrow_right: '>',
edge_h: '-',
edge_v: '|',
corner_dr: '+',
corner_dl: '+',
corner_ur: '+',
corner_ul: '+',
cross: '+',
junction_down: '+',
junction_up: '+',
junction_right: '+',
junction_left: '+',
back_h: '-',
back_v: ':',
dotted_h: '-',
dotted_v: ':',
circle_end: 'o',
cross_end: 'x',
portal_pierce: 'o',
};
pub static UNICODE_CHARS: StyleChars = StyleChars {
tl: '┌',
tr: '┐',
bl: '└',
br: '┘',
h: '─',
v: '│',
arrow_down: '↓',
arrow_up: '↑',
arrow_left: '←',
arrow_right: '→',
edge_h: '─',
edge_v: '│',
corner_dr: '┐',
corner_dl: '┌',
corner_ur: '┘',
corner_ul: '└',
cross: '┼',
junction_down: '┬',
junction_up: '┴',
junction_right: '├',
junction_left: '┤',
back_h: '─',
back_v: '│',
dotted_h: '╌',
dotted_v: '╎',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub static DOUBLE_CHARS: StyleChars = StyleChars {
tl: '╔',
tr: '╗',
bl: '╚',
br: '╝',
h: '═',
v: '║',
arrow_down: '▼',
arrow_up: '▲',
arrow_left: '◀',
arrow_right: '▶',
edge_h: '═',
edge_v: '║',
corner_dr: '╗',
corner_dl: '╔',
corner_ur: '╝',
corner_ul: '╚',
cross: '╬',
junction_down: '╦',
junction_up: '╩',
junction_right: '╠',
junction_left: '╣',
back_h: '═',
back_v: '║',
dotted_h: '╌',
dotted_v: '╎',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub static ROUNDED_CHARS: StyleChars = StyleChars {
tl: '╭',
tr: '╮',
bl: '╰',
br: '╯',
h: '─',
v: '│',
arrow_down: '↓',
arrow_up: '↑',
arrow_left: '←',
arrow_right: '→',
edge_h: '─',
edge_v: '│',
corner_dr: '╮',
corner_dl: '╭',
corner_ur: '╯',
corner_ul: '╰',
cross: '┼',
junction_down: '┬',
junction_up: '┴',
junction_right: '├',
junction_left: '┤',
back_h: '─',
back_v: '│',
dotted_h: '╌',
dotted_v: '╎',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub static HEAVY_CHARS: StyleChars = StyleChars {
tl: '┏',
tr: '┓',
bl: '┗',
br: '┛',
h: '━',
v: '┃',
arrow_down: '▼',
arrow_up: '▲',
arrow_left: '◀',
arrow_right: '▶',
edge_h: '━',
edge_v: '┃',
corner_dr: '┓',
corner_dl: '┏',
corner_ur: '┛',
corner_ul: '┗',
cross: '╋',
junction_down: '┳',
junction_up: '┻',
junction_right: '┣',
junction_left: '┫',
back_h: '━',
back_v: '┃',
dotted_h: '╌',
dotted_v: '╎',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub static DOTS_CHARS: StyleChars = StyleChars {
tl: '•',
tr: '•',
bl: '•',
br: '•',
h: '─',
v: '│',
arrow_down: '↓',
arrow_up: '↑',
arrow_left: '←',
arrow_right: '→',
edge_h: '─',
edge_v: '│',
corner_dr: '┐',
corner_dl: '┌',
corner_ur: '┘',
corner_ul: '└',
cross: '┼',
junction_down: '┬',
junction_up: '┴',
junction_right: '├',
junction_left: '┤',
back_h: '─',
back_v: '│',
dotted_h: '─',
dotted_v: ':',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub static PLUS_CHARS: StyleChars = StyleChars {
tl: '+',
tr: '+',
bl: '+',
br: '+',
h: '-',
v: '|',
arrow_down: 'v',
arrow_up: '^',
arrow_left: '<',
arrow_right: '>',
edge_h: '-',
edge_v: '|',
corner_dr: '+',
corner_dl: '+',
corner_ur: '+',
corner_ul: '+',
cross: '+',
junction_down: '+',
junction_up: '+',
junction_right: '+',
junction_left: '+',
back_h: '-',
back_v: ':',
dotted_h: '-',
dotted_v: ':',
circle_end: 'o',
cross_end: 'x',
portal_pierce: 'o',
};
pub static STARS_CHARS: StyleChars = StyleChars {
tl: '*',
tr: '*',
bl: '*',
br: '*',
h: '─',
v: '│',
arrow_down: '↓',
arrow_up: '↑',
arrow_left: '←',
arrow_right: '→',
edge_h: '─',
edge_v: '│',
corner_dr: '┐',
corner_dl: '┌',
corner_ur: '┘',
corner_ul: '└',
cross: '┼',
junction_down: '┬',
junction_up: '┴',
junction_right: '├',
junction_left: '┤',
back_h: '─',
back_v: '│',
dotted_h: '╌',
dotted_v: '╎',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub static BLOCKS_CHARS: StyleChars = StyleChars {
tl: '█',
tr: '█',
bl: '█',
br: '█',
h: '█',
v: '█',
arrow_down: '▼',
arrow_up: '▲',
arrow_left: '◀',
arrow_right: '▶',
edge_h: '─',
edge_v: '│',
corner_dr: '┐',
corner_dl: '┌',
corner_ur: '┘',
corner_ul: '└',
cross: '┼',
junction_down: '┬',
junction_up: '┴',
junction_right: '├',
junction_left: '┤',
back_h: '█',
back_v: '█',
dotted_h: '╌',
dotted_v: '╎',
circle_end: '○',
cross_end: '✕',
portal_pierce: '○',
};
pub fn truncate_label(label: &str, max_width: usize) -> String {
let current_width = display_width(label);
if current_width <= max_width {
return label.to_string();
}
let ellipsis = "...";
let ellipsis_width = display_width(ellipsis);
if max_width <= ellipsis_width {
return truncate_to_width(ellipsis, max_width);
}
let prefix = truncate_to_width(label, max_width.saturating_sub(ellipsis_width));
format!("{prefix}{ellipsis}")
}
pub fn box_width(label: &str) -> usize {
let label_width = display_width(label).min(MAX_LABEL_WIDTH);
(label_width + BOX_PADDING * 2 + 2).max(BOX_MIN_WIDTH)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_box_width_simple() {
assert_eq!(box_width("A"), 7); assert_eq!(box_width("Gateway"), 13); }
#[test]
fn test_truncate_label() {
assert_eq!(truncate_label("Short", 10), "Short");
assert_eq!(truncate_label("VeryLongLabel", 10), "VeryLon...");
}
#[test]
fn truncate_to_width_preserves_grapheme_clusters() {
let family = "👨👩👧👦";
assert_eq!(
truncate_to_width(&format!("{family}{family}"), display_width(family)),
family
);
}
#[test]
fn split_text_to_width_chunks_preserves_grapheme_clusters() {
let family = "👨👩👧👦";
assert_eq!(
split_text_to_width_chunks(&format!("{family}{family}"), display_width(family)),
vec![family.to_string(), family.to_string()]
);
}
#[test]
fn truncate_label_preserves_combining_clusters() {
let accented = "e\u{301}";
assert_eq!(
truncate_label(
&format!("{accented}{accented}{accented}{accented}{accented}"),
4
),
format!("{accented}...")
);
}
#[test]
fn test_display_width() {
assert_eq!(display_width("ABC"), 3);
assert_eq!(display_width("日本語"), 6); }
#[test]
fn test_composite_style_parse_simple() {
let style = CompositeStyle::parse("unicode");
assert_eq!(style.corner, Some(BaseStyle::Unicode));
assert_eq!(style.border, Some(BaseStyle::Unicode));
assert_eq!(style.arrow, Some(BaseStyle::Unicode));
assert_eq!(style.edge, Some(BaseStyle::Unicode));
assert_eq!(style.junction, Some(BaseStyle::Unicode));
assert_eq!(style.back, Some(BaseStyle::Unicode));
}
#[test]
fn test_composite_style_parse_complex() {
let style = CompositeStyle::parse("corner:rounded,border:heavy,arrow:unicode,edge:double");
assert_eq!(style.corner, Some(BaseStyle::Rounded));
assert_eq!(style.border, Some(BaseStyle::Heavy));
assert_eq!(style.arrow, Some(BaseStyle::Unicode));
assert_eq!(style.edge, Some(BaseStyle::Double));
assert_eq!(style.junction, None);
assert_eq!(style.back, None);
}
#[test]
fn test_composite_style_parse_all_components() {
let style = CompositeStyle::parse(
"corner:dots,border:heavy,arrow:unicode,edge:double,junction:heavy,back:rounded",
);
assert_eq!(style.corner, Some(BaseStyle::Dots));
assert_eq!(style.border, Some(BaseStyle::Heavy));
assert_eq!(style.arrow, Some(BaseStyle::Unicode));
assert_eq!(style.edge, Some(BaseStyle::Double));
assert_eq!(style.junction, Some(BaseStyle::Heavy));
assert_eq!(style.back, Some(BaseStyle::Rounded));
}
#[test]
fn test_composite_style_to_style_chars() {
let mut composite = CompositeStyle::default();
composite.corner = Some(BaseStyle::Dots);
composite.border = Some(BaseStyle::Heavy);
composite.arrow = Some(BaseStyle::Heavy);
let chars = composite.to_style_chars(BaseStyle::Unicode);
assert_eq!(chars.tl, '•');
assert_eq!(chars.tr, '•');
assert_eq!(chars.h, '━');
assert_eq!(chars.v, '┃');
assert_eq!(chars.arrow_down, '▼');
assert_eq!(chars.edge_h, '─');
}
#[test]
fn test_new_styles() {
assert_eq!(BaseStyle::parse_name("dots"), Some(BaseStyle::Dots));
assert_eq!(BaseStyle::parse_name("dot"), Some(BaseStyle::Dots));
assert_eq!(BaseStyle::parse_name("plus"), Some(BaseStyle::Plus));
assert_eq!(BaseStyle::parse_name("stars"), Some(BaseStyle::Stars));
assert_eq!(BaseStyle::parse_name("star"), Some(BaseStyle::Stars));
assert_eq!(BaseStyle::parse_name("blocks"), Some(BaseStyle::Blocks));
assert_eq!(BaseStyle::parse_name("block"), Some(BaseStyle::Blocks));
}
#[test]
fn test_legacy_compatibility() {
let style = CompositeStyle::parse("box:rounded");
assert_eq!(style.corner, Some(BaseStyle::Rounded));
assert_eq!(style.border, Some(BaseStyle::Rounded));
let style =
CompositeStyle::parse("box_corner:dots,box_line:heavy,line:double,back_edge:ascii");
assert_eq!(style.corner, Some(BaseStyle::Dots));
assert_eq!(style.border, Some(BaseStyle::Heavy));
assert_eq!(style.edge, Some(BaseStyle::Double));
assert_eq!(style.back, Some(BaseStyle::Ascii));
}
}