use crate::command::chat::render::theme::Theme;
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
pub const POINTER_SELECTED: &str = " ❯ ";
pub const POINTER_EMPTY: &str = " ";
pub const TOGGLE_ON: &str = "\u{25cf}";
pub const TOGGLE_OFF: &str = "\u{25cb}";
pub const SEPARATOR_V: &str = "\u{2502}";
pub const INDENT: &str = " ";
pub const LABEL_WIDTH: usize = 16;
pub fn separator_line(width: u16, theme: &Theme) -> Line<'static> {
let w = (width as usize).saturating_sub(4); let bar: String = "\u{2500}".repeat(w);
Line::from(Span::styled(
format!("{INDENT}{bar}"),
Style::default().fg(theme.separator),
))
}
pub fn section_header<'a>(icon: &str, title: &str, theme: &Theme) -> Line<'a> {
Line::from(Span::styled(
format!("{INDENT}{icon} {title}"),
Style::default()
.fg(theme.help_title)
.add_modifier(Modifier::BOLD),
))
}
pub fn pointer_span<'a>(selected: bool, theme: &Theme) -> Span<'a> {
if selected {
Span::styled(POINTER_SELECTED, Style::default().fg(theme.config_pointer))
} else {
Span::styled(POINTER_EMPTY, Style::default())
}
}
pub fn label_span<'a>(text: &str, width: usize, selected: bool, theme: &Theme) -> Span<'a> {
use unicode_width::UnicodeWidthStr;
let style = if selected {
Style::default()
.fg(theme.config_label_selected)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.config_label)
};
let display_w = UnicodeWidthStr::width(text);
let padding = if display_w < width {
" ".repeat(width - display_w)
} else {
String::new()
};
Span::styled(format!("{text}{padding}"), style)
}
fn value_style(selected: bool, editing: bool, theme: &Theme) -> Style {
if editing && selected {
Style::default()
.fg(theme.text_white)
.bg(theme.config_edit_bg)
} else if selected {
Style::default().fg(theme.text_white)
} else {
Style::default().fg(theme.config_value)
}
}
pub fn cursor_spans<'a>(value: &str, cursor: usize, style: Style, theme: &Theme) -> Vec<Span<'a>> {
let chars: Vec<char> = value.chars().collect();
let before: String = chars[..cursor.min(chars.len())].iter().collect();
let cursor_ch = if cursor < chars.len() {
chars[cursor].to_string()
} else {
" ".to_string()
};
let after: String = if cursor < chars.len() {
chars[cursor + 1..].iter().collect()
} else {
String::new()
};
vec![
Span::styled(before, style),
Span::styled(
cursor_ch,
Style::default().fg(theme.cursor_fg).bg(theme.cursor_bg),
),
Span::styled(after, style),
Span::styled(" \u{270f}\u{fe0f}", Style::default()),
]
}
fn render_preview_value(raw: &str) -> String {
if raw.is_empty() {
return "(\u{7a7a})".to_string();
}
let flat: String = raw
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.collect();
if flat.chars().count() > 40 {
let truncated: String = flat.chars().take(40).collect();
format!("{truncated}...")
} else {
flat
}
}
pub fn toggle_row<'a>(
label: &str,
is_on: bool,
selected: bool,
hint: &str,
theme: &Theme,
) -> Line<'a> {
let toggle_style = if is_on {
Style::default()
.fg(theme.config_toggle_on)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.config_toggle_off)
};
let toggle_text = if is_on {
format!("{TOGGLE_ON} \u{5f00}\u{542f}")
} else {
format!("{TOGGLE_OFF} \u{5173}\u{95ed}")
};
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(toggle_text, toggle_style),
Span::styled(
if selected {
format!(" ({hint})")
} else {
String::new()
},
Style::default().fg(theme.config_dim),
),
])
}
pub fn text_field_row<'a>(
label: &str,
value: &str,
selected: bool,
editing: bool,
cursor: usize,
theme: &Theme,
) -> Line<'a> {
let vs = value_style(selected, editing, theme);
if editing && selected {
let mut spans = vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
];
spans.extend(cursor_spans(value, cursor, vs, theme));
Line::from(spans)
} else {
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(
if value.is_empty() {
"(\u{7a7a})".to_string()
} else {
value.to_string()
},
vs,
),
])
}
}
pub fn secret_field_row<'a>(
label: &str,
value: &str,
selected: bool,
editing: bool,
cursor: usize,
theme: &Theme,
) -> Line<'a> {
if editing && selected {
let vs = value_style(selected, editing, theme);
let mut spans = vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
];
spans.extend(cursor_spans(value, cursor, vs, theme));
Line::from(spans)
} else {
let vs = if selected {
Style::default().fg(theme.text_white)
} else {
Style::default().fg(theme.config_api_key)
};
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(
if value.is_empty() {
"(\u{7a7a})".to_string()
} else {
value.to_string()
},
vs,
),
])
}
}
fn desc_span<'a>(text: &str, max_width: usize, theme: &Theme) -> Span<'a> {
use unicode_width::UnicodeWidthStr;
if text.is_empty() {
return Span::styled(String::new(), Style::default());
}
let display_w = UnicodeWidthStr::width(text);
if display_w <= max_width {
let padding = " ".repeat(max_width - display_w);
Span::styled(
format!(" {text}{padding}"),
Style::default().fg(theme.config_dim),
)
} else {
let mut w = 0;
let end = max_width.saturating_sub(3); let truncated: String = text
.chars()
.take_while(|c| {
let cw = UnicodeWidthStr::width(c.to_string().as_str());
if w + cw > end {
false
} else {
w += cw;
true
}
})
.collect();
let padding = " ".repeat(max_width - w - 3);
Span::styled(
format!(" {truncated}...{padding}"),
Style::default().fg(theme.config_dim),
)
}
}
pub fn global_text_row<'a>(
label: &str,
value: &str,
desc: &str,
selected: bool,
editing: bool,
cursor: usize,
theme: &Theme,
) -> Line<'a> {
let vs = value_style(selected, editing, theme);
if editing && selected {
let mut spans = vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
];
spans.extend(cursor_spans(value, cursor, vs, theme));
Line::from(spans)
} else {
let display_value = if value.is_empty() {
"(\u{7a7a})".to_string()
} else {
value.to_string()
};
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(display_value, vs),
desc_span(desc, 30, theme),
])
}
}
pub fn global_toggle_row<'a>(
label: &str,
is_on: bool,
desc: &str,
selected: bool,
hint: &str,
theme: &Theme,
) -> Line<'a> {
let toggle_style = if is_on {
Style::default()
.fg(theme.config_toggle_on)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.config_toggle_off)
};
let toggle_text = if is_on {
format!("{TOGGLE_ON} \u{5f00}\u{542f}")
} else {
format!("{TOGGLE_OFF} \u{5173}\u{95ed}")
};
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(toggle_text, toggle_style),
desc_span(desc, 30, theme),
Span::styled(
if selected {
format!(" ({hint})")
} else {
String::new()
},
Style::default().fg(theme.config_dim),
),
])
}
pub fn global_preview_row<'a>(
label: &str,
raw: &str,
desc: &str,
selected: bool,
hint: &str,
theme: &Theme,
) -> Line<'a> {
let vs = value_style(selected, false, theme);
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(render_preview_value(raw), vs),
desc_span(desc, 30, theme),
Span::styled(
if selected {
format!(" ({hint})")
} else {
String::new()
},
Style::default().fg(theme.config_dim),
),
])
}
pub fn global_theme_row<'a>(
label: &str,
name: &str,
desc: &str,
selected: bool,
hint: &str,
theme: &Theme,
) -> Line<'a> {
Line::from(vec![
pointer_span(selected, theme),
label_span(label, LABEL_WIDTH, selected, theme),
Span::styled(" ", Style::default()),
Span::styled(
format!("\u{1f3a8} {name}"),
Style::default()
.fg(theme.config_toggle_on)
.add_modifier(Modifier::BOLD),
),
desc_span(desc, 30, theme),
Span::styled(
if selected {
format!(" ({hint})")
} else {
String::new()
},
Style::default().fg(theme.config_dim),
),
])
}
pub fn toggle_list_item<'a>(
name: &str,
enabled: bool,
selected: bool,
desc: Option<&str>,
tag: Option<&str>,
theme: &Theme,
) -> Line<'a> {
let toggle_style = if enabled {
Style::default()
.fg(theme.config_toggle_on)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.config_toggle_off)
};
let toggle_text = if enabled { TOGGLE_ON } else { TOGGLE_OFF };
let name_style = if selected {
Style::default()
.fg(theme.config_label_selected)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.config_label)
};
let mut spans = vec![
pointer_span(selected, theme),
Span::styled(toggle_text, toggle_style),
Span::styled(" ", Style::default()),
Span::styled(name.to_string(), name_style),
];
if let Some(d) = desc {
spans.push(Span::styled(
format!(" {d}"),
Style::default().fg(theme.config_dim),
));
}
if let Some(t) = tag {
spans.push(Span::styled(
format!(" [{t}]"),
Style::default().fg(theme.config_dim),
));
}
Line::from(spans)
}
pub fn selectable_row<'a>(
primary: &str,
secondary: &str,
selected: bool,
theme: &Theme,
) -> Line<'a> {
let name_style = if selected {
Style::default()
.fg(theme.config_label_selected)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.config_label)
};
Line::from(vec![
pointer_span(selected, theme),
Span::styled(primary.to_string(), name_style),
Span::styled(
format!(" {secondary}"),
Style::default().fg(theme.config_dim),
),
])
}
pub fn tab_bar<'a>(tabs: &[(&str, bool)], hint: &str, theme: &Theme) -> Line<'a> {
let mut spans: Vec<Span<'a>> = vec![Span::styled(" ", Style::default())];
for (i, (label, active)) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(
format!(" {SEPARATOR_V} "),
Style::default().fg(theme.separator),
));
}
let text = format!(" {label} ");
if *active {
spans.push(Span::styled(
text,
Style::default()
.fg(theme.config_tab_active_fg)
.bg(theme.config_tab_active_bg)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
text,
Style::default().fg(theme.config_tab_inactive),
));
}
}
if !hint.is_empty() {
spans.push(Span::styled(
format!(" ({hint})"),
Style::default().fg(theme.config_dim),
));
}
Line::from(spans)
}
pub fn help_key_row<'a>(key: &str, desc: &str, key_width: usize, theme: &Theme) -> Line<'a> {
Line::from(vec![
Span::styled(INDENT, Style::default()),
Span::styled(
format!("{:<width$}", key, width = key_width),
Style::default()
.fg(theme.help_key)
.add_modifier(Modifier::BOLD),
),
Span::styled(desc.to_string(), Style::default().fg(theme.help_desc)),
])
}
pub fn hint_spans<'a>(key: &str, desc: &str, theme: &Theme) -> Vec<Span<'a>> {
vec![
Span::styled(
format!(" {key} "),
Style::default().fg(theme.hint_key_fg).bg(theme.hint_key_bg),
),
Span::styled(format!(" {desc}"), Style::default().fg(theme.hint_desc)),
]
}
pub fn welcome_box<'a>(width: u16, theme: &Theme, quote_idx: usize) -> Vec<Line<'a>> {
use unicode_width::UnicodeWidthStr;
let inner = ((width as usize) / 2).clamp(30, 55);
let box_w = inner + 2;
let total_w = width as usize;
let left_pad = if total_w > box_w {
(total_w - box_w) / 2
} else {
0
};
let pad: String = " ".repeat(left_pad);
let border_style = Style::default().fg(theme.welcome_border);
use super::palette;
let triple = palette::get_gradient(theme.welcome_palette, quote_idx);
let (start_c, mid_c, end_c) = triple;
let ornament = " ◈ ";
let orn_w = UnicodeWidthStr::width(ornament);
let bar_sides = inner.saturating_sub(orn_w);
let left_h = bar_sides / 2;
let right_h = bar_sides - left_h;
let h_bar_top = format!(
"\u{256d}{}{}{}\u{256e}",
"\u{2500}".repeat(left_h),
ornament,
"\u{2500}".repeat(right_h),
);
let h_bar_bot = format!("\u{2570}{}\u{256f}", "\u{2500}".repeat(inner));
let empty_row = format!("\u{2502}{}\u{2502}", " ".repeat(inner));
let text_area = inner.saturating_sub(2);
let quote = super::quotes::get_quote(quote_idx);
let quote_width = quote
.chars()
.map(|c| UnicodeWidthStr::width(c.to_string().as_str()))
.sum::<usize>();
let cn_break = [',', '。', '!', '?', ';', ':'];
let en_break = [',', '.', '!', '?'];
let lines_chars: Vec<Vec<char>> = if quote_width <= text_area {
vec![quote.chars().collect()]
} else {
let mut lines_chars: Vec<Vec<char>> = Vec::new();
let mut cur: Vec<char> = Vec::new();
let mut cur_w = 0usize;
for ch in quote.chars() {
let cw = UnicodeWidthStr::width(ch.to_string().as_str());
if cur_w + cw > text_area && !cur.is_empty() {
lines_chars.push(std::mem::take(&mut cur));
cur_w = 0;
}
cur.push(ch);
cur_w += cw;
if cn_break.contains(&ch) {
lines_chars.push(std::mem::take(&mut cur));
cur_w = 0;
} else if en_break.contains(&ch) && cur_w * 2 >= text_area {
lines_chars.push(std::mem::take(&mut cur));
cur_w = 0;
}
}
if !cur.is_empty() {
lines_chars.push(cur);
}
lines_chars
};
let total_chars: usize = lines_chars.iter().map(|l| l.len()).sum();
let total_n = total_chars.max(2);
let mut quote_lines: Vec<Line<'a>> = Vec::new();
let mut global_idx = 0usize;
for line_chars in &lines_chars {
let line_w: usize = line_chars
.iter()
.map(|c| UnicodeWidthStr::width(c.to_string().as_str()))
.sum();
let pl = if inner > line_w + 2 {
(inner - line_w) / 2
} else {
1
};
let pr = inner.saturating_sub(line_w + pl);
let mut spans: Vec<Span<'a>> = vec![Span::styled(
format!("{}\u{2502}{}", pad, " ".repeat(pl)),
border_style,
)];
for (i, &ch) in line_chars.iter().enumerate() {
let gi = global_idx + i;
let t = gi as f32 / (total_n - 1) as f32;
let (from, to, local_t) = if t <= 0.5 {
(start_c, mid_c, t * 2.0)
} else {
(mid_c, end_c, (t - 0.5) * 2.0)
};
let r = (from.0 as f32 * (1.0 - local_t) + to.0 as f32 * local_t).round() as u8;
let g = (from.1 as f32 * (1.0 - local_t) + to.1 as f32 * local_t).round() as u8;
let b = (from.2 as f32 * (1.0 - local_t) + to.2 as f32 * local_t).round() as u8;
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(ratatui::style::Color::Rgb(r, g, b)),
));
}
spans.push(Span::styled(
format!("{}\u{2502}", " ".repeat(pr)),
border_style,
));
quote_lines.push(Line::from(spans));
global_idx += line_chars.len();
if lines_chars.len() > 1 {
quote_lines.push(Line::from(Span::styled(
format!("{pad}{empty_row}"),
border_style,
)));
}
}
if lines_chars.len() > 1 && quote_lines.len() > 1 {
quote_lines.pop();
}
let pad_rows = if lines_chars.len() == 1 { 2 } else { 1 };
let make_empty =
|| -> Line<'a> { Line::from(Span::styled(format!("{pad}{empty_row}"), border_style)) };
let mut result: Vec<Line<'a>> = vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled(format!("{pad}{h_bar_top}"), border_style)),
];
for _ in 0..pad_rows {
result.push(make_empty());
}
result.extend(quote_lines);
for _ in 0..pad_rows {
result.push(make_empty());
}
result.push(Line::from(Span::styled(
format!("{pad}{h_bar_bot}"),
border_style,
)));
result
}