use crate::console::{Console, ConsoleOptions, Renderable};
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::text::Text;
#[derive(Debug, Clone)]
pub struct Group {
items: Vec<Text>,
fit: bool,
}
impl Group {
pub fn new(items: Vec<Text>) -> Self {
Group { items, fit: false }
}
pub fn fit(items: Vec<Text>) -> Self {
Group { items, fit: true }
}
pub fn is_fit(&self) -> bool {
self.fit
}
pub fn items(&self) -> &[Text] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
if self.fit {
self.measure_renderables(options)
} else {
Measurement::new(options.max_width, options.max_width)
}
}
fn measure_renderables(&self, options: &ConsoleOptions) -> Measurement {
if self.items.is_empty() {
return Measurement::new(1, 1);
}
let mut min_width = 0usize;
let mut max_width = 0usize;
for item in &self.items {
let m = item.measure();
min_width = min_width.max(m.minimum);
max_width = max_width.max(m.maximum);
}
Measurement::new(
min_width.min(options.max_width),
max_width.min(options.max_width),
)
}
}
impl Renderable for Group {
fn rich_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let render_options = if self.fit {
let measurement = self.measure_renderables(options);
options.update_width(measurement.maximum.min(options.max_width))
} else {
options.clone()
};
let mut segments = Vec::new();
for item in &self.items {
segments.extend(item.rich_console(console, &render_options));
}
segments
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
fn make_console(width: usize) -> Console {
Console::builder()
.width(width)
.force_terminal(true)
.no_color(true)
.markup(false)
.build()
}
fn segments_text(segments: &[Segment]) -> String {
segments.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn test_new_creates_non_fit_group() {
let items = vec![
Text::new("Hello", Style::null()),
Text::new("World", Style::null()),
];
let group = Group::new(items);
assert!(!group.is_fit());
assert_eq!(group.len(), 2);
}
#[test]
fn test_fit_creates_fit_group() {
let items = vec![
Text::new("Hello", Style::null()),
Text::new("World", Style::null()),
];
let group = Group::fit(items);
assert!(group.is_fit());
assert_eq!(group.len(), 2);
}
#[test]
fn test_empty_group() {
let group = Group::new(vec![]);
assert!(group.is_empty());
assert_eq!(group.len(), 0);
}
#[test]
fn test_items_accessor() {
let items = vec![
Text::new("Alpha", Style::null()),
Text::new("Beta", Style::null()),
];
let group = Group::new(items);
assert_eq!(group.items().len(), 2);
assert_eq!(group.items()[0].plain(), "Alpha");
assert_eq!(group.items()[1].plain(), "Beta");
}
#[test]
fn test_measure_non_fit_fills_width() {
let console = make_console(80);
let opts = console.options();
let items = vec![
Text::new("Short", Style::null()),
Text::new("A bit longer text", Style::null()),
];
let group = Group::new(items);
let m = group.measure(&console, &opts);
assert_eq!(m.minimum, 80);
assert_eq!(m.maximum, 80);
}
#[test]
fn test_measure_fit_matches_content() {
let console = make_console(80);
let opts = console.options();
let items = vec![
Text::new("Hi", Style::null()), Text::new("Hello World", Style::null()), Text::new("Foo", Style::null()), ];
let group = Group::fit(items);
let m = group.measure(&console, &opts);
assert_eq!(m.maximum, 11);
assert_eq!(m.minimum, 5);
}
#[test]
fn test_measure_fit_empty() {
let console = make_console(80);
let opts = console.options();
let group = Group::fit(vec![]);
let m = group.measure(&console, &opts);
assert_eq!(m, Measurement::new(1, 1));
}
#[test]
fn test_measure_fit_clamped_to_max_width() {
let console = make_console(5);
let opts = console.options();
let items = vec![Text::new("A very long line of text", Style::null())];
let group = Group::fit(items);
let m = group.measure(&console, &opts);
assert!(m.maximum <= 5);
}
#[test]
fn test_render_contains_all_items() {
let console = make_console(80);
let opts = console.options();
let items = vec![
Text::new("First", Style::null()),
Text::new("Second", Style::null()),
Text::new("Third", Style::null()),
];
let group = Group::new(items);
let segments = group.rich_console(&console, &opts);
let text = segments_text(&segments);
assert!(text.contains("First"));
assert!(text.contains("Second"));
assert!(text.contains("Third"));
}
#[test]
fn test_render_preserves_order() {
let console = make_console(80);
let opts = console.options();
let items = vec![
Text::new("AAA", Style::null()),
Text::new("BBB", Style::null()),
Text::new("CCC", Style::null()),
];
let group = Group::new(items);
let segments = group.rich_console(&console, &opts);
let text = segments_text(&segments);
let pos_a = text.find("AAA").unwrap();
let pos_b = text.find("BBB").unwrap();
let pos_c = text.find("CCC").unwrap();
assert!(pos_a < pos_b);
assert!(pos_b < pos_c);
}
#[test]
fn test_render_empty_group() {
let console = make_console(80);
let opts = console.options();
let group = Group::new(vec![]);
let segments = group.rich_console(&console, &opts);
assert!(segments.is_empty());
}
#[test]
fn test_render_single_item() {
let console = make_console(80);
let opts = console.options();
let mut t = Text::new("Only one", Style::null());
t.end = String::new();
let group = Group::new(vec![t]);
let segments = group.rich_console(&console, &opts);
let text = segments_text(&segments);
assert!(text.contains("Only one"));
}
#[test]
fn test_fit_rendering_constrains_width() {
let console = make_console(80);
let opts = console.options();
let items = vec![
Text::new("Short", Style::null()),
Text::new("Medium text", Style::null()),
];
let group_fit = Group::fit(items.clone());
let group_no_fit = Group::new(items);
let seg_fit = group_fit.rich_console(&console, &opts);
let seg_no_fit = group_no_fit.rich_console(&console, &opts);
let text_fit = segments_text(&seg_fit);
let text_no_fit = segments_text(&seg_no_fit);
assert!(text_fit.contains("Short"));
assert!(text_fit.contains("Medium text"));
assert!(text_no_fit.contains("Short"));
assert!(text_no_fit.contains("Medium text"));
}
#[test]
fn test_console_render_integration() {
let console = make_console(80);
let items = vec![Text::new("via console render", Style::null())];
let group = Group::new(items);
let segments = console.render(&group, None);
let combined: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(combined.contains("via console render"));
}
#[test]
fn test_clone() {
let items = vec![Text::new("cloneable", Style::null())];
let group = Group::fit(items);
let cloned = group.clone();
assert_eq!(cloned.len(), group.len());
assert_eq!(cloned.is_fit(), group.is_fit());
assert_eq!(cloned.items()[0].plain(), "cloneable");
}
#[test]
fn test_debug() {
let items = vec![Text::new("debug", Style::null())];
let group = Group::new(items);
let debug_str = format!("{:?}", group);
assert!(debug_str.contains("Group"));
}
#[test]
fn test_styled_content_preserved() {
let console = make_console(80);
let opts = console.options();
let items = vec![
Text::styled("Bold item", Style::parse("bold").unwrap()),
Text::styled("Italic item", Style::parse("italic").unwrap()),
];
let group = Group::new(items);
let segments = group.rich_console(&console, &opts);
let has_bold = segments.iter().any(|s| {
s.text.contains("Bold item")
&& s.style.as_ref().map_or(false, |st| st.bold() == Some(true))
});
let has_italic = segments.iter().any(|s| {
s.text.contains("Italic item")
&& s.style
.as_ref()
.map_or(false, |st| st.italic() == Some(true))
});
assert!(has_bold, "Expected bold segment in output");
assert!(has_italic, "Expected italic segment in output");
}
}