use crate::config_manager::NamedProfile;
use crate::render::panel::{
footer_hints, Padding, PanelBuilder, RenderedOverlay, RichCell, TextAlignment,
};
use crate::render::theme::PanelStyle;
const BROWSER_CONTENT_WIDTH: usize = 50;
const BROWSER_MAX_VISIBLE: usize = 9;
fn config_row_text(index: usize, c: &NamedProfile) -> String {
let palette = c
.overrides
.palette
.as_ref()
.map(|p| p.name())
.unwrap_or("?");
let pop = c.overrides.population.unwrap_or(0) / 1000;
format!("{} {} - {} - {}k agents", index + 1, c.name, palette, pop)
}
pub fn stamp_caret(
overlay: &mut RenderedOverlay,
label: &str,
field_width: usize,
cursor: usize,
style: &PanelStyle,
) {
let cursor = cursor.min(field_width.saturating_sub(1));
let mut rich: Vec<Vec<RichCell>> = overlay
.lines
.iter()
.map(|l| l.chars().map(|c| (c, None, None)).collect())
.collect();
if let Some((row_idx, row)) = overlay
.lines
.iter()
.enumerate()
.find(|(_, l)| l.contains(label))
{
let label_start = row.find(label).unwrap_or(0);
let caret_col = label_start + label.chars().count() + cursor;
if let Some(cell) = rich.get_mut(row_idx).and_then(|r| r.get_mut(caret_col)) {
cell.1 = Some(style.bg_color);
cell.2 = Some(style.accent_active);
}
}
overlay.rich_lines = Some(rich);
}
pub fn scroll_start(total: usize, selected: usize, max_visible: usize) -> usize {
if total <= max_visible || max_visible == 0 {
return 0;
}
let sel = selected.min(total - 1);
sel.saturating_sub(max_visible - 1).min(total - max_visible)
}
pub fn build_config_browser(
configs: &[NamedProfile],
selected: usize,
active_name: Option<&str>,
) -> RenderedOverlay {
use TextAlignment::Left;
let mut builder = PanelBuilder::new(BROWSER_CONTENT_WIDTH, None)
.with_padding(Padding::new(1, 1, 2, 2))
.with_title("SAVED CONFIGURATIONS")
.with_title_box();
if configs.is_empty() {
return builder
.add_empty()
.add_single("No saved configurations", Left)
.add_empty()
.add_single("Press Ctrl+S to save current settings", Left)
.add_empty()
.add_single(footer_hints(&[("esc", "cancel")]), Left)
.build_overlay();
}
let total = configs.len();
let selected = selected.min(total - 1);
let start = scroll_start(total, selected, BROWSER_MAX_VISIBLE);
let end = (start + BROWSER_MAX_VISIBLE).min(total);
if start > 0 {
builder = builder.add_single(format!("▲ {} above", start), Left);
} else {
builder = builder.add_empty();
}
for (i, c) in configs.iter().enumerate().skip(start).take(end - start) {
let cursor = if i == selected { "›" } else { " " };
let active = if active_name == Some(c.name.as_str()) {
"▶"
} else {
" "
};
builder = builder.add_single(format!("{cursor}{active}{}", config_row_text(i, c)), Left);
}
if end < total {
builder = builder.add_single(format!("▼ {} below", total - end), Left);
} else {
builder = builder.add_empty();
}
builder
.add_empty()
.add_single(
footer_hints(&[
("↑↓", "navigate"),
("↵", "load"),
("del", "delete"),
("esc", "cancel"),
]),
Left,
)
.build_overlay()
}
const SAVE_CONTENT_WIDTH: usize = 34;
const SAVE_FIELD_WIDTH: usize = 25;
pub fn build_config_save(value: &str, cursor: usize, style: &PanelStyle) -> RenderedOverlay {
use TextAlignment::Left;
const LABEL: &str = "Name: ";
let name_line = format!("{LABEL}{value:<SAVE_FIELD_WIDTH$}");
let mut overlay = PanelBuilder::new(SAVE_CONTENT_WIDTH, None)
.with_padding(Padding::new(0, 0, 1, 1))
.with_title("SAVE CONFIGURATION")
.with_title_box()
.add_empty()
.add_empty()
.add_single(&name_line, Left)
.add_empty()
.add_single(footer_hints(&[("↵", "save"), ("esc", "cancel")]), Left)
.build_overlay();
stamp_caret(&mut overlay, LABEL, SAVE_FIELD_WIDTH, cursor, style);
overlay
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_overrides::ProfileOverrides;
fn profile(name: &str) -> NamedProfile {
NamedProfile {
name: name.to_string(),
description: None,
overrides: ProfileOverrides::default(),
}
}
#[test]
fn chrome_dims_and_title_box() {
let configs = vec![profile("alpha"), profile("beta")];
let overlay = build_config_browser(&configs, 0, None);
let w = overlay.lines[0].chars().count();
assert_eq!(w, BROWSER_WIDTH_TOTAL);
assert!(overlay.lines.iter().all(|l| l.chars().count() == w));
let tb = overlay.title_box.expect("title box present");
assert!(tb.lines.iter().any(|l| l.contains("SAVED CONFIGURATIONS")));
}
#[test]
fn row_format_and_marker_match_app() {
let configs = vec![profile("alpha"), profile("beta")];
let overlay = build_config_browser(&configs, 0, None);
let text = overlay.lines.join("\n");
let alpha_line = text.lines().find(|l| l.contains("alpha")).unwrap_or("");
let alpha_content = alpha_line.chars().skip(3).collect::<String>();
assert!(
alpha_content.starts_with("› 1 "),
"selected alpha row must start with '› 1 ' (after border+padding):\n{alpha_line}\ncontent: {alpha_content}"
);
let beta_line = text.lines().find(|l| l.contains("beta")).unwrap_or("");
let beta_content = beta_line.chars().skip(3).collect::<String>();
assert!(
beta_content.starts_with(" 2 "),
"non-selected beta row must start with ' 2 ' (space+space+index, after border+padding):\n{beta_line}\ncontent: {beta_content}"
);
assert!(text.contains("↑↓ navigate"), "nav footer missing:\n{text}");
assert!(text.contains("esc cancel"), "esc footer missing:\n{text}");
}
#[test]
fn empty_state_renders_hint() {
let overlay = build_config_browser(&[], 0, None);
let text = overlay.lines.join("\n");
assert!(
text.contains("No saved configurations"),
"empty hint missing:\n{text}"
);
}
#[test]
fn selection_stays_visible_when_scrolled_past_window() {
let configs: Vec<_> = (0..30).map(|i| profile(&format!("cfg{i:02}"))).collect();
let overlay = build_config_browser(&configs, 29, None);
let text = overlay.lines.join("\n");
assert!(
text.contains("cfg29"),
"selected (last) entry scrolled off:\n{text}"
);
assert!(text.contains('›'), "highlight marker missing:\n{text}");
assert!(
text.contains("▲ "),
"above-scroll indicator missing:\n{text}"
);
assert!(
!text.contains("▼ "),
"should be at bottom, no below indicator:\n{text}"
);
}
#[test]
fn active_marker_shown_for_active_config() {
let configs = vec![profile("alpha"), profile("beta"), profile("gamma")];
let overlay = build_config_browser(&configs, 0, Some("beta"));
let text = overlay.lines.join("\n");
let alpha_line = text.lines().find(|l| l.contains("alpha")).unwrap_or("");
let alpha_content = alpha_line.chars().skip(3).collect::<String>();
assert!(
alpha_content.starts_with("› "),
"alpha (selected, not active) row prefix must be '› ' (after border+padding):\n{alpha_line}\ncontent: {alpha_content}"
);
assert!(
!alpha_content.contains("▶"),
"alpha content should not contain active marker:\n{alpha_content}"
);
let beta_line = text.lines().find(|l| l.contains("beta")).unwrap_or("");
let beta_content = beta_line.chars().skip(3).collect::<String>();
assert!(
beta_content.starts_with(" ▶"),
"beta (not selected, active) row prefix must be ' ▶' (after border+padding):\n{beta_line}\ncontent: {beta_content}"
);
let gamma_line = text.lines().find(|l| l.contains("gamma")).unwrap_or("");
let gamma_content = gamma_line.chars().skip(3).collect::<String>();
assert!(
gamma_content.starts_with(" "),
"gamma (not selected, not active) row prefix must be ' ' (after border+padding):\n{gamma_line}\ncontent: {gamma_content}"
);
assert!(
!gamma_content.contains("▶"),
"gamma should not have active marker:\n{gamma_content}"
);
}
#[test]
fn no_active_marker_when_active_name_is_none() {
let configs: Vec<_> = (0..5).map(|i| profile(&format!("cfg{i}"))).collect();
let overlay = build_config_browser(&configs, 2, None);
let text = overlay.lines.join("\n");
assert!(
!text.contains("▶"),
"should be no active marker when active_name=None:\n{text}"
);
}
#[test]
fn scroll_start_matches_overlay_window_helper() {
assert_eq!(scroll_start(5, 0, 9), 0);
assert_eq!(scroll_start(5, 4, 9), 0);
assert_eq!(scroll_start(15, 0, 9), 0);
assert_eq!(scroll_start(15, 8, 9), 0);
assert_eq!(scroll_start(15, 9, 9), 1);
assert_eq!(scroll_start(15, 11, 9), 3);
assert_eq!(scroll_start(15, 14, 9), 6);
assert_eq!(scroll_start(15, 99, 9), 6);
}
const BROWSER_WIDTH_TOTAL: usize = 56;
#[test]
fn save_dialog_caret_follows_cursor() {
let style = crate::render::theme::PanelStyle::default();
let value = "myconfig";
let overlay = build_config_save(value, 3, &style);
let text = overlay.lines.join("\n");
assert!(
text.contains("Name: myconfig"),
"field text missing:\n{text}"
);
let rich = overlay.rich_lines.expect("caret needs rich_lines");
let caret_cells: Vec<(usize, usize)> = rich
.iter()
.enumerate()
.flat_map(|(r, row)| {
row.iter()
.enumerate()
.filter(|(_, c)| c.2 == Some(style.accent_active))
.map(move |(col, _)| (r, col))
})
.collect();
assert_eq!(caret_cells.len(), 1, "expected exactly one caret cell");
let (row, col) = caret_cells[0];
let label_col = rich[row]
.iter()
.map(|c| c.0)
.collect::<String>()
.find("Name: ")
.unwrap();
assert_eq!(col, label_col + "Name: ".chars().count() + 3);
}
}