use std::collections::HashSet;
use std::io::{IsTerminal, Read, Write};
use clap::Args;
use crossterm::{
cursor::Show,
event::{
read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
MouseButton, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use hefesto_widgets::{TreeNode, TreeState, TreePopup, PopupSize};
use ratatui::{
backend::CrosstermBackend,
layout::Rect,
widgets::StatefulWidget,
Terminal,
};
use crate::{keybinds, style, popup_config::{PopupConfig, PopupConfigurable}};
const MENU_GUIDE: &str = include_str!("../guides/MENU_GUIDE.md");
#[derive(Args)]
pub struct MenuArgs {
#[arg()]
pub file: Option<String>,
#[arg(short = 'f', long, default_value = "auto")]
pub format: String,
#[arg(short, long, default_value = "Menú")]
pub title: String,
#[arg(short = 'W', long, default_value = "0")]
pub width: u16,
#[arg(short = 'H', long, default_value = "0")]
pub height: u16,
#[arg(long)]
pub guide: bool,
}
#[derive(serde::Deserialize)]
struct TreeNodeInput {
id: usize,
text: String,
#[serde(default)]
children: Vec<TreeNodeInput>,
}
impl From<TreeNodeInput> for TreeNode<'static> {
fn from(input: TreeNodeInput) -> Self {
TreeNode {
id: input.id,
text: ratatui::text::Line::from(input.text.trim().to_string()),
children: input.children.into_iter().map(Into::into).collect(),
}
}
}
fn find_duplicate_id(nodes: &[TreeNodeInput]) -> Option<usize> {
let mut seen = HashSet::new();
fn walk(nodes: &[TreeNodeInput], seen: &mut HashSet<usize>) -> Option<usize> {
for node in nodes {
if !seen.insert(node.id) {
return Some(node.id);
}
if let Some(dup) = walk(&node.children, seen) {
return Some(dup);
}
}
None
}
walk(nodes, &mut seen)
}
fn parse_plano(input: &str) -> Vec<TreeNodeInput> {
let mut roots: Vec<TreeNodeInput> = Vec::new();
let mut next_id: usize = 1;
for line in input.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('/').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
continue;
}
insert_path(&mut roots, &parts, &mut next_id);
}
roots
}
fn insert_path(nodes: &mut Vec<TreeNodeInput>, parts: &[&str], next_id: &mut usize) {
let name = parts[0];
let idx = if let Some(pos) = nodes.iter().position(|n| n.text == name) {
pos
} else {
let id = *next_id;
*next_id += 1;
nodes.push(TreeNodeInput {
id,
text: name.to_string(),
children: Vec::new(),
});
nodes.len() - 1
};
if parts.len() > 1 {
insert_path(&mut nodes[idx].children, &parts[1..], next_id);
}
}
fn detect_format(file: &Option<String>) -> &str {
match file {
Some(path) if path.ends_with(".json") => "json",
_ => "plano",
}
}
fn node_id_at_visible(
nodes: &[TreeNode],
expanded: &HashSet<usize>,
cursor: usize,
) -> Option<usize> {
fn walk(
nodes: &[TreeNode],
expanded: &HashSet<usize>,
cursor: usize,
count: &mut usize,
) -> Option<usize> {
for node in nodes {
if *count == cursor {
return Some(node.id);
}
*count += 1;
if !node.children.is_empty() && expanded.contains(&node.id) {
if let Some(found) = walk(&node.children, expanded, cursor, count) {
return Some(found);
}
}
}
None
}
walk(nodes, expanded, cursor, &mut 0)
}
fn total_visible(nodes: &[TreeNode], expanded: &HashSet<usize>) -> usize {
fn walk(nodes: &[TreeNode], expanded: &HashSet<usize>, count: &mut usize) {
for node in nodes {
*count += 1;
if !node.children.is_empty() && expanded.contains(&node.id) {
walk(&node.children, expanded, count);
}
}
}
let mut count = 0;
walk(nodes, expanded, &mut count);
count
}
fn has_children(nodes: &[TreeNode], id: usize) -> bool {
fn walk(nodes: &[TreeNode], id: usize) -> Option<bool> {
for node in nodes {
if node.id == id {
return Some(!node.children.is_empty());
}
if let Some(found) = walk(&node.children, id) {
return Some(found);
}
}
None
}
walk(nodes, id).unwrap_or(false)
}
fn node_path(nodes: &[TreeNode], id: usize) -> Option<String> {
let mut segments = Vec::new();
fn walk<'a>(nodes: &'a [TreeNode<'a>], id: usize, segments: &mut Vec<String>) -> bool {
for node in nodes {
segments.push(node.text.to_string());
if node.id == id {
return true;
}
if !node.children.is_empty() && walk(&node.children, id, segments) {
return true;
}
segments.pop();
}
false
}
if walk(nodes, id, &mut segments) {
Some(segments.join("/"))
} else {
None
}
}
struct TerminalGuard {
cleaned: bool,
}
impl TerminalGuard {
fn new() -> Self {
Self { cleaned: false }
}
fn cleanup(&mut self) {
if self.cleaned {
return;
}
self.cleaned = true;
let mut writer: Box<dyn Write> =
match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(f) => Box::new(f),
Err(_) => Box::new(std::io::stdout()),
};
let _ = disable_raw_mode();
let _ = execute!(writer, LeaveAlternateScreen, DisableMouseCapture, Show);
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
self.cleanup();
}
}
pub fn run(args: MenuArgs) {
if args.guide {
println!("{}", MENU_GUIDE);
return;
}
let format = if args.format == "auto" {
detect_format(&args.file)
} else {
&args.format
};
match format {
"json" | "plano" => {}
_ => {
eprintln!("{} menu: formato '{}' no soportado (json, plano)", crate::BIN_NAME, format);
std::process::exit(1);
}
}
let input = if let Some(path) = &args.file {
match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("{} menu: error al leer '{}': {}", crate::BIN_NAME, path, e);
std::process::exit(1);
}
}
} else {
if std::io::stdin().is_terminal() {
eprintln!("{} menu: se requiere un archivo como argumento o datos por pipe a stdin", crate::BIN_NAME);
std::process::exit(1);
}
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() {
eprintln!("{} menu: no se recibieron datos (provee un archivo como argumento o pipe a stdin)", crate::BIN_NAME);
std::process::exit(1);
}
buf
};
let input_nodes: Vec<TreeNodeInput> = match format {
"json" => match serde_json::from_str(&input) {
Ok(n) => n,
Err(e) => {
eprintln!("{} menu: error al parsear JSON: {}", crate::BIN_NAME, e);
std::process::exit(1);
}
},
"plano" => parse_plano(&input),
_ => unreachable!(),
};
if let Some(dup) = find_duplicate_id(&input_nodes) {
eprintln!("{} menu: id duplicado '{}' en el árbol", crate::BIN_NAME, dup);
std::process::exit(1);
}
let nodes: Vec<TreeNode<'static>> = input_nodes.into_iter().map(Into::into).collect();
if nodes.is_empty() {
eprintln!("{} menu: árbol vacío", crate::BIN_NAME);
std::process::exit(1);
}
crate::tty::ensure_terminal_stdin();
let mut tty: Box<dyn Write> =
match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(f) => Box::new(f),
Err(_) => Box::new(std::io::stdout()),
};
if enable_raw_mode().is_err()
|| execute!(tty, EnterAlternateScreen, EnableMouseCapture).is_err()
{
eprintln!("{} menu: el terminal no es interactivo", crate::BIN_NAME);
std::process::exit(1);
}
let mut terminal = match Terminal::new(CrosstermBackend::new(tty)) {
Ok(t) => t,
Err(e) => {
eprintln!("{} menu: error al inicializar terminal: {}", crate::BIN_NAME, e);
std::process::exit(1);
}
};
let mut guard = TerminalGuard::new();
if let Err(e) = crate::tty::with_terminal_stdout(|| terminal.clear()) {
eprintln!("{} menu: error al limpiar pantalla: {}", crate::BIN_NAME, e);
guard.cleanup();
std::process::exit(1);
}
if let Err(e) = crate::tty::with_terminal_stdout(|| terminal.hide_cursor()) {
eprintln!("{} menu: error al ocultar cursor: {}", crate::BIN_NAME, e);
guard.cleanup();
std::process::exit(1);
}
let mut tree_state = TreeState::default();
tree_state.scroll_state.follow = false;
tree_state.scroll_state.select(Some(0));
let title = args.title;
let mut result: Option<String> = None;
let mut pending_g = false;
let mut cfg = PopupConfig::new()
.border_type(style::BORDER)
.header();
if args.width > 0 {
cfg = cfg.width(args.width);
}
if args.height > 0 {
cfg = cfg.height(args.height);
}
let mut drag = crate::drag::DragState::new();
let mut origin: Option<(u16, u16)> = None;
let mut resize_w: Option<u16> = None;
let mut resize_h: Option<u16> = None;
while result.is_none() {
let size = match terminal.size() {
Ok(s) => s,
Err(e) => {
eprintln!("{} menu: error al leer tamaño del terminal: {}", crate::BIN_NAME, e);
break;
}
};
let area = Rect::new(0, 0, size.width, size.height);
let mut popup = TreePopup::new(nodes.clone())
.title(&title)
.border_color(style::ACCENT)
.with_config(&cfg);
if let Some((ox, oy)) = origin {
popup = popup.origin(ox, oy);
}
let h = match cfg.height {
Some(uh) => PopupSize::Fixed(uh),
None => PopupSize::Fixed(crate::popup_rect::tree_default_height(
total_visible(&nodes, &tree_state.expanded),
cfg.header,
)),
};
popup = popup.height(h);
if let Some(w) = resize_w { popup = popup.width(hefesto_widgets::PopupSize::Fixed(w)); }
if let Some(h) = resize_h { popup = popup.height(hefesto_widgets::PopupSize::Fixed(h)); }
let pr = popup.resolve_rect(area, &tree_state);
if resize_w.is_some() || resize_h.is_some() {
resize_w = Some(pr.width);
resize_h = Some(pr.height);
}
let inner = Rect {
x: pr.x + 1,
y: pr.y + 1,
width: pr.width.saturating_sub(2),
height: pr.height.saturating_sub(2),
};
if let Err(e) = terminal.draw(|frame| {
StatefulWidget::render(popup, frame.area(), frame.buffer_mut(), &mut tree_state);
}) {
eprintln!("{} menu: error al dibujar: {}", crate::BIN_NAME, e);
break;
}
let event = match read() {
Ok(e) => e,
Err(e) => {
eprintln!("{} menu: error al leer entrada: {}", crate::BIN_NAME, e);
break;
}
};
match event {
Event::Key(key) => {
if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
result = Some(String::new());
break;
}
let total = total_visible(&nodes, &tree_state.expanded);
match key.code {
keybinds::UP | keybinds::UP_ALT | keybinds::BACK_TAB => {
tree_state.scroll_state.previous();
}
keybinds::DOWN | keybinds::DOWN_ALT | keybinds::TAB => {
tree_state.scroll_state.next(total);
}
keybinds::CONFIRM => {
let cursor = tree_state.scroll_state.list_state.selected().unwrap_or(0);
if let Some(id) = node_id_at_visible(&nodes, &tree_state.expanded, cursor)
{
if has_children(&nodes, id) {
tree_state.toggle(id);
let new_total =
total_visible(&nodes, &tree_state.expanded);
if new_total > 0 {
let sel = tree_state
.scroll_state
.list_state
.selected()
.unwrap_or(0);
if sel >= new_total {
tree_state.scroll_state.select(Some(new_total - 1));
}
}
} else {
result = node_path(&nodes, id);
}
}
}
keybinds::TOGGLE_MULTI | KeyCode::Right => {
let cursor = tree_state.scroll_state.list_state.selected().unwrap_or(0);
if let Some(id) = node_id_at_visible(&nodes, &tree_state.expanded, cursor)
{
if has_children(&nodes, id)
&& !tree_state.expanded.contains(&id)
{
tree_state.expanded.insert(id);
}
}
}
KeyCode::Left => {
let cursor = tree_state.scroll_state.list_state.selected().unwrap_or(0);
if let Some(id) = node_id_at_visible(&nodes, &tree_state.expanded, cursor)
{
if tree_state.expanded.contains(&id) {
tree_state.expanded.remove(&id);
let new_total =
total_visible(&nodes, &tree_state.expanded);
if new_total > 0 {
let sel = tree_state
.scroll_state
.list_state
.selected()
.unwrap_or(0);
if sel >= new_total {
tree_state.scroll_state.select(Some(new_total - 1));
}
}
}
}
}
keybinds::CANCEL | keybinds::CANCEL_ALT => {
result = Some(String::new());
}
keybinds::FIRST => {
if pending_g {
tree_state.scroll_state.select(Some(0));
pending_g = false;
} else {
pending_g = true;
}
}
keybinds::LAST => {
let total = total_visible(&nodes, &tree_state.expanded);
if total > 0 {
tree_state.scroll_state.select(Some(total - 1));
}
}
_ => pending_g = false,
}
}
Event::Mouse(mouse) => {
let col = mouse.column;
let row = mouse.row;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
let zone = crate::drag::default_zone_at(pr, col, row);
drag.begin(pr, col, row, zone);
if !drag.is_dragging() && crate::drag::contains(inner, col, row) {
let item_idx = (row - inner.y) as usize;
let total = total_visible(&nodes, &tree_state.expanded);
if item_idx < total {
tree_state.scroll_state.select(Some(item_idx));
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
match drag.update(area, col, row) {
crate::drag::DragUpdate::Moved { x, y } => origin = Some((x, y)),
crate::drag::DragUpdate::Resized { x, y, w, h } => {
origin = Some((x, y));
resize_w = Some(w);
resize_h = Some(h);
}
crate::drag::DragUpdate::None => {}
}
}
MouseEventKind::Up(MouseButton::Left) => {
drag.end();
}
_ => {}
}
}
_ => {}
}
}
if result.is_none() {
eprintln!("{} menu: el terminal dejó de responder", crate::BIN_NAME);
guard.cleanup();
std::process::exit(1);
}
let output = result.unwrap();
guard.cleanup();
if output.is_empty() {
std::process::exit(1);
}
println!("{}", output);
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, text::Line, Terminal};
fn sample_nodes() -> Vec<TreeNode<'static>> {
vec![
TreeNode {
id: 1,
text: Line::from("src"),
children: vec![
TreeNode {
id: 2,
text: Line::from("main.rs"),
children: vec![],
},
TreeNode {
id: 3,
text: Line::from("lib.rs"),
children: vec![],
},
],
},
TreeNode {
id: 4,
text: Line::from("Cargo.toml"),
children: vec![],
},
]
}
fn sample_many() -> Vec<TreeNode<'static>> {
let mut items = sample_nodes();
for i in 5..=12 {
items.push(TreeNode {
id: i,
text: Line::from(format!("item_{i}")),
children: vec![],
});
}
items
}
fn render_popup(name: &str, popup: TreePopup<'_>, state: &mut TreeState) {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_stateful_widget(popup, f.area(), state))
.unwrap();
insta::with_settings!({
snapshot_path => "menu/snapshots",
prepend_module_to_snapshot => false,
}, {
assert_snapshot!(name, terminal.backend());
});
}
#[test]
fn parse_plano_simple() {
let nodes = parse_plano("Archivo\nEditar\nAyuda");
assert_eq!(nodes.len(), 3);
assert_eq!(nodes[0].text, "Archivo");
assert_eq!(nodes[1].text, "Editar");
assert_eq!(nodes[2].text, "Ayuda");
}
#[test]
fn parse_plano_nested() {
let nodes = parse_plano("Archivo/Nuevo/Documento\nArchivo/Abrir");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].text, "Archivo");
assert_eq!(nodes[0].children.len(), 2);
assert_eq!(nodes[0].children[0].text, "Nuevo");
assert_eq!(nodes[0].children[0].children[0].text, "Documento");
assert_eq!(nodes[0].children[1].text, "Abrir");
}
#[test]
fn parse_plano_trims_whitespace() {
let nodes = parse_plano(" Archivo \n\tEditar\t");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].text, "Archivo");
assert_eq!(nodes[1].text, "Editar");
}
#[test]
fn parse_plano_skips_blank_lines() {
let nodes = parse_plano("Archivo\n\n \nEditar");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].text, "Archivo");
assert_eq!(nodes[1].text, "Editar");
}
#[test]
fn parse_plano_ignores_empty_segments() {
let nodes = parse_plano("Archivo//Nuevo///Documento");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].children[0].text, "Nuevo");
assert_eq!(nodes[0].children[0].children[0].text, "Documento");
}
#[test]
fn parse_plano_auto_increments_ids() {
let nodes = parse_plano("A/B\nC/D/E");
assert_eq!(nodes[0].id, 1);
assert_eq!(nodes[0].children[0].id, 2);
assert_eq!(nodes[1].id, 3);
assert_eq!(nodes[1].children[0].id, 4);
assert_eq!(nodes[1].children[0].children[0].id, 5);
}
#[test]
fn parse_plano_merges_shared_prefixes() {
let nodes = parse_plano("Archivo/Nuevo\nArchivo/Abrir");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].text, "Archivo");
assert_eq!(nodes[0].children.len(), 2);
}
#[test]
fn find_duplicate_id_none_when_unique() {
let nodes = vec![
TreeNodeInput { id: 1, text: "a".into(), children: vec![] },
TreeNodeInput { id: 2, text: "b".into(), children: vec![] },
];
assert_eq!(find_duplicate_id(&nodes), None);
}
#[test]
fn find_duplicate_id_at_root() {
let nodes = vec![
TreeNodeInput { id: 1, text: "a".into(), children: vec![] },
TreeNodeInput { id: 1, text: "b".into(), children: vec![] },
];
assert_eq!(find_duplicate_id(&nodes), Some(1));
}
#[test]
fn find_duplicate_id_nested() {
let nodes = vec![
TreeNodeInput {
id: 1,
text: "a".into(),
children: vec![
TreeNodeInput { id: 2, text: "x".into(), children: vec![] },
],
},
TreeNodeInput { id: 2, text: "b".into(), children: vec![] },
];
assert_eq!(find_duplicate_id(&nodes), Some(2));
}
#[test]
fn json_text_trims_leading_trailing_whitespace() {
let json = r#"[
{"id": 1, "text": " Archivo ", "children": [
{"id": 2, "text": " Nuevo "}
]}
]"#;
let nodes: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
let tree: Vec<TreeNode<'static>> = nodes.into_iter().map(Into::into).collect();
assert_eq!(tree[0].text.to_string(), "Archivo");
assert_eq!(tree[0].children[0].text.to_string(), "Nuevo");
}
#[test]
fn json_text_keeps_internal_spaces() {
let json = r#"[{"id": 1, "text": "Foo Bar"}]"#;
let nodes: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
let tree: Vec<TreeNode<'static>> = nodes.into_iter().map(Into::into).collect();
assert_eq!(tree[0].text.to_string(), "Foo Bar");
}
#[test]
fn json_nested_text_also_trimmed() {
let json = r#"[
{"id": 1, "text": "\tRoot\n", "children": [
{"id": 2, "text": "\n Leaf\t"}
]}
]"#;
let nodes: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
let tree: Vec<TreeNode<'static>> = nodes.into_iter().map(Into::into).collect();
assert_eq!(tree[0].text.to_string(), "Root");
assert_eq!(tree[0].children[0].text.to_string(), "Leaf");
}
#[test]
fn total_visible_collapsed() {
let nodes = sample_nodes();
assert_eq!(total_visible(&nodes, &HashSet::new()), 2);
}
#[test]
fn total_visible_expanded() {
let nodes = sample_nodes();
let mut expanded = HashSet::new();
expanded.insert(1);
assert_eq!(total_visible(&nodes, &expanded), 4);
}
#[test]
fn node_id_at_visible_root() {
let nodes = sample_nodes();
assert_eq!(node_id_at_visible(&nodes, &HashSet::new(), 0), Some(1));
assert_eq!(node_id_at_visible(&nodes, &HashSet::new(), 1), Some(4));
}
#[test]
fn node_id_at_visible_nested() {
let nodes = sample_nodes();
let mut expanded = HashSet::new();
expanded.insert(1);
assert_eq!(node_id_at_visible(&nodes, &expanded, 1), Some(2));
assert_eq!(node_id_at_visible(&nodes, &expanded, 2), Some(3));
}
#[test]
fn node_id_at_visible_out_of_range() {
let nodes = sample_nodes();
assert_eq!(node_id_at_visible(&nodes, &HashSet::new(), 99), None);
}
#[test]
fn has_children_true_for_parent() {
let nodes = sample_nodes();
assert!(has_children(&nodes, 1));
}
#[test]
fn has_children_false_for_leaf() {
let nodes = sample_nodes();
assert!(!has_children(&nodes, 4));
}
#[test]
fn has_children_missing_returns_false() {
let nodes = sample_nodes();
assert!(!has_children(&nodes, 999));
}
#[test]
fn node_path_root() {
let nodes = sample_nodes();
assert_eq!(node_path(&nodes, 4), Some("Cargo.toml".to_string()));
}
#[test]
fn node_path_nested() {
let nodes = sample_nodes();
assert_eq!(node_path(&nodes, 2), Some("src/main.rs".to_string()));
assert_eq!(node_path(&nodes, 3), Some("src/lib.rs".to_string()));
}
#[test]
fn node_path_missing_returns_none() {
let nodes = sample_nodes();
assert_eq!(node_path(&nodes, 999), None);
}
#[test]
fn detect_format_json_extension() {
assert_eq!(detect_format(&Some("menu.json".to_string())), "json");
}
#[test]
fn detect_format_other_extension_is_plano() {
assert_eq!(detect_format(&Some("menu.txt".to_string())), "plano");
}
#[test]
fn detect_format_none_is_plano() {
assert_eq!(detect_format(&None), "plano");
}
#[test]
fn snapshot_tree_popup_default() {
let popup = TreePopup::new(sample_nodes()).header();
let mut state = TreeState::default();
render_popup("tree_popup_default", popup, &mut state);
}
#[test]
fn snapshot_tree_popup_with_title() {
let popup = TreePopup::new(sample_nodes()).title("Project").header();
let mut state = TreeState::default();
render_popup("tree_popup_with_title", popup, &mut state);
}
#[test]
fn snapshot_tree_popup_expanded() {
let popup = TreePopup::new(sample_nodes())
.title("Project")
.header()
.height(hefesto_widgets::PopupSize::Fixed(10));
let mut state = TreeState {
expanded: HashSet::from([1]),
..TreeState::default()
};
state.scroll_state.follow = false;
state.scroll_state.select(Some(0));
render_popup("tree_popup_expanded", popup, &mut state);
}
#[test]
fn snapshot_tree_popup_with_whitespace_text() {
let json = r#"[
{"id": 1, "text": " Archivo ", "children": [
{"id": 2, "text": " Nuevo "}
]}
]"#;
let inputs: Vec<TreeNodeInput> = serde_json::from_str(json).unwrap();
let nodes: Vec<TreeNode<'static>> = inputs.into_iter().map(Into::into).collect();
let popup = TreePopup::new(nodes).title("Menú").header();
let mut state = TreeState {
expanded: HashSet::from([1]),
..TreeState::default()
};
state.scroll_state.follow = false;
state.scroll_state.select(Some(0));
render_popup("tree_popup_with_whitespace_text", popup, &mut state);
}
#[test]
fn snapshot_tree_popup_auto_height_many_items() {
let popup = TreePopup::new(sample_many()).header();
let mut state = TreeState::default();
state.scroll_state.follow = false;
state.scroll_state.select(Some(0));
render_popup("tree_popup_auto_height_many_items", popup, &mut state);
}
const FIXTURE_MENU: &str = include_str!("menu/fixtures/ejemplo_menu.txt");
#[test]
fn parse_fixture_has_all_items() {
let nodes = parse_plano(FIXTURE_MENU);
assert_eq!(nodes.len(), 4);
assert_eq!(nodes[0].text, "Archivo");
assert_eq!(nodes[1].text, "Editar");
assert_eq!(nodes[2].text, "Ver");
assert_eq!(nodes[3].text, "Ayuda");
}
#[test]
fn parse_fixture_total_nodes() {
fn count(nodes: &[TreeNodeInput]) -> usize {
let mut c = nodes.len();
for n in nodes {
c += count(&n.children);
}
c
}
let nodes = parse_plano(FIXTURE_MENU);
assert_eq!(count(&nodes), 25);
}
#[test]
fn parse_fixture_archivo_children() {
let nodes = parse_plano(FIXTURE_MENU);
assert_eq!(nodes[0].children.len(), 5);
assert_eq!(nodes[0].children[0].text, "Nuevo");
assert_eq!(nodes[0].children[0].children[0].text, "Documento");
assert_eq!(nodes[0].children[0].children[1].text, "Hoja de cálculo");
}
#[test]
fn parse_fixture_no_duplicates() {
let nodes = parse_plano(FIXTURE_MENU);
assert_eq!(find_duplicate_id(&nodes), None);
}
#[test]
fn snapshot_fixture_menu_collapsed() {
let input = parse_plano(FIXTURE_MENU);
let nodes: Vec<TreeNode<'static>> = input.into_iter().map(Into::into).collect();
let popup = TreePopup::new(nodes).header();
let mut state = TreeState::default();
state.scroll_state.follow = false;
state.scroll_state.select(Some(0));
render_popup("fixture_menu_collapsed", popup, &mut state);
}
#[test]
fn snapshot_fixture_menu_archivo_expanded() {
let input = parse_plano(FIXTURE_MENU);
let nodes: Vec<TreeNode<'static>> = input.into_iter().map(Into::into).collect();
let popup = TreePopup::new(nodes).header();
let mut state = TreeState {
expanded: HashSet::from([1]), ..TreeState::default()
};
state.scroll_state.follow = false;
state.scroll_state.select(Some(0));
render_popup("fixture_menu_archivo_expanded", popup, &mut state);
}
}