use super::width::ch_width;
use crossterm::style::Color as CtColor;
pub(super) const RESET: &str = "\x1b[0m";
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub(super) struct Style {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
pub code: bool,
pub color: Option<CtColor>,
pub role: Option<crate::tty::theme::Role>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Cell {
pub ch: char,
pub style: Style,
}
pub(super) fn open(style: Style) -> String {
open_with_theme(style, &crate::tty::theme::active())
}
fn open_with_theme(style: Style, theme: &crate::tty::theme::Theme) -> String {
use crate::tty::theme::Role;
use crossterm::style::{Attribute, ContentStyle};
let role = if style.code {
Some(Role::InlineCode)
} else {
style.role
};
let mut resolved = role.map_or_else(ContentStyle::default, |r| theme.style(r));
if style.bold
&& theme
.style(Role::MarkdownStrong)
.attributes
.has(Attribute::Bold)
{
resolved.attributes.set(Attribute::Bold);
}
if style.italic
&& theme
.style(Role::MarkdownItalic)
.attributes
.has(Attribute::Italic)
{
resolved.attributes.set(Attribute::Italic);
}
if style.underline
&& theme
.style(Role::MarkdownLink)
.attributes
.has(Attribute::Underlined)
{
resolved.attributes.set(Attribute::Underlined);
}
if style.strike
&& theme
.style(Role::MarkdownStrike)
.attributes
.has(Attribute::CrossedOut)
{
resolved.attributes.set(Attribute::CrossedOut);
}
if !style.code {
if let Some(c) = style.color {
resolved.foreground_color = Some(c);
}
}
crate::tty::theme::ansi_style(resolved)
}
pub(super) fn render_cells(cells: &[Cell]) -> String {
let mut out = String::new();
let mut i = 0;
while i < cells.len() {
let style = cells[i].style;
let mut text = String::new();
while i < cells.len() && cells[i].style == style {
text.push(cells[i].ch);
i += 1;
}
let o = open(style);
if o.is_empty() {
out.push_str(&text);
} else {
out.push_str(&o);
out.push_str(&text);
out.push_str(RESET);
}
}
out
}
fn words(cells: &[Cell]) -> Vec<Vec<Cell>> {
let mut words: Vec<Vec<Cell>> = Vec::new();
let mut cur: Vec<Cell> = Vec::new();
for &c in cells {
if c.ch == ' ' {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
} else {
cur.push(c);
}
}
if !cur.is_empty() {
words.push(cur);
}
words
}
pub(super) fn wrap_cells(cells: &[Cell], budget: usize) -> Vec<Vec<Cell>> {
let budget = budget.max(1);
let words = words(cells);
if words.is_empty() {
return vec![Vec::new()];
}
let mut space = Cell {
ch: ' ',
style: Style::default(),
};
let mut lines: Vec<Vec<Cell>> = Vec::new();
let mut cur: Vec<Cell> = Vec::new();
let mut cur_w = 0usize;
for w in words {
space.style = w.first().map(|c| c.style).unwrap_or_default();
let ww: usize = w.iter().map(|c| ch_width(c.ch)).sum();
if cur.is_empty() {
cur = w;
cur_w = ww;
} else if cur_w + 1 + ww <= budget {
cur.push(space);
cur.extend(w);
cur_w += 1 + ww;
} else {
lines.push(std::mem::take(&mut cur));
cur = w;
cur_w = ww;
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(Vec::new());
}
lines
}
#[cfg(test)]
mod theme_tests {
use super::*;
use crate::tty::theme::{Role, Theme};
use crossterm::style::Attribute;
#[test]
fn agent_text_keeps_its_separate_color_and_accepts_explicit_theme_styles() {
let plain = Style {
role: Some(Role::AgentText),
..Style::default()
};
assert_eq!(open_with_theme(plain, &Theme::builtin()), "\x1b[38;5;7m");
let (mut theme, warnings) = crate::tty::theme::from_env(Some("agent-text=#123456"));
assert!(warnings.is_empty());
let mut style = theme.style(Role::AgentText);
style.attributes.set(Attribute::Italic);
theme.set_style(Role::AgentText, style);
assert_eq!(open_with_theme(plain, &theme), "\x1b[3m\x1b[38;2;18;52;86m");
}
#[test]
fn heading_and_code_obey_explicit_bold_off_and_custom_color() {
let mut theme = Theme::builtin();
for role in [
Role::MarkdownHeading,
Role::InlineCode,
Role::MarkdownStrong,
Role::MarkdownLink,
Role::MarkdownStrike,
] {
let mut style = theme.style(role);
style.attributes.unset(Attribute::Bold);
style.attributes.set(Attribute::Italic);
style.foreground_color = Some(CtColor::Rgb {
r: 12,
g: 34,
b: 56,
});
theme.set_style(role, style);
let open = open_with_theme(
Style {
role: Some(role),
code: role == Role::InlineCode,
bold: role == Role::MarkdownStrong,
..Style::default()
},
&theme,
);
assert!(!open.contains("\x1b[1m"));
assert!(open.contains("\x1b[3m"));
assert!(open.contains("\x1b[38;2;12;34;56m"));
}
}
}