Screenshot automation (--connect repeatable, --do, --answer, --shot-delay), SFTP Browser palette entry, centred dialogs, IRC opens the first joined room; demo_data example

This commit is contained in:
Andy Dixon
2026-10-11 19:41:30 +01:00
parent 84eff02c15
commit ce9c5c6b44
8 changed files with 262 additions and 13 deletions
+33 -10
View File
@@ -23,8 +23,14 @@ pub struct Options {
/// Open a window at startup: editor, quick, settings[:tab], keys, snippets, triggers,
/// recordings, palette, export, import (screenshot checks).
pub open: Option<String>,
/// Connect a saved profile (by name or id) at startup.
pub connect: Option<String>,
/// Connect saved profiles (by name or id) at startup.
pub connect: Vec<String>,
/// Command-palette actions (by label) to run once sessions have had time to connect.
pub actions: Vec<String>,
/// Replies to the first credential prompts, in order (screenshots of token-protected sessions).
pub answers: Vec<String>,
/// How long to wait before taking the screenshot.
pub shot_delay: std::time::Duration,
}
/// Errors from worker threads, drained into toasts each frame.
@@ -108,8 +114,10 @@ pub struct App {
pub tabs_ui: tabs::TabsUi,
pub latency: Option<(String, Result<i64, String>)>,
pub update: Option<nexus_core::updater::UpdateInfo>,
screenshot: Option<(PathBuf, u32)>,
screenshot: Option<(PathBuf, std::time::Instant, std::time::Duration)>,
shot_settle: u32,
actions: Vec<String>,
answers: Vec<String>,
run_local: Option<(String, Option<String>)>,
/// Apply `prefs.sidebar_width` to the panel on the next frame (after Settings changed it).
resize_sidebar: bool,
@@ -155,13 +163,15 @@ impl App {
tabs_ui: Default::default(),
latency: None,
update: None,
screenshot: opts.screenshot.map(|p| (p, 0)),
screenshot: opts.screenshot.map(|p| (p, std::time::Instant::now(), opts.shot_delay)),
shot_settle: 0,
actions: opts.actions,
answers: opts.answers,
run_local: opts.run_local.map(|c| (c, None)),
resize_sidebar: false,
};
if let Some(c) = opts.connect.as_deref() {
match app.s.profiles.iter().find(|p| p.id == c || p.display_name == c).map(|p| p.id.clone()) {
for c in &opts.connect {
match app.s.profiles.iter().find(|p| &p.id == c || &p.display_name == c).map(|p| p.id.clone()) {
Some(id) => app.connect_profile(&id, true),
None => eprintln!("--connect: no profile named {c:?}"),
}
@@ -479,6 +489,9 @@ impl App {
self.s.success(format!("Recording saved ({:.0}s, {events} events)", duration));
}
Event::SftpProgress { session, progress } => self.sftp.on_progress(&session, progress),
Event::SecretPrompt { session, title, message, reply } if !self.answers.is_empty() => {
let _ = (session, title, message, reply.send(self.answers.remove(0)));
}
Event::SecretPrompt { session, title, message, reply } => {
self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply });
}
@@ -807,11 +820,21 @@ impl App {
// --- screenshot automation ---------------------------------------------------------
fn screenshot(&mut self, ctx: &egui::Context) {
let Some((path, frames)) = &mut self.screenshot else {
let Some((path, started, delay)) = &mut self.screenshot else {
return;
};
*frames += 1;
if *frames < 40 {
let (elapsed, delay) = (started.elapsed(), *delay);
if !self.actions.is_empty() && elapsed >= delay / 2 {
for a in std::mem::take(&mut self.actions) {
if !chrome::run_labelled(self, ctx, &a) {
eprintln!("--do: no palette command {a:?}");
}
}
ctx.request_repaint();
return;
}
let path = path.clone();
if elapsed < delay {
std::thread::sleep(std::time::Duration::from_millis(15));
ctx.request_repaint();
return;
@@ -824,7 +847,7 @@ impl App {
if let Some(img) = shot {
let [w, h] = img.size;
let raw: Vec<u8> = img.pixels.iter().flat_map(|p| p.to_array()).collect();
if let Err(e) = image::save_buffer(&*path, &raw, w as u32, h as u32, image::ExtendedColorType::Rgba8) {
if let Err(e) = image::save_buffer(&path, &raw, w as u32, h as u32, image::ExtendedColorType::Rgba8) {
eprintln!("screenshot: {e}");
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+16
View File
@@ -436,6 +436,7 @@ enum Cmd {
Theme(ThemeKind),
About,
Fullscreen,
Sftp(String),
}
/// Fuzzy subsequence match with bonuses for consecutive and leading hits (utils.js fuzzyMatch).
@@ -503,14 +504,29 @@ fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)>
if app.s.focused_session().and_then(|f| app.s.pane(&f)).is_some_and(|p| matches!(p.view, View::Terminal(_))) {
v.push((Cmd::SplitRight, "Split Pane Right".into(), "Terminal", icon::SQUARE_SPLIT_HORIZONTAL, keys("Mod+Shift+D")));
v.push((Cmd::SplitDown, "Split Pane Down".into(), "Terminal", icon::SQUARE_SPLIT_VERTICAL, keys("Mod+Shift+E")));
if let Some(id) = app.s.focused_session().filter(|f| app.s.pane(f).is_some_and(|p| p.info.protocol == Protocol::Ssh)) {
v.push((Cmd::Sftp(id), "SFTP Browser".into(), "Terminal", icon::FOLDER_OPEN, String::new()));
}
v.push((Cmd::Clear, "Clear Terminal".into(), "Terminal", icon::ERASER, String::new()));
v.push((Cmd::Reset, "Reset Terminal".into(), "Terminal", icon::ARROW_COUNTER_CLOCKWISE, String::new()));
}
v
}
/// Runs the palette command with this label (case-insensitive); false when there is none.
pub fn run_labelled(app: &mut App, ctx: &egui::Context, label: &str) -> bool {
match commands(app).into_iter().find(|c| c.1.eq_ignore_ascii_case(label)) {
Some(c) => {
run(app, ctx, c.0);
true
}
None => false,
}
}
fn run(app: &mut App, ctx: &egui::Context, c: Cmd) {
match c {
Cmd::Sftp(id) => app.s.request(Request::Sftp(id)),
Cmd::Connect(id) => app.connect_profile(&id, false),
Cmd::NewProfile(p) => app.s.request(Request::NewProfile { protocol: Some(p), folder: String::new() }),
Cmd::Quick => app.quick.open(),
+5
View File
@@ -191,7 +191,12 @@ impl IrcView {
pub fn on_event(&mut self, event: irc::Event) {
match route(&event) {
Route::Rooms => {
// Show the first room once auto-join lands, rather than leaving the server log up.
let first = self.joined.is_empty() && self.active == SERVER;
self.joined = event.rooms;
if first && let Some(r) = self.joined.first().map(|r| r.name.clone()) {
self.set_active(&r);
}
for r in self.joined.clone() {
self.target(&r.name);
}
+19 -3
View File
@@ -33,7 +33,16 @@ use nexus_core::service::Service;
use std::sync::Arc;
fn main() -> eframe::Result {
let mut opts = app::Options { screenshot: None, theme: None, run_local: None, open: None, connect: None };
let mut opts = app::Options {
screenshot: None,
theme: None,
run_local: None,
open: None,
connect: vec![],
actions: vec![],
answers: vec![],
shot_delay: std::time::Duration::from_millis(600),
};
let mut size = [1360.0, 860.0];
let mut data_dir = None;
let mut args = std::env::args().skip(1);
@@ -42,7 +51,14 @@ fn main() -> eframe::Result {
"--theme" => opts.theme = args.next(),
"--run-local" => opts.run_local = args.next(),
"--open" => opts.open = args.next(),
"--connect" => opts.connect = args.next(),
"--connect" => opts.connect.extend(args.next()),
"--do" => opts.actions.extend(args.next()),
"--answer" => opts.answers.extend(args.next()),
"--shot-delay" => {
if let Some(ms) = args.next().and_then(|s| s.parse().ok()) {
opts.shot_delay = std::time::Duration::from_millis(ms);
}
}
"--screenshot" => opts.screenshot = args.next().map(Into::into),
// Use another data directory (testing, screenshots, portable installs).
"--data-dir" => data_dir = args.next(),
@@ -57,7 +73,7 @@ fn main() -> eframe::Result {
}
_ => {
println!(
"usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME] [--open WINDOW] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]"
"usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME]... [--open WINDOW] [--do PALETTE-COMMAND]... [--answer SECRET]... [--shot-delay MS] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]"
);
return Ok(());
}
+4
View File
@@ -183,6 +183,8 @@ impl Recordings {
let mut open = self.open;
egui::Window::new(format!("{} Session Recordings", icon::RECORD))
.id(egui::Id::new("recordings"))
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.open(&mut open)
.collapsible(false)
.default_size([640.0, 440.0])
@@ -298,6 +300,8 @@ impl Recordings {
let mut open = true;
egui::Window::new(format!("{} {}", icon::PLAY_CIRCLE, p.title))
.id(egui::Id::new("rec-playback"))
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.open(&mut open)
.collapsible(false)
.default_size([820.0, 520.0])
+2
View File
@@ -104,6 +104,8 @@ impl Snippets {
let mut open = self.open;
egui::Window::new(format!("{} Snippets", icon::CODE))
.id(egui::Id::new("snippets"))
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.open(&mut open)
.collapsible(false)
.default_size([720.0, 440.0])
+2
View File
@@ -159,6 +159,8 @@ impl Triggers {
let mut open = self.open;
egui::Window::new(format!("{} Trigger Rules", icon::LIGHTNING))
.id(egui::Id::new("triggers"))
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.open(&mut open)
.collapsible(false)
.default_size([620.0, 380.0])