262 lines
12 KiB
Rust
262 lines
12 KiB
Rust
//! Renders the `NotesCraft` UI offscreen (no window) and saves a PNG for visual checks.
|
|
//!
|
|
//! ```sh
|
|
//! cargo run -p notescraft-ui-egui --example snapshot -- --vault /tmp/nc-vault --out ui.png \
|
|
//! --size 1440x900 --scale 2 --open "Projects/NotesCraft spec.md" [--vim] [--help-card]
|
|
//! [--export-dialog]
|
|
//! ```
|
|
//! Panel previews accept `--sidebar-width`, `--right-width`, repeated
|
|
//! `--collapse sidebar|right`, `--drop-hover`, and `--no-animations`.
|
|
|
|
use notescraft_engine::Session;
|
|
use notescraft_ui_egui::app::NotesApp;
|
|
|
|
fn arg(args: &[String], name: &str) -> Option<String> {
|
|
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
|
}
|
|
|
|
// A flat list of preview flags: splitting it would only scatter the argument parsing.
|
|
#[allow(clippy::too_many_lines)]
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let out = arg(&args, "--out").unwrap_or_else(|| "snapshot.png".into());
|
|
let vault = arg(&args, "--vault").unwrap_or_else(|| "/tmp/nc-vault".into());
|
|
let (w, h) = arg(&args, "--size")
|
|
.and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse::<f32>().ok()?, b.parse::<f32>().ok()?))))
|
|
.unwrap_or((1440.0, 900.0));
|
|
let scale: f32 = arg(&args, "--scale").and_then(|s| s.parse().ok()).unwrap_or(1.0);
|
|
let theme_id = arg(&args, "--theme").unwrap_or_else(|| "catppuccin-mocha".into());
|
|
let Some(theme) = notescraft_themes::builtin(&theme_id) else {
|
|
eprintln!("unknown theme {theme_id}");
|
|
std::process::exit(2);
|
|
};
|
|
// `--lazy` opens the vault the way the app does, so the first frames show the indexing card.
|
|
let opened = if args.iter().any(|arg| arg == "--lazy") {
|
|
Session::open_lazy(std::path::Path::new(&vault))
|
|
} else if args.iter().any(|arg| ["--editable", "--slash", "--batch"].contains(&arg.as_str())) {
|
|
Session::open(std::path::Path::new(&vault))
|
|
} else {
|
|
Session::open_read_only(std::path::Path::new(&vault))
|
|
};
|
|
let session = match opened {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("could not open vault: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
let mut app = NotesApp::new(session, theme);
|
|
if let Err(error) = panel_options(&args, &mut app) {
|
|
eprintln!("{error}");
|
|
std::process::exit(2);
|
|
}
|
|
// The app restores the saved theme from the config: select the requested one the same way a user would.
|
|
if args.iter().any(|a| a == "--theme") {
|
|
app.run_command("settings.set", &serde_json::json!({"key":"session.theme", "value":theme_id}));
|
|
}
|
|
// `--tab explorer|search|tags|buffers|source` and `--query <text>` pick the sidebar view.
|
|
if let Some(tab) = arg(&args, "--tab") {
|
|
use notescraft_ui_egui::sidebar_tabs::SidebarTab;
|
|
app.state.sidebar_tab = match tab.as_str() {
|
|
"search" => SidebarTab::Search,
|
|
"tags" => SidebarTab::Tags,
|
|
"buffers" => SidebarTab::Buffers,
|
|
"source" => SidebarTab::Source,
|
|
_ => SidebarTab::Explorer,
|
|
};
|
|
}
|
|
if let Some(query) = arg(&args, "--query") {
|
|
app.search.set_query(query, 0.0);
|
|
}
|
|
// `--pin <folder>` (repeatable), `--filter <text>` and `--follow` set the Explorer navigation state.
|
|
for (index, _) in args.iter().enumerate().filter(|(_, a)| *a == "--pin") {
|
|
if let Some(folder) = args.get(index.saturating_add(1)) {
|
|
let _ = app.state.toggle_pin(folder);
|
|
}
|
|
}
|
|
if let Some(text) = arg(&args, "--filter") {
|
|
app.state.explorer_filter = text;
|
|
}
|
|
app.state.explorer_follow = args.iter().any(|a| a == "--follow");
|
|
// `--batch` opens the batch review and runs its dry run; `--batch-note <path>` then shows that note's changes.
|
|
let batch = args.iter().any(|a| a == "--batch");
|
|
if args.iter().any(|a| a == "--vim") {
|
|
app.run_command("settings.set", &serde_json::json!({"key":"editor.vim", "value":true}));
|
|
}
|
|
app.state.split_request = args.iter().any(|a| a == "--split");
|
|
if args.iter().any(|a| a == "--find") {
|
|
app.open_find();
|
|
}
|
|
if args.iter().any(|a| a == "--pin-tab") {
|
|
app.tabs.toggle_pin("lists.md");
|
|
}
|
|
|
|
if args.iter().any(|a| a == "--help-card") {
|
|
app.vim_nav.help = true;
|
|
}
|
|
for path in args.iter().enumerate().filter(|(_, a)| *a == "--open").filter_map(|(i, _)| args.get(i + 1)) {
|
|
app.open_note(path);
|
|
if args.iter().any(|a| a == "--source") {
|
|
app.editing.set_view(path, notescraft_ui_egui::center::ViewMode::Markdown);
|
|
}
|
|
if args.iter().any(|arg| arg == "--properties-raw") {
|
|
app.state.set_properties_expanded(path, true);
|
|
}
|
|
if let Some(edit) = app.editing.editor_of(path) {
|
|
if args.iter().any(|arg| arg == "--properties-raw") {
|
|
edit.properties.set_raw_mode(true);
|
|
}
|
|
if let Some(query) = arg(&args, "--slash") {
|
|
edit.view = notescraft_ui_egui::center::ViewMode::Markdown;
|
|
edit.editor.text.push_str("\n\n");
|
|
edit.editor.text.push_str(&query);
|
|
edit.editor.jump = Some(edit.editor.text.len());
|
|
}
|
|
}
|
|
}
|
|
if args.iter().any(|a| a == "--settings") {
|
|
app.open_note(notescraft_ui_egui::tabs::SETTINGS_PATH);
|
|
}
|
|
if args.iter().any(|arg| arg == "--registry") {
|
|
app.open_registry();
|
|
}
|
|
// `--right-tab related` opens that right-card tab; `--related-json <file>` shows a canned `knowledge.related` result.
|
|
if let Some(tab) = arg(&args, "--right-tab") {
|
|
use notescraft_ui_egui::right_view::RightTab;
|
|
app.right_tab = match tab.as_str() {
|
|
"related" => RightTab::Related,
|
|
"lint" => RightTab::Lint,
|
|
"outline" => RightTab::Outline,
|
|
"assistant" => RightTab::Assistant,
|
|
_ => RightTab::Backlinks,
|
|
};
|
|
}
|
|
if let Some(value) = arg(&args, "--related-json").and_then(|file| std::fs::read_to_string(file).ok()).and_then(|text| serde_json::from_str(&text).ok()) {
|
|
app.preview_related(&value);
|
|
}
|
|
if args.iter().any(|arg| arg == "--export-dialog") {
|
|
app.export_dialog = app
|
|
.tabs
|
|
.active_path()
|
|
.filter(|path| notescraft_ui_egui::tabs::TabKind::of(path) == notescraft_ui_egui::tabs::TabKind::Note)
|
|
.map(notescraft_ui_egui::export_dialog::ExportDialog::for_note);
|
|
}
|
|
if args.iter().any(|arg| arg == "--assistant-demo") {
|
|
let connection = notescraft_engine::Connection {
|
|
id: "opencode".into(),
|
|
name: "OpenCode".into(),
|
|
command: "opencode acp".into(),
|
|
icon: "opencode".into(),
|
|
..Default::default()
|
|
};
|
|
let settings = notescraft_engine::AssistantSettings { connections: vec![connection], active: "opencode".into(), ..Default::default() };
|
|
app.run_command("settings.set", &serde_json::json!({"key":"assistant", "value":settings}));
|
|
if args.iter().any(|arg| arg == "--assistant-sources") {
|
|
app.assistant.seed_demo_sources();
|
|
} else {
|
|
app.assistant.seed_demo();
|
|
}
|
|
if let Err(error) = app.assistant.attach(notescraft_ui_egui::assistant_panel::Chip::Selection {
|
|
path: "Projects/NotesCraft spec.md".into(),
|
|
start_line: 3,
|
|
end_line: 5,
|
|
text: "A native workspace for connected notes.".into(),
|
|
}) {
|
|
eprintln!("{error}");
|
|
}
|
|
app.run_command("ui.toggle_assistant", &serde_json::Value::Null);
|
|
}
|
|
if batch {
|
|
app.start_batch_review();
|
|
if let Some(path) = arg(&args, "--batch-note") {
|
|
app.select_batch_note(&path);
|
|
}
|
|
}
|
|
let mut harness = egui_kittest::Harness::builder()
|
|
.with_size(egui::vec2(w, h))
|
|
.with_pixels_per_point(scale)
|
|
.with_max_steps(32)
|
|
.wgpu()
|
|
.build_ui_state(|ui, app: &mut NotesApp| app.ui(ui.ctx()), app);
|
|
// Panels with background work (git, search) keep repainting: step with short pauses so
|
|
// worker replies land, instead of waiting for the UI to go idle.
|
|
for _ in 0..16 {
|
|
if args.iter().any(|arg| arg == "--drop-hover") {
|
|
let input = harness.input_mut();
|
|
input.hovered_files = vec![egui::HoveredFile { path: Some(std::path::PathBuf::from("preview.png")), mime: "image/png".to_owned() }];
|
|
input.events.push(egui::Event::PointerMoved(egui::pos2(w * 0.5, h * 0.5)));
|
|
}
|
|
harness.step();
|
|
std::thread::sleep(std::time::Duration::from_millis(60));
|
|
}
|
|
// `--click-at x,y` presses the primary button there (several allowed), as a hand would.
|
|
for (flag, value) in args.iter().enumerate().filter(|(_, a)| *a == "--click-at").filter_map(|(i, a)| args.get(i + 1).map(|v| (a, v))) {
|
|
let _ = flag;
|
|
let Some((x, y)) = value.split_once(',').and_then(|(x, y)| Some((x.parse::<f32>().ok()?, y.parse::<f32>().ok()?))) else { continue };
|
|
let at = egui::pos2(x, y);
|
|
for event in [
|
|
egui::Event::PointerMoved(at),
|
|
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
|
|
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
|
|
] {
|
|
harness.input_mut().events.push(event);
|
|
harness.step();
|
|
}
|
|
for _ in 0..6 {
|
|
harness.step();
|
|
std::thread::sleep(std::time::Duration::from_millis(40));
|
|
}
|
|
}
|
|
// `--hover-at x,y` rests the pointer there for a second (the link preview card needs a dwell).
|
|
if let Some((x, y)) = arg(&args, "--hover-at").and_then(|v| v.split_once(',').and_then(|(x, y)| Some((x.parse::<f32>().ok()?, y.parse::<f32>().ok()?)))) {
|
|
harness.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
|
for _ in 0..24 {
|
|
harness.step();
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
}
|
|
}
|
|
match harness.render() {
|
|
Ok(image) => {
|
|
if let Err(e) = image.save(&out) {
|
|
eprintln!("could not save {out}: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
println!("wrote {out}");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("render failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn panel_options(args: &[String], app: &mut NotesApp) -> Result<(), String> {
|
|
use notescraft_ui_egui::panel_resize::{RIGHT, SIDEBAR};
|
|
const INVALID_WIDTH: &str = "Invalid panel width";
|
|
const INVALID_COLLAPSE: &str = "Expected --collapse sidebar|right";
|
|
for (flag, card, width) in [("--sidebar-width", SIDEBAR, &mut app.state.sidebar_width), ("--right-width", RIGHT, &mut app.state.right_width)] {
|
|
if args.iter().any(|value| value == flag) {
|
|
let text = arg(args, flag).ok_or_else(|| format!("{INVALID_WIDTH}: {flag}"))?;
|
|
let value = text.parse::<f32>().map_err(|_| format!("{INVALID_WIDTH}: {flag} {text}"))?;
|
|
if !(card.minimum..=card.maximum).contains(&value) {
|
|
return Err(format!("{INVALID_WIDTH}: {flag} {text}"));
|
|
}
|
|
*width = value;
|
|
}
|
|
}
|
|
for (index, _) in args.iter().enumerate().filter(|(_, value)| value.as_str() == "--collapse") {
|
|
match args.get(index.saturating_add(1)).map(String::as_str) {
|
|
Some("sidebar") => app.state.sidebar_visible = false,
|
|
Some("right") => app.state.right_visible = false,
|
|
_ => return Err(INVALID_COLLAPSE.into()),
|
|
}
|
|
}
|
|
if args.iter().any(|value| value == "--show-right") {
|
|
app.state.right_visible = true;
|
|
}
|
|
if args.iter().any(|value| value == "--no-animations") {
|
|
app.state.animations = false;
|
|
}
|
|
Ok(())
|
|
}
|