Skip to content

Commit 52639e5

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

File tree

3 files changed

+299
-1
lines changed

3 files changed

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

0 commit comments

Comments
 (0)