update terminal ui to show nested examples

This commit is contained in:
Timur Gordon 2025-08-07 15:36:55 +02:00
parent b31651cfeb
commit 0df79e78c6

View File

@ -82,6 +82,70 @@ pub struct ExampleScript {
pub path: PathBuf, pub path: PathBuf,
} }
#[derive(Debug, Clone)]
pub enum ExampleTreeNode {
File {
name: String,
path: PathBuf,
},
Folder {
name: String,
path: PathBuf,
children: Vec<ExampleTreeNode>,
expanded: bool,
},
}
impl ExampleTreeNode {
pub fn name(&self) -> &str {
match self {
ExampleTreeNode::File { name, .. } => name,
ExampleTreeNode::Folder { name, .. } => name,
}
}
pub fn path(&self) -> &PathBuf {
match self {
ExampleTreeNode::File { path, .. } => path,
ExampleTreeNode::Folder { path, .. } => path,
}
}
pub fn is_file(&self) -> bool {
matches!(self, ExampleTreeNode::File { .. })
}
pub fn is_folder(&self) -> bool {
matches!(self, ExampleTreeNode::Folder { .. })
}
pub fn is_expanded(&self) -> bool {
match self {
ExampleTreeNode::File { .. } => false,
ExampleTreeNode::Folder { expanded, .. } => *expanded,
}
}
pub fn toggle_expanded(&mut self) {
if let ExampleTreeNode::Folder { expanded, .. } = self {
*expanded = !*expanded;
}
}
pub fn set_expanded(&mut self, expand: bool) {
if let ExampleTreeNode::Folder { expanded, .. } = self {
*expanded = expand;
}
}
}
#[derive(Debug, Clone)]
pub struct ExampleTreeItem {
pub node: ExampleTreeNode,
pub depth: usize,
pub index: usize,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct JobInfo { pub struct JobInfo {
pub job: Job, pub job: Job,
@ -108,6 +172,11 @@ pub struct App {
pub status_message: Option<String>, pub status_message: Option<String>,
pub last_executed_job: Option<JobInfo>, pub last_executed_job: Option<JobInfo>,
pub last_refresh: Instant, pub last_refresh: Instant,
// New hierarchical examples system
pub example_tree: Vec<ExampleTreeNode>,
pub example_tree_items: Vec<ExampleTreeItem>,
pub example_tree_state: ListState,
pub selected_tree_item: Option<ExampleTreeItem>,
} }
pub struct ActorTui; pub struct ActorTui;
@ -138,42 +207,192 @@ impl App {
status_message: None, status_message: None,
last_executed_job: None, last_executed_job: None,
last_refresh: Instant::now(), last_refresh: Instant::now(),
// Initialize new hierarchical examples system
example_tree: Vec::new(),
example_tree_items: Vec::new(),
example_tree_state: ListState::default(),
selected_tree_item: None,
}; };
if let Some(dir) = example_dir { if let Some(dir) = example_dir {
if let Err(e) = app.load_examples(dir) { if let Err(e) = app.load_examples_tree(dir) {
error!("Failed to load examples: {}", e); error!("Failed to load examples tree: {}", e);
} }
} }
Ok(app) Ok(app)
} }
pub fn load_examples(&mut self, dir: PathBuf) -> Result<()> { pub fn load_examples_tree(&mut self, dir: PathBuf) -> Result<()> {
if !dir.exists() { if !dir.exists() {
log::warn!("Examples directory does not exist: {:?}", dir);
return Ok(()); return Ok(());
} }
log::info!("Loading examples tree from: {:?}", dir);
// Load hierarchical tree structure
match self.load_example_tree(&dir) {
Ok(tree) => {
self.example_tree = tree;
log::info!("Successfully loaded {} top-level tree nodes", self.example_tree.len());
}
Err(e) => {
log::error!("Failed to load example tree: {}", e);
// Create a simple fallback structure
self.example_tree = vec![
ExampleTreeNode::File {
name: "Error loading examples".to_string(),
path: dir.join("error.rhai"),
}
];
}
}
self.rebuild_tree_items();
log::info!("Rebuilt tree items: {} total items", self.example_tree_items.len());
if !self.example_tree_items.is_empty() {
self.example_tree_state.select(Some(0));
self.selected_tree_item = Some(self.example_tree_items[0].clone());
// Update selected_example for backward compatibility
if let Some(first_file) = self.find_first_file_in_tree() {
self.selected_example = Some(ExampleScript {
name: first_file.name().to_string(),
path: first_file.path().clone(),
});
}
}
Ok(())
}
fn load_example_tree(&self, dir: &PathBuf) -> Result<Vec<ExampleTreeNode>> {
let mut nodes = Vec::new();
log::debug!("Loading directory: {:?}", dir);
let mut entries: Vec<_> = fs::read_dir(dir)?
.collect::<Result<Vec<_>, _>>()?;
log::debug!("Found {} entries in {:?}", entries.len(), dir);
// Sort entries: directories first, then files, both alphabetically
entries.sort_by(|a, b| {
let a_is_dir = a.path().is_dir();
let b_is_dir = b.path().is_dir();
match (a_is_dir, b_is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.file_name().cmp(&b.file_name()),
}
});
for entry in entries {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
log::debug!("Loading folder: {}", name);
let children = self.load_example_tree(&path)?;
log::debug!("Folder '{}' has {} children", name, children.len());
nodes.push(ExampleTreeNode::Folder {
name,
path,
children,
expanded: true, // Expand folders by default to show hierarchy
});
} else if path.extension().map_or(false, |ext| ext == "rhai") {
log::debug!("Loading file: {}", name);
nodes.push(ExampleTreeNode::File {
name: path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&name)
.to_string(),
path,
});
}
}
Ok(nodes)
}
fn load_flat_examples(&mut self, dir: &PathBuf) -> Result<()> {
let mut examples = Vec::new();
self.collect_all_rhai_files(&mut examples, dir)?;
self.examples = examples;
if !self.examples.is_empty() {
self.example_list_state.select(Some(0));
if self.selected_example.is_none() {
self.selected_example = Some(self.examples[0].clone());
}
}
Ok(())
}
fn collect_all_rhai_files(&self, examples: &mut Vec<ExampleScript>, dir: &PathBuf) -> Result<()> {
for entry in fs::read_dir(dir)? { for entry in fs::read_dir(dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "rhai") { if path.is_dir() {
self.collect_all_rhai_files(examples, &path)?;
} else if path.extension().map_or(false, |ext| ext == "rhai") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
self.examples.push(ExampleScript { examples.push(ExampleScript {
name: name.to_string(), name: name.to_string(),
path, path,
}); });
} }
} }
} }
Ok(())
if !self.examples.is_empty() {
self.example_list_state.select(Some(0));
self.selected_example = Some(self.examples[0].clone());
} }
Ok(()) fn rebuild_tree_items(&mut self) {
self.example_tree_items.clear();
log::info!("Rebuilding tree items from {} root nodes", self.example_tree.len());
let mut index = 0;
let tree_clone = self.example_tree.clone();
for node in &tree_clone {
log::info!("Processing root node: {:?}", node.name());
self.add_tree_items_recursive(node, 0, &mut index);
}
log::info!("Final tree items count: {}", self.example_tree_items.len());
}
fn add_tree_items_recursive(&mut self, node: &ExampleTreeNode, depth: usize, index: &mut usize) {
// Always add the current node to the flattened list
log::debug!("Adding tree item: {} at depth {}", node.name(), depth);
self.example_tree_items.push(ExampleTreeItem {
node: node.clone(),
depth,
index: *index,
});
*index += 1;
// For folders, add children only if the folder is expanded
if let ExampleTreeNode::Folder { children, expanded, .. } = node {
log::debug!("Folder '{}' has {} children, expanded: {}", node.name(), children.len(), expanded);
if *expanded {
for child in children {
self.add_tree_items_recursive(child, depth + 1, index);
}
}
}
}
fn find_first_file_in_tree(&self) -> Option<&ExampleTreeNode> {
for item in &self.example_tree_items {
if item.node.is_file() {
return Some(&item.node);
}
}
None
} }
pub async fn refresh_jobs(&mut self) -> Result<()> { pub async fn refresh_jobs(&mut self) -> Result<()> {
@ -403,13 +622,13 @@ impl App {
} }
pub fn next_example(&mut self) { pub fn next_example(&mut self) {
if self.examples.is_empty() { if self.example_tree_items.is_empty() {
return; return;
} }
let selected = match self.example_list_state.selected() { let i = match self.example_tree_state.selected() {
Some(i) => { Some(i) => {
if i >= self.examples.len() - 1 { if i >= self.example_tree_items.len() - 1 {
0 0
} else { } else {
i + 1 i + 1
@ -417,27 +636,109 @@ impl App {
} }
None => 0, None => 0,
}; };
self.example_list_state.select(Some(selected)); self.example_tree_state.select(Some(i));
self.selected_example = Some(self.examples[selected].clone()); self.selected_tree_item = Some(self.example_tree_items[i].clone());
// Update selected_example for backward compatibility
if self.example_tree_items[i].node.is_file() {
self.selected_example = Some(ExampleScript {
name: self.example_tree_items[i].node.name().to_string(),
path: self.example_tree_items[i].node.path().clone(),
});
}
} }
pub fn previous_example(&mut self) { pub fn previous_example(&mut self) {
if self.examples.is_empty() { if self.example_tree_items.is_empty() {
return; return;
} }
let selected = match self.example_list_state.selected() { let i = match self.example_tree_state.selected() {
Some(i) => { Some(i) => {
if i == 0 { if i == 0 {
self.examples.len() - 1 self.example_tree_items.len() - 1
} else { } else {
i - 1 i - 1
} }
} }
None => 0, None => 0,
}; };
self.example_list_state.select(Some(selected)); self.example_tree_state.select(Some(i));
self.selected_example = Some(self.examples[selected].clone()); self.selected_tree_item = Some(self.example_tree_items[i].clone());
// Update selected_example for backward compatibility
if self.example_tree_items[i].node.is_file() {
self.selected_example = Some(ExampleScript {
name: self.example_tree_items[i].node.name().to_string(),
path: self.example_tree_items[i].node.path().clone(),
});
}
}
pub fn expand_selected_folder(&mut self) {
if let Some(selected_index) = self.example_tree_state.selected() {
if selected_index < self.example_tree_items.len() {
let item = &self.example_tree_items[selected_index];
if item.node.is_folder() {
let path = item.node.path().clone();
self.expand_folder_by_path(&path);
}
}
}
}
pub fn collapse_selected_folder(&mut self) {
if let Some(selected_index) = self.example_tree_state.selected() {
if selected_index < self.example_tree_items.len() {
let item = &self.example_tree_items[selected_index];
if item.node.is_folder() {
let path = item.node.path().clone();
self.collapse_folder_by_path(&path);
}
}
}
}
pub fn toggle_selected_folder(&mut self) {
if let Some(selected_index) = self.example_tree_state.selected() {
if selected_index < self.example_tree_items.len() {
let item = &self.example_tree_items[selected_index];
if item.node.is_folder() {
let path = item.node.path().clone();
let is_expanded = item.node.is_expanded();
if is_expanded {
self.collapse_folder_by_path(&path);
} else {
self.expand_folder_by_path(&path);
}
}
}
}
}
fn expand_folder_by_path(&mut self, target_path: &PathBuf) {
Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, true);
self.rebuild_tree_items();
}
fn collapse_folder_by_path(&mut self, target_path: &PathBuf) {
Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, false);
self.rebuild_tree_items();
}
fn set_folder_expanded_by_path_static(nodes: &mut Vec<ExampleTreeNode>, target_path: &PathBuf, expanded: bool) {
for node in nodes {
match node {
ExampleTreeNode::Folder { path, children, expanded: node_expanded, .. } => {
if path == target_path {
*node_expanded = expanded;
return;
}
Self::set_folder_expanded_by_path_static(children, target_path, expanded);
}
_ => {}
}
}
} }
@ -868,20 +1169,33 @@ fn render_examples(f: &mut Frame, app: &mut App, area: Rect) {
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area); .split(area);
// Example list // Hierarchical example tree list
let examples: Vec<ListItem> = app.examples let tree_items: Vec<ListItem> = app.example_tree_items
.iter() .iter()
.map(|example| { .map(|item| {
ListItem::new(Line::from(vec![Span::raw(example.name.clone())])) let indent = " ".repeat(item.depth);
let (icon, name) = match &item.node {
ExampleTreeNode::File { name, .. } => ("📄", name.as_str()),
ExampleTreeNode::Folder { name, expanded, .. } => {
if *expanded {
("📂", name.as_str())
} else {
("📁", name.as_str())
}
}
};
let display_text = format!("{}{} {}", indent, icon, name);
ListItem::new(Line::from(vec![Span::raw(display_text)]))
}) })
.collect(); .collect();
let examples_list = List::new(examples) let examples_list = List::new(tree_items)
.block(Block::default().borders(Borders::ALL).title("Example Scripts")) .block(Block::default().borders(Borders::ALL).title("Example Scripts (→ expand, ← collapse)"))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.highlight_symbol("> "); .highlight_symbol("> ");
f.render_stateful_widget(examples_list, chunks[0], &mut app.example_list_state); f.render_stateful_widget(examples_list, chunks[0], &mut app.example_tree_state);
// Example content // Example content
if let Some(ref example) = app.selected_example { if let Some(ref example) = app.selected_example {
@ -992,14 +1306,41 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result
_ => {} _ => {}
} }
} }
KeyCode::Left => {
if app.current_tab == TabState::Examples {
app.collapse_selected_folder();
}
}
KeyCode::Right => {
if app.current_tab == TabState::Examples {
app.expand_selected_folder();
}
}
KeyCode::Enter => { KeyCode::Enter => {
if app.current_tab == TabState::Examples { if app.current_tab == TabState::Examples {
if let Some(example) = app.selected_example.clone() { // Handle hierarchical tree navigation
if let Some(selected_index) = app.example_tree_state.selected() {
if selected_index < app.example_tree_items.len() {
let item = app.example_tree_items[selected_index].clone();
match &item.node {
ExampleTreeNode::Folder { .. } => {
// Toggle folder expand/collapse
app.toggle_selected_folder();
}
ExampleTreeNode::File { path, name, .. } => {
// Load file into New Job tab
let example = ExampleScript {
name: name.clone(),
path: path.clone(),
};
if let Err(e) = app.load_example_to_new_job(&example) { if let Err(e) = app.load_example_to_new_job(&example) {
app.status_message = Some(format!("Error loading example: {}", e)); app.status_message = Some(format!("Error loading example: {}", e));
} else { } else {
app.current_tab = TabState::NewJob; app.current_tab = TabState::NewJob;
app.status_message = Some(format!("Loaded example '{}' into New Job tab", example.name)); app.status_message = Some(format!("Loaded example '{}' into New Job tab", name));
}
}
}
} }
} }
} else if app.current_tab == TabState::NewJob { } else if app.current_tab == TabState::NewJob {