use crate::element::{BoxElement, Element, FlexDirection, TextElement};
use crate::style::{
fit_visible, split_nonempty_lines_preserving_trailing_blank, truncate_visible, visible_len,
wrap_words, Color, Style,
};
const MAX_SIDE_NOTE_PANEL_BODY_LINES: usize = u16::MAX as usize;
const MAX_SIDE_NOTE_PANEL_INDENT: usize = u16::MAX as usize;
#[derive(Debug, Clone)]
pub struct SideNotePanel {
title: String,
question: Option<String>,
answer: Option<String>,
loading_text: String,
footer: Option<String>,
max_body_lines: usize,
fill_height: bool,
indent: usize,
title_color: Color,
question_color: Color,
answer_color: Color,
muted_color: Color,
}
impl SideNotePanel {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
question: None,
answer: None,
loading_text: "thinking...".to_string(),
footer: None,
max_body_lines: 12,
fill_height: false,
indent: 2,
title_color: Color::Yellow,
question_color: Color::Yellow,
answer_color: Color::Yellow,
muted_color: Color::BrightBlack,
}
}
pub fn question(mut self, question: impl Into<String>) -> Self {
let question = question.into();
if !question.is_empty() {
self.question = Some(question);
}
self
}
pub fn answer(mut self, answer: impl Into<String>) -> Self {
let answer = answer.into();
if !answer.is_empty() {
self.answer = Some(answer);
}
self
}
pub fn loading_text(mut self, text: impl Into<String>) -> Self {
self.loading_text = text.into();
self
}
pub fn footer(mut self, footer: impl Into<String>) -> Self {
let footer = footer.into();
if !footer.is_empty() {
self.footer = Some(footer);
}
self
}
pub fn max_body_lines(mut self, max: usize) -> Self {
self.max_body_lines = max.clamp(1, MAX_SIDE_NOTE_PANEL_BODY_LINES);
self
}
pub fn fill_height(mut self, enabled: bool) -> Self {
self.fill_height = enabled;
self
}
pub fn indent(mut self, indent: usize) -> Self {
self.indent = indent.min(MAX_SIDE_NOTE_PANEL_INDENT);
self
}
pub fn title_color(mut self, color: Color) -> Self {
self.title_color = color;
self
}
pub fn question_color(mut self, color: Color) -> Self {
self.question_color = color;
self
}
pub fn answer_color(mut self, color: Color) -> Self {
self.answer_color = color;
self
}
pub fn muted_color(mut self, color: Color) -> Self {
self.muted_color = color;
self
}
pub fn title_value(&self) -> &str {
&self.title
}
pub fn question_value(&self) -> Option<&str> {
self.question.as_deref()
}
pub fn answer_value(&self) -> Option<&str> {
self.answer.as_deref()
}
pub fn view(&self, width: u16, height: usize) -> String {
let width = width as usize;
if width == 0 || height == 0 {
return String::new();
}
let mut lines = self.render_lines(width);
lines.truncate(height);
if self.fill_height {
while lines.len() < height {
lines.push(String::new());
}
}
lines
.into_iter()
.map(|line| fit_visible(&line, width))
.collect::<Vec<_>>()
.join("\n")
}
pub fn element<Msg>(&self, width: u16) -> Element<Msg> {
let width = width as usize;
let children = self.element_children(width);
Element::Box(
BoxElement::new()
.direction(FlexDirection::Column)
.children(children),
)
}
pub fn element_with_height<Msg>(&self, width: u16, height: usize) -> Element<Msg> {
let width = width as usize;
if width == 0 || height == 0 {
return Element::Box(BoxElement::new().direction(FlexDirection::Column));
}
let mut children = self.element_children(width);
children.truncate(height);
if self.fill_height {
while children.len() < height {
children.push(Element::Text(TextElement::new("")));
}
}
Element::Box(
BoxElement::new()
.direction(FlexDirection::Column)
.children(children),
)
}
fn render_lines(&self, width: usize) -> Vec<String> {
let mut lines = vec![Style::new()
.fg(self.title_color)
.bold()
.render(&fit_visible(
&self.indented_for_width(&self.title, width),
width,
))];
if let Some(question) = self.question.as_deref() {
for row in self.wrap_prefixed("Q: ", question, width) {
lines.push(
Style::new()
.fg(self.question_color)
.bold()
.render(&fit_visible(&row, width)),
);
}
}
let body = self.answer.as_deref().unwrap_or(&self.loading_text);
for row in self
.wrap_prefixed("", body, width)
.into_iter()
.take(self.max_body_lines)
{
lines.push(
Style::new()
.fg(self.answer_color)
.render(&fit_visible(&row, width)),
);
}
if let Some(footer) = self.footer.as_deref() {
lines.push(
Style::new()
.fg(self.muted_color)
.render(&fit_visible(&self.indented_for_width(footer, width), width)),
);
}
lines
}
fn element_children<Msg>(&self, width: usize) -> Vec<Element<Msg>> {
let mut children = Vec::new();
children.push(Element::Text(
TextElement::new(self.indented_for_width(&self.title, width))
.fg(self.title_color)
.bold(),
));
if let Some(question) = self.question.as_deref() {
for row in self.wrap_prefixed("Q: ", question, width) {
children.push(Element::Text(
TextElement::new(row).fg(self.question_color).bold(),
));
}
}
let body = self.answer.as_deref().unwrap_or(&self.loading_text);
for row in self
.wrap_prefixed("", body, width)
.into_iter()
.take(self.max_body_lines)
{
children.push(Element::Text(TextElement::new(row).fg(self.answer_color)));
}
if let Some(footer) = self.footer.as_deref() {
children.push(Element::Text(
TextElement::new(self.indented_for_width(footer, width)).fg(self.muted_color),
));
}
children
}
fn wrap_prefixed(&self, prefix: &str, text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let indent = " ".repeat(self.indent_for_prefix(prefix, width));
let first_prefix = format!("{indent}{prefix}");
let continuation_prefix =
" ".repeat(visible_len(&first_prefix).min(width.saturating_sub(1)));
let first_width = width.saturating_sub(visible_len(&first_prefix)).max(1);
let continuation_width = width
.saturating_sub(visible_len(&continuation_prefix))
.max(1);
let mut rows = Vec::new();
for line in split_nonempty_lines_preserving_trailing_blank(text) {
let wrapped = wrap_words(line, first_width);
if wrapped.is_empty() {
rows.push(first_prefix.clone());
continue;
}
for (index, part) in wrapped.into_iter().enumerate() {
if index == 0 {
rows.push(format!("{first_prefix}{part}"));
} else {
for continuation in wrap_words(&part, continuation_width) {
rows.push(format!("{continuation_prefix}{continuation}"));
}
}
}
}
if rows.is_empty() {
rows.push(first_prefix);
}
rows
}
fn indented_for_width(&self, value: &str, width: usize) -> String {
let indent = " ".repeat(self.indent_for_value(value, width));
truncate_visible(&format!("{indent}{value}"), width)
}
fn indent_for_prefix(&self, prefix: &str, width: usize) -> usize {
self.indent
.min(width.saturating_sub(visible_len(prefix).saturating_add(1)))
.min(MAX_SIDE_NOTE_PANEL_INDENT)
}
fn indent_for_value(&self, value: &str, width: usize) -> usize {
self.indent
.min(width.saturating_sub(visible_len(value).min(width)))
.min(MAX_SIDE_NOTE_PANEL_INDENT)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{strip_ansi, visible_len};
fn sample() -> SideNotePanel {
SideNotePanel::new("↘ by the way · Esc to close")
.question("Can we reuse this panel for background side questions?")
.answer("Yes. It renders a compact answer body and keeps rows width-safe.")
.footer("side-channel")
}
#[test]
fn renders_title_question_answer_and_footer() {
let rendered = sample().view(48, 8);
let plain = strip_ansi(&rendered);
assert!(plain.contains("by the way"));
assert!(plain.contains("Q: Can we reuse"));
assert!(plain.contains("compact answer"));
assert!(plain.contains("side-channel"));
for line in rendered.lines() {
assert_eq!(visible_len(line), 48, "{line:?}");
}
}
#[test]
fn loading_state_uses_fallback_answer() {
let rendered = SideNotePanel::new("↘ by the way")
.question("still working?")
.loading_text("thinking...")
.view(32, 4);
let plain = strip_ansi(&rendered);
assert!(plain.contains("still working"));
assert!(plain.contains("thinking"));
}
#[test]
fn caps_answer_lines() {
let rendered = SideNotePanel::new("note")
.answer("one\ntwo\nthree\nfour")
.max_body_lines(2)
.view(24, 5);
let plain = strip_ansi(&rendered);
assert!(plain.contains("one"));
assert!(plain.contains("two"));
assert!(!plain.contains("three"));
}
#[test]
fn answer_preserves_trailing_blank_row() {
let panel = SideNotePanel::new("note").answer("one\n");
let Element::Box(column) = panel.element::<()>(24) else {
panic!("expected column element");
};
let Element::Text(answer) = &column.children[1] else {
panic!("expected answer row");
};
let Element::Text(blank) = &column.children[2] else {
panic!("expected trailing blank answer row");
};
assert_eq!(answer.content, " one");
assert_eq!(blank.content, " ");
}
#[test]
fn cjk_text_wraps_by_display_width() {
let rendered = SideNotePanel::new("提示")
.question("中文问题 with a long suffix")
.answer("中文答案 with another long suffix")
.view(20, 8);
assert!(strip_ansi(&rendered).contains("中文"));
for line in rendered.lines() {
assert_eq!(visible_len(line), 20, "{line:?}");
}
}
#[test]
fn fill_height_pads_remaining_rows() {
let rendered = sample().fill_height(true).view(32, 10);
assert_eq!(rendered.lines().count(), 10);
for line in rendered.lines() {
assert_eq!(visible_len(line), 32, "{line:?}");
}
}
#[test]
fn zero_size_renders_empty_string() {
assert_eq!(sample().view(0, 4), "");
assert_eq!(sample().view(40, 0), "");
}
#[test]
fn oversized_indent_is_clamped_to_render_width() {
let panel = SideNotePanel::new("note")
.question("why")
.answer("because")
.footer("done")
.indent(usize::MAX);
let rendered = panel.view(8, 6);
let prefixed_rows = panel.wrap_prefixed("Q: ", "why", 8);
assert_eq!(panel.indent, MAX_SIDE_NOTE_PANEL_INDENT);
assert!(rendered.lines().all(|line| visible_len(line) == 8));
assert!(prefixed_rows.iter().all(|row| visible_len(row) <= 8));
let Element::Box(column) = panel.element::<()>(8) else {
panic!("expected column element");
};
let Element::Text(title) = &column.children[0] else {
panic!("expected title text");
};
assert!(visible_len(&title.content) <= 8);
}
#[test]
fn oversized_body_line_limit_is_clamped() {
let panel = SideNotePanel::new("note")
.answer("one\ntwo")
.max_body_lines(usize::MAX);
let rendered = panel.view(24, 3);
assert_eq!(panel.max_body_lines, MAX_SIDE_NOTE_PANEL_BODY_LINES);
assert!(rendered.lines().all(|line| visible_len(line) == 24));
}
#[test]
fn element_produces_column() {
let el: Element<()> = sample().element(48);
match el {
Element::Box(column) => {
assert_eq!(column.style.flex_direction, FlexDirection::Column);
assert!(!column.children.is_empty());
}
_ => panic!("expected Box"),
}
}
#[test]
fn element_with_height_zero_returns_empty_column() {
let el: Element<()> = sample().element_with_height(48, 0);
let Element::Box(column) = el else {
panic!("expected column");
};
assert!(column.children.is_empty());
}
#[test]
fn element_with_height_limits_rows() {
let el: Element<()> = SideNotePanel::new("note")
.question("why")
.answer("one\ntwo\nthree")
.footer("done")
.element_with_height(24, 3);
let Element::Box(column) = el else {
panic!("expected column");
};
let text = column
.children
.iter()
.filter_map(Element::text_content)
.collect::<Vec<_>>()
.join("\n");
assert_eq!(column.children.len(), 3);
assert!(text.contains("note"), "{text:?}");
assert!(text.contains("Q: why"), "{text:?}");
assert!(text.contains("one"), "{text:?}");
assert!(!text.contains("two"), "{text:?}");
assert!(!text.contains("done"), "{text:?}");
}
#[test]
fn element_with_height_fill_height_pads_empty_rows() {
let el: Element<()> = SideNotePanel::new("note")
.answer("ok")
.fill_height(true)
.element_with_height(20, 4);
let Element::Box(column) = el else {
panic!("expected column");
};
let rows = column
.children
.iter()
.filter_map(Element::text_content)
.collect::<Vec<_>>();
assert_eq!(rows.len(), 4);
assert_eq!(rows[0], " note");
assert_eq!(rows[1], " ok");
assert_eq!(rows[2], "");
assert_eq!(rows[3], "");
}
}