use std::collections::HashSet;
use std::io::Read;
use clap::Args;
use crossterm::{
event::{
read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
MouseButton, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use hefesto_widgets::{Tree, TreeNode, TreeState};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Rect},
style::Style,
widgets::{Block, Borders, StatefulWidget, Widget},
Terminal,
};
use crate::{keybinds, style};
const MENU_GUIDE: &str = include_str!("../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),
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
}
}
const POPUP_MIN_W: u16 = 40;
const POPUP_MIN_H: u16 = 8;
fn centered_rect(area: Rect, w: u16, h: u16) -> Rect {
let w = w.min(area.width.saturating_sub(2));
let h = h.min(area.height.saturating_sub(2));
let x = (area.width.saturating_sub(w)) / 2;
let y = (area.height.saturating_sub(h)) / 2;
Rect::new(x, y, w, h)
}
fn contains(rect: Rect, col: u16, row: u16) -> bool {
col >= rect.x
&& col < rect.x.saturating_add(rect.width)
&& row >= rect.y
&& row < rect.y.saturating_add(rect.height)
}
fn is_drag_area(popup: Rect, col: u16, row: u16) -> bool {
let inner = Rect {
x: popup.x + 1,
y: popup.y + 1,
width: popup.width.saturating_sub(2),
height: popup.height.saturating_sub(2),
};
col >= popup.x
&& col < popup.x.saturating_add(popup.width)
&& row >= popup.y
&& row < popup.y.saturating_add(popup.height)
&& !contains(inner, col, row)
}
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!("pandora menu: formato '{}' no soportado (json, plano)", 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!("pandora menu: error al leer '{}': {}", path, e);
std::process::exit(1);
}
}
} else {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() {
eprintln!("pandora menu: no se recibieron datos (provee un archivo como argumento o pipe a stdin)");
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!("pandora menu: error al parsear JSON: {}", e);
std::process::exit(1);
}
},
"plano" => parse_plano(&input),
_ => unreachable!(),
};
if let Some(dup) = find_duplicate_id(&input_nodes) {
eprintln!("pandora menu: id duplicado '{}' en el árbol", dup);
std::process::exit(1);
}
let nodes: Vec<TreeNode<'static>> = input_nodes.into_iter().map(Into::into).collect();
if nodes.is_empty() {
eprintln!("pandora menu: árbol vacío");
std::process::exit(1);
}
let mut tty: Box<dyn std::io::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!("pandora menu: el terminal no es interactivo");
std::process::exit(1);
}
let mut terminal = Terminal::new(CrosstermBackend::new(tty)).unwrap();
terminal.clear().unwrap();
terminal.hide_cursor().unwrap();
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 drag_offset: Option<(u16, u16)> = None;
let mut popup_origin: Option<(u16, u16)> = None;
while result.is_none() {
let size = terminal.size().unwrap();
let area = Rect::new(0, 0, size.width, size.height);
let pw = if args.width > 0 {
args.width
} else {
POPUP_MIN_W.max(area.width.saturating_div(3).min(60))
};
let ph = if args.height > 0 {
args.height
} else {
let estimated = total_visible(&nodes, &tree_state.expanded) as u16 + 4;
POPUP_MIN_H.max(estimated.min(area.height.saturating_sub(4)).min(25))
};
let (mut ox, mut oy) = popup_origin.unwrap_or_else(|| {
let c = centered_rect(area, pw, ph);
(c.x, c.y)
});
ox = ox.min(area.width.saturating_sub(pw));
oy = oy.min(area.height.saturating_sub(ph));
let popup_rect = Rect::new(ox, oy, pw.min(area.width), ph.min(area.height));
let inner = Rect {
x: popup_rect.x + 1,
y: popup_rect.y + 1,
width: popup_rect.width.saturating_sub(2),
height: popup_rect.height.saturating_sub(2),
};
terminal
.draw(|frame| {
let block = Block::default()
.title(title.as_str())
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_style(Style::default().fg(style::ACCENT));
block.render(popup_rect, frame.buffer_mut());
StatefulWidget::render(
Tree::new(nodes.clone()),
inner,
frame.buffer_mut(),
&mut tree_state,
);
})
.unwrap();
match read().unwrap() {
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 => {
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) => {
if is_drag_area(popup_rect, col, row) {
let ox = col.saturating_sub(popup_rect.x);
let oy = row.saturating_sub(popup_rect.y);
drag_offset = Some((ox, oy));
} else if 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) => {
if let Some((dx, dy)) = drag_offset {
let max_w = area.width.saturating_sub(pw);
let max_h = area.height.saturating_sub(ph);
let nx = (col as i16 - dx as i16).clamp(0, max_w as i16) as u16;
let ny = (row as i16 - dy as i16).clamp(0, max_h as i16) as u16;
popup_origin = Some((nx, ny));
}
}
MouseEventKind::Up(MouseButton::Left) => {
drag_offset = None;
}
_ => {}
}
}
_ => {}
}
}
let output = result.unwrap();
disable_raw_mode().unwrap();
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.unwrap();
terminal.show_cursor().unwrap();
if output.is_empty() {
std::process::exit(1);
}
println!("{}", output);
}