use crate::{component::Component, theme::Color}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Token { Centered(Box), Limited(Box, usize), CharPad(Box, char, usize), PadPercent(Box, u8), Fg(Box, Color), Bg(Box, Color), Link(Box, String), String(Box, String), End, } impl From for Token { fn from(value: String) -> Self { Self::text(value) } } impl From<&str> for Token { fn from(value: &str) -> Self { Self::text(value) } } impl Token { pub fn text(t: T) -> Self where T: Into, { Self::End.string(t) } pub fn string(self, s: S) -> Self where S: Into, { Token::String(Box::new(self), s.into()) } pub fn centered(self) -> Self { Self::Centered(Box::new(self)) } pub fn limited(self, lim: usize) -> Self { Self::Limited(Box::new(self), lim) } pub fn padded(self, pad_to: usize) -> Self { Self::CharPad(Box::new(self), ' ', pad_to) } pub fn pad_percent(self, pct: u8) -> Self { #[cfg(debug_assertions)] if pct > 100 { panic!("pad_percent with >100% width: {}", pct); } Self::PadPercent(Box::new(self), 100.min(pct)) } // Returns (str_count, str_len_total) pub fn str_len(&self, total_width: usize) -> (usize, usize) { match self { Token::String(t, s) => { let (count, len) = t.str_len(total_width); (count + 1, len + s.len()) } Token::Fg(t, _) => t.str_len(total_width), Token::Bg(t, _) => t.str_len(total_width), Token::Link(t, _) => t.str_len(total_width), Token::Centered(t) => t.str_len(total_width), Token::Limited(t, lim) => { let (count, len) = t.str_len(total_width); (count, (*lim).min(len)) } Token::CharPad(t, _, pad_to) => { let (count, len) = t.str_len(total_width); (count, (*pad_to).max(len)) } Token::End => (0, 0), Token::PadPercent(t, pct) => { let (count, len) = t.str_len(total_width); ( count, ((*pct as usize * total_width) / 100).max(len), ) } } } // Checks if any children are Token::Centered // Used for links so they do not get set incorrectly fn has_centered(&self) -> bool { match self { Token::Centered(_) => true, Token::Limited(next, _) => next.has_centered(), Token::CharPad(next, _, _) => next.has_centered(), Token::Fg(next, _) => next.has_centered(), Token::Bg(next, _) => next.has_centered(), Token::Link(next, _) => next.has_centered(), Token::String(next, _) => next.has_centered(), Token::End => false, Token::PadPercent(next, _) => next.has_centered(), } } pub fn link(self, name: S) -> Self where S: Into, { #[cfg(debug_assertions)] { if self.has_centered() { panic!("FIXME: Token::link called after centered"); } } Self::Link(Box::new(self), name.into()) } pub fn pad_char(self, c: char, pad_to: usize) -> Self { Self::CharPad(Box::new(self), c, pad_to) } pub fn fg(self, c: Color) -> Self { Self::Fg(Box::new(self), c) } pub fn bg(self, c: Color) -> Self { Self::Bg(Box::new(self), c) } pub fn with_width(self, width: usize) -> Vec { match self { Token::String(t, s) => vec![Component::String(s)] .into_iter() .chain(t.with_width(width)) .collect(), Token::Link(t, name) => { vec![Component::Link(name, t.str_len(width).1)] .into_iter() .chain(t.with_width(width)) .collect() } Token::Centered(cnt) => { let mut str_len = cnt.str_len(width).1; let components = if str_len > width { str_len = width; cnt.limited(width).with_width(width) } else { cnt.with_width(width) }; vec![Component::X((width - str_len) / 2)] .into_iter() .chain(components) .collect() } Token::Limited(s, lim) => s .with_width(width) .into_iter() .map(|cmp| { if let Component::String(mut s) = cmp { s.truncate(lim); Component::String(s) } else { cmp } }) .collect(), Token::CharPad(s, c, pad_to) => s .with_width(width) .into_iter() .map(|comp| { if let Component::String(s) = comp { if s.len() >= pad_to { return Component::String(s); } Component::String(format!( "{}{}", s, c.to_string().repeat(pad_to - s.len()) )) } else { comp } }) .collect(), Token::Fg(t, c) => vec![Component::Fg(c)] .into_iter() .chain(t.with_width(width)) .collect(), Token::Bg(t, c) => vec![Component::Bg(c)] .into_iter() .chain(t.with_width(width)) .collect(), Token::End => vec![], Token::PadPercent(next, pct) => { // FIXME: I feel like this'll be a problem // at some point but I cba to deal with it rn let (str_cnt, str_len) = next.str_len(width); let actual_pad = (pct as usize * width) / 100; let mut str_idx = 0; next.with_width(width) .into_iter() .map(|comp| { if let Component::String(s) = comp { str_idx += 1; if str_idx != str_cnt { // Continue as this isn't the end Component::String(s) } else if str_len >= actual_pad { // FIXME: this is probably THE BAD Component::String({ let mut s = s; s.truncate( actual_pad - (str_len - s.len()), ); s }) } else { Component::String(format!( "{}{}", s, " ".repeat(actual_pad - str_len) )) } } else { comp } }) .collect() } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_token_gen() { const WIDTH: usize = 100; vec![ ( "string -> string", Token::text("hello"), vec![Component::String("hello".into())], ), ( "string -> limited", Token::text("This string is too long for this!") .limited(4), vec![Component::String("This".into())], ), ( "string -> limit -> pad_to", Token::text("This string is too long, but some") .limited(10) .padded(15), vec![Component::String("This strin ".into())], ), ( "center limited string", Token::text("Ahhh this won't go far") .limited(10) .centered(), vec![ Component::X((WIDTH - 10) / 2), Component::String("Ahhh this ".into()), ], ), ( "padded string with underscores is centered", Token::text("It was...").pad_char('_', 15).centered(), vec![ Component::X((WIDTH - 15) / 2), Component::String("It was...______".into()), ], ), ( "prefixes color", Token::text("this is red").fg(Color::RED), vec![ Component::Fg(Color::RED), Component::String("this is red".into()), ], ), ( "color at beginning of line", Token::text("colored").centered().bg(Color::RED), vec![ Component::Bg(Color::RED), Component::X((WIDTH - 7) / 2), Component::String("colored".into()), ], ), ( "color isnt part of limit", Token::text("ten chars!") .bg(Color::RED) .fg(Color::GREEN) .bg(Color::BLUE) .fg(Color::BLACK) .limited(10), vec![ Component::Fg(Color::BLACK), Component::Bg(Color::BLUE), Component::Fg(Color::GREEN), Component::Bg(Color::RED), Component::String("ten chars!".into()), ], ), ( "multicolor string centered", Token::text("end") .fg(Color::RED) .string("mid") .fg(Color::BLUE) .string("start") .fg(Color::GREEN) .centered(), vec![ Component::X((WIDTH - 11) / 2), Component::Fg(Color::GREEN), Component::String("start".into()), Component::Fg(Color::BLUE), Component::String("mid".into()), Component::Fg(Color::RED), Component::String("end".into()), ], ), ( "contains a link", Token::text("learn more") .link("string_link") .centered(), vec![ Component::X((WIDTH - 10) / 2), Component::Link("string_link".into(), 10), Component::String("learn more".into()), ], ), ( "padding percentage", Token::text("this gets longer").pad_percent(100), vec![Component::String( "this gets longer".to_string() + &" " .repeat(WIDTH - "this gets longer".len()), )], ), ( "pad percent with centering", Token::text("all to the middle") .pad_percent(60) .centered(), vec![ Component::X((WIDTH - (WIDTH * 60) / 100) / 2), Component::String( "all to the middle".to_string() + &" ".repeat( ((WIDTH * 60) / 100) - "all to the middle".len(), ), ), ], ), ( "two strings, padded pct", Token::text("two") .bg(Color::BLUE) .string("one") .pad_percent(80) .centered(), vec![ Component::X((WIDTH - ((WIDTH * 80) / 100)) / 2), Component::String("one".into()), Component::Bg(Color::BLUE), Component::String( "two".to_string() + &" ".repeat(((WIDTH * 80) / 100) - 6), ), ], ), ] .into_iter() .for_each(|(name, token, expected)| { eprintln!("running test <{}>", &name); let actual = token.with_width(WIDTH); assert!( expected.len() == actual.len(), "<{}>: length mismatch. expected {} actual {}", &name, expected.len(), actual.len(), ); for (i, exp) in expected.into_iter().enumerate() { let act = &actual[i]; assert!( exp == *act, "<{}>: component at index {} mismatch. expected:\n{} actual:\n{}", &name, i, exp.debug(), act.debug(), ); } }) } }