Files
Marcos Paulo c78fd488cc
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: registry core (model, guard, verify, store, net, install, commands), registry browser UI, signing tool, size profile and font dedupe, lazy Hebrew/Arabic fonts, htmlmd/srs/textdiff crates
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 17:28:10 -03:00

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(())
}