Skip to content

Commit 39329d5

Browse files
author
Dave Horner
committed
tui_example_browser - tui for exploring examples
1 parent c8ac92d commit 39329d5

File tree

3 files changed

+297
-1
lines changed

3 files changed

+297
-1
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ members = [
1717
"nannou_wgpu",
1818
"nature_of_code",
1919
"scripts/run_all_examples",
20-
"scripts/set_version",
20+
"scripts/set_version", "scripts/tui_example_browser",
2121
]
2222

2323
# Required for wgpu v0.10 feature resolution.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "tui_example_browser"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
crossterm = "0.28.1"
8+
ratatui = "0.29.0"
9+
toml = "0.8.20"
10+
11+
[[bin]]
12+
name = "tui_example_browser"
13+
path = "main.rs"
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
use std::{
2+
collections::HashSet,
3+
env, fs,
4+
io::{self, Write},
5+
path::Path,
6+
process::Command,
7+
thread,
8+
time::Duration,
9+
};
10+
11+
use crossterm::{
12+
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
13+
execute,
14+
terminal::{
15+
Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
16+
enable_raw_mode,
17+
},
18+
};
19+
use ratatui::{
20+
Terminal,
21+
backend::CrosstermBackend,
22+
layout::{Constraint, Direction, Layout},
23+
style::{Color, Style},
24+
widgets::{Block, Borders, List, ListItem, ListState},
25+
};
26+
27+
#[derive(Debug, Clone)]
28+
struct Example {
29+
package: String,
30+
name: String,
31+
}
32+
33+
impl Example {
34+
fn identifier(&self) -> String {
35+
format!("{}:{}", self.package, self.name)
36+
}
37+
38+
fn display_text(&self) -> String {
39+
format!("{} - {}", self.package, self.name)
40+
}
41+
}
42+
43+
fn main() -> Result<(), Box<dyn std::error::Error>> {
44+
// Default packages to look for examples if none are specified.
45+
const ALL_PACKAGES: &[&str] = &["examples", "generative_design", "nature_of_code"];
46+
47+
// Process command-line arguments to determine which packages to consider.
48+
// If none are provided, default to ALL_PACKAGES.
49+
let mut args = env::args();
50+
args.next(); // skip executable name
51+
let specified: Vec<String> = args.collect();
52+
let packages: Vec<String> = if specified.is_empty() {
53+
ALL_PACKAGES.iter().map(|s| s.to_string()).collect()
54+
} else {
55+
specified
56+
};
57+
58+
// Determine the workspace root by moving two levels up from this package's Cargo.toml.
59+
let manifest_dir = env!("CARGO_MANIFEST_DIR");
60+
let workspace_manifest_dir = Path::new(manifest_dir)
61+
.parent()
62+
.unwrap() // e.g. nannou/scripts
63+
.parent()
64+
.unwrap(); // e.g. nannou
65+
66+
// First, collect examples from each specified package.
67+
let mut examples: Vec<Example> = Vec::new();
68+
for package in packages.iter() {
69+
let package_dir = workspace_manifest_dir.join(package);
70+
// Expect the manifest at "Cargo.toml" in the package directory.
71+
let manifest_path = package_dir.join("Cargo").with_extension("toml");
72+
let manifest_contents = fs::read(&manifest_path)
73+
.unwrap_or_else(|_| panic!("Failed to read manifest at {:?}", manifest_path));
74+
let manifest_str = std::str::from_utf8(&manifest_contents)
75+
.unwrap_or_else(|_| panic!("Manifest is not valid UTF-8 at {:?}", manifest_path));
76+
let toml_value: toml::Value = toml::from_str(manifest_str)
77+
.unwrap_or_else(|_| panic!("Failed to parse manifest at {:?}", manifest_path));
78+
79+
// Retrieve the examples array from the manifest (assumed to be under the key "example").
80+
let ex_arr = toml_value
81+
.get("example")
82+
.and_then(|v| v.as_array())
83+
.unwrap_or_else(|| panic!("Failed to get 'example' array in {:?}", manifest_path));
84+
85+
for ex in ex_arr {
86+
let name = ex
87+
.get("name")
88+
.and_then(|v| v.as_str())
89+
.unwrap_or_else(|| panic!("Failed to get example name in {:?}", manifest_path));
90+
examples.push(Example {
91+
package: package.clone(),
92+
name: name.to_string(),
93+
});
94+
}
95+
}
96+
97+
// Next, collect binary targets from the workspace.
98+
// Running "cargo run --bin" with no argument produces an error message listing available binaries.
99+
let bin_output = Command::new("cargo")
100+
.args(&["run", "--bin"])
101+
.current_dir(workspace_manifest_dir)
102+
.output()?;
103+
let bin_stderr = String::from_utf8_lossy(&bin_output.stderr);
104+
if let Some(idx) = bin_stderr.find("Available binaries:") {
105+
// Take all lines after "Available binaries:".
106+
let bin_list_str = &bin_stderr[idx..];
107+
for line in bin_list_str.lines() {
108+
let trimmed = line.trim();
109+
// Skip the header line and any empty lines.
110+
if trimmed.is_empty() || trimmed == "Available binaries:" {
111+
continue;
112+
}
113+
// Each remaining line is a binary name.
114+
examples.push(Example {
115+
package: "bin".to_string(),
116+
name: trimmed.to_string(),
117+
});
118+
}
119+
}
120+
121+
// Sort the combined list by identifier.
122+
examples.sort_by(|a, b| a.identifier().cmp(&b.identifier()));
123+
if examples.is_empty() {
124+
println!("No examples or binaries found!");
125+
return Ok(());
126+
}
127+
128+
// Determine the run history file location in the workspace root.
129+
let history_path = workspace_manifest_dir.join("run_history.txt");
130+
let mut run_history: HashSet<String> = HashSet::new();
131+
if let Ok(contents) = fs::read_to_string(&history_path) {
132+
for line in contents.lines() {
133+
let trimmed = line.trim();
134+
if !trimmed.is_empty() {
135+
run_history.insert(trimmed.to_string());
136+
}
137+
}
138+
}
139+
140+
// Set up terminal in raw mode with an alternate screen.
141+
enable_raw_mode()?;
142+
let mut stdout = io::stdout();
143+
execute!(
144+
stdout,
145+
EnterAlternateScreen,
146+
EnableMouseCapture,
147+
Clear(ClearType::All)
148+
)?;
149+
let backend = CrosstermBackend::new(stdout);
150+
let mut terminal = Terminal::new(backend)?;
151+
152+
// Set up TUI list state.
153+
let mut list_state = ListState::default();
154+
list_state.select(Some(0));
155+
156+
'main_loop: loop {
157+
terminal.draw(|f| {
158+
let size = f.size();
159+
let chunks = Layout::default()
160+
.direction(Direction::Vertical)
161+
.margin(2)
162+
.constraints([Constraint::Percentage(100)].as_ref())
163+
.split(size);
164+
165+
// Build list items—if an example has been run, display it in blue.
166+
let items: Vec<ListItem> = examples
167+
.iter()
168+
.map(|ex| {
169+
let mut item = ListItem::new(ex.display_text());
170+
if run_history.contains(&ex.identifier()) {
171+
item = item.style(Style::default().fg(Color::Blue));
172+
}
173+
item
174+
})
175+
.collect();
176+
177+
let title = format!(
178+
"Select target ({} items found, Esc or q to exit)",
179+
examples.len()
180+
);
181+
let list = List::new(items)
182+
.block(Block::default().borders(Borders::ALL).title(title))
183+
.highlight_style(Style::default().fg(Color::Yellow))
184+
.highlight_symbol(">> ");
185+
f.render_stateful_widget(list, chunks[0], &mut list_state);
186+
})?;
187+
188+
if event::poll(Duration::from_millis(200))? {
189+
if let Event::Key(key) = event::read()? {
190+
match key.code {
191+
KeyCode::Esc | KeyCode::Char('q') => break 'main_loop,
192+
KeyCode::Down => {
193+
let i = match list_state.selected() {
194+
Some(i) if i >= examples.len() - 1 => i,
195+
Some(i) => i + 1,
196+
None => 0,
197+
};
198+
list_state.select(Some(i));
199+
}
200+
KeyCode::Up => {
201+
let i = match list_state.selected() {
202+
Some(0) | None => 0,
203+
Some(i) => i - 1,
204+
};
205+
list_state.select(Some(i));
206+
}
207+
KeyCode::Enter => {
208+
if let Some(idx) = list_state.selected() {
209+
let ex = &examples[idx];
210+
// Tear down the TUI before launching the target.
211+
disable_raw_mode()?;
212+
execute!(
213+
terminal.backend_mut(),
214+
LeaveAlternateScreen,
215+
DisableMouseCapture
216+
)?;
217+
terminal.show_cursor()?;
218+
219+
// Run the selected target.
220+
if ex.package == "bin" {
221+
println!("Running: cargo run --bin {}", ex.name);
222+
let mut child = Command::new("cargo")
223+
.args(&["run", "--bin", &ex.name])
224+
.spawn()
225+
.expect("Failed to spawn process");
226+
let status = child.wait().expect("Failed to wait for process");
227+
println!("Process exited with status: {}", status);
228+
} else {
229+
println!(
230+
"Running: cargo run -p {} --example {}",
231+
ex.package, ex.name
232+
);
233+
let mut child = Command::new("cargo")
234+
.args(&["run", "-p", &ex.package, "--example", &ex.name])
235+
.spawn()
236+
.expect("Failed to spawn process");
237+
let status = child.wait().expect("Failed to wait for process");
238+
println!("Process exited with status: {}", status);
239+
}
240+
241+
// Update run history.
242+
if run_history.insert(ex.identifier()) {
243+
let history_data: Vec<String> =
244+
run_history.iter().cloned().collect();
245+
fs::write(&history_path, history_data.join("\n"))?;
246+
}
247+
248+
// Flush stray events.
249+
while event::poll(Duration::from_millis(0))? {
250+
let _ = event::read();
251+
}
252+
thread::sleep(Duration::from_millis(50));
253+
254+
// Reinitialize the terminal.
255+
enable_raw_mode()?;
256+
let mut stdout = io::stdout();
257+
execute!(
258+
stdout,
259+
EnterAlternateScreen,
260+
EnableMouseCapture,
261+
Clear(ClearType::All)
262+
)?;
263+
terminal = Terminal::new(CrosstermBackend::new(stdout))?;
264+
}
265+
}
266+
_ => {}
267+
}
268+
}
269+
}
270+
}
271+
272+
// Restore terminal state on exit.
273+
disable_raw_mode()?;
274+
let mut stdout = io::stdout();
275+
execute!(
276+
stdout,
277+
LeaveAlternateScreen,
278+
DisableMouseCapture,
279+
Clear(ClearType::All)
280+
)?;
281+
terminal.show_cursor()?;
282+
Ok(())
283+
}

0 commit comments

Comments
 (0)