diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index fc35185..d907345 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -931,7 +931,7 @@ impl eframe::App for App { if self.palette.is_none() && self.dialog.is_none() && self.prompts.is_empty() { self.shortcuts(&ctx); } - if ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) && self.palette.is_none() { + if crate::chrome::modal_key(&ctx, Key::Escape) && ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) && self.palette.is_none() { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); } self.file_drops(&ctx); @@ -979,6 +979,8 @@ impl eframe::App for App { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| tabs::workspace(self, ui)); // Floating windows and modals. + let prompt_open = !self.prompts.is_empty(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-prompt-open"), prompt_open)); self.quick.ui(&ctx, &mut self.s); self.settings.ui(&ctx, &mut self.s); self.keys.ui(&ctx, &mut self.s); diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index 026ce04..e37c346 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -598,14 +598,14 @@ pub fn palette(app: &mut App, ctx: &egui::Context) { let matches: Vec<_> = matches.into_iter().map(|(_, c)| c).collect(); pal.sel = pal.sel.min(matches.len().saturating_sub(1)); let mut chosen = None; - let mut close = ctx.input(|i| i.key_pressed(Key::Escape)); + let mut close = crate::chrome::modal_key(ctx, Key::Escape); if ctx.input(|i| i.key_pressed(Key::ArrowDown)) { pal.sel = (pal.sel + 1).min(matches.len().saturating_sub(1)); } if ctx.input(|i| i.key_pressed(Key::ArrowUp)) { pal.sel = pal.sel.saturating_sub(1); } - if ctx.input(|i| i.key_pressed(Key::Enter)) { + if crate::chrome::modal_key(ctx, Key::Enter) { chosen = matches.get(pal.sel).map(|m| m.0.clone()); } modal_backdrop(ctx); @@ -677,6 +677,15 @@ pub fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut U }); } +/// Set each frame while a credential or host-key prompt is on screen. +const PROMPT_OPEN: &str = "nexus-prompt-open"; + +/// Enter/Escape for any modal except the prompts: while a prompt is showing it takes those keys, +/// so one key press can't also confirm or dismiss the dialog underneath. +pub fn modal_key(ctx: &egui::Context, key: Key) -> bool { + !ctx.data(|d| d.get_temp::(egui::Id::new(PROMPT_OPEN)).unwrap_or(false)) && ctx.input(|i| i.key_pressed(key)) +} + pub fn prompts(app: &mut App, ctx: &egui::Context) { let Some(p) = app.prompts.front_mut() else { return; @@ -781,7 +790,7 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { return; }; let t = Tokens::get(ctx); - let esc = ctx.input(|i| i.key_pressed(Key::Escape)); + let esc = crate::chrome::modal_key(ctx, Key::Escape); let mut keep = !esc; match &mut d { Dialog::Reuse { profile, existing } => { @@ -835,7 +844,7 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { ui.label(format!("{name} is still connected.")); ui.add_space(8.0); ui.horizontal(|ui| { - if widgets::primary(ui, icon::X, "Close").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)) { + if widgets::primary(ui, icon::X, "Close").clicked() || crate::chrome::modal_key(ctx, Key::Enter) { let id = session.clone(); app.close_session(&id); keep = false; @@ -869,7 +878,7 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(name).desired_width(f32::INFINITY)); r.request_focus(); ui.horizontal(|ui| { - if widgets::primary(ui, icon::CHECK, "Rename").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)) { + if widgets::primary(ui, icon::CHECK, "Rename").clicked() || crate::chrome::modal_key(ctx, Key::Enter) { let n = name.trim().to_string(); if !n.is_empty() { let _ = app.s.svc.sessions.rename(session, &n); @@ -943,7 +952,7 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { } keep = false; } - if ui.button("Cancel").clicked() || ctx.input(|i| i.key_pressed(Key::Escape)) { + if ui.button("Cancel").clicked() || crate::chrome::modal_key(ctx, Key::Escape) { keep = false; } }); @@ -1012,7 +1021,7 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(value).desired_width(f32::INFINITY)); r.request_focus(); ui.horizontal(|ui| { - submit = widgets::primary(ui, icon::CHECK, "OK").clicked() || ctx.input(|i| i.key_pressed(Key::Enter)); + submit = widgets::primary(ui, icon::CHECK, "OK").clicked() || crate::chrome::modal_key(ctx, Key::Enter); if ui.button("Cancel").clicked() { keep = false; } @@ -1093,3 +1102,26 @@ pub fn toasts(app: &mut App, ctx: &egui::Context) { app.s.toasts.remove(i); } } + +#[cfg(test)] +mod modal_key_tests { + use super::*; + + #[test] + fn an_open_prompt_takes_enter_and_escape() { + let ctx = egui::Context::default(); + let press = |key| egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ..Default::default() + }; + for (prompt_open, want) in [(false, true), (true, false)] { + let mut got = None; + let mut out = ctx.run_ui(press(Key::Enter), |ui| { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(PROMPT_OPEN), prompt_open)); + got = Some(modal_key(ui.ctx(), Key::Enter)); + }); + out.textures_delta.clear(); + assert_eq!(got, Some(want), "prompt open: {prompt_open}"); + } + } +} diff --git a/rust/app/src/forwards.rs b/rust/app/src/forwards.rs index 246ada6..b59b282 100644 --- a/rust/app/src/forwards.rs +++ b/rust/app/src/forwards.rs @@ -73,7 +73,7 @@ impl Forwards { } }; let t = Tokens::get(ctx); - let mut keep = !ctx.input(|i| i.key_pressed(Key::Escape)); + let mut keep = !crate::chrome::modal_key(ctx, Key::Escape); let mut stop = None; let mut start = false; let f = &mut self.form; diff --git a/rust/app/src/quick_connect.rs b/rust/app/src/quick_connect.rs index b4ce9b5..100e6c6 100644 --- a/rust/app/src/quick_connect.rs +++ b/rust/app/src/quick_connect.rs @@ -84,7 +84,7 @@ impl QuickConnect { return; } let t = Tokens::get(ctx); - let (enter, esc) = ctx.input(|i| (i.key_pressed(Key::Enter), i.key_pressed(Key::Escape))); + let (enter, esc) = (crate::chrome::modal_key(ctx, Key::Enter), crate::chrome::modal_key(ctx, Key::Escape)); let mut connect = enter; let mut close = esc; let label = |ui: &mut egui::Ui, l: &str| ui.label(RichText::new(l).color(t.text_dim).size(12.0)); diff --git a/rust/app/src/recordings.rs b/rust/app/src/recordings.rs index 55bdb4c..948d446 100644 --- a/rust/app/src/recordings.rs +++ b/rust/app/src/recordings.rs @@ -266,7 +266,7 @@ impl Recordings { let Some(r) = &self.confirm_delete else { return; }; - let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + let (mut yes, mut no) = (false, crate::chrome::modal_key(ctx, Key::Escape)); let name = if r.title.is_empty() { r.id.clone() } else { r.title.clone() }; crate::chrome::modal(ctx, "rec-delete", 380.0, |ui| { ui.label(RichText::new("Delete recording?").font(theme::semibold(15.0))); diff --git a/rust/app/src/sftp.rs b/rust/app/src/sftp.rs index 76d7c41..3b37ff1 100644 --- a/rust/app/src/sftp.rs +++ b/rust/app/src/sftp.rs @@ -772,8 +772,8 @@ impl Sftp { return; }; let t = Tokens::get(ctx); - let esc = ctx.input(|i| i.key_pressed(Key::Escape)); - let enter = ctx.input(|i| i.key_pressed(Key::Enter)); + let esc = crate::chrome::modal_key(ctx, Key::Escape); + let enter = crate::chrome::modal_key(ctx, Key::Enter); let mut keep = !esc; match &mut d { Dialog::Input { session, kind, value } => { @@ -961,7 +961,7 @@ impl Sftp { } } } - if ctx.input(|i| i.key_pressed(Key::Enter)) && writable && !target.is_empty() { + if crate::chrome::modal_key(ctx, Key::Enter) && writable && !target.is_empty() { chosen = Some(target.clone()); } if back && pk.filter.is_empty() && !pk.cwd.is_empty() && pk.cwd != "/" { diff --git a/rust/app/src/snippets.rs b/rust/app/src/snippets.rs index e118817..449d612 100644 --- a/rust/app/src/snippets.rs +++ b/rust/app/src/snippets.rs @@ -291,7 +291,7 @@ impl Snippets { fn var_form(&mut self, ctx: &egui::Context, s: &mut Shared) { let Some(f) = &mut self.vars else { return }; - let (mut send, mut cancel) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + let (mut send, mut cancel) = (false, crate::chrome::modal_key(ctx, Key::Escape)); crate::chrome::modal(ctx, "snippet-vars", 360.0, |ui| { ui.label(RichText::new("Snippet Variables").font(theme::semibold(15.0))); ui.add_space(4.0); @@ -304,7 +304,7 @@ impl Snippets { } ui.add_space(8.0); ui.horizontal(|ui| { - send = widgets::primary(ui, icon::PAPER_PLANE_RIGHT, "Send").clicked() || ui.input(|i| i.key_pressed(Key::Enter)); + send = widgets::primary(ui, icon::PAPER_PLANE_RIGHT, "Send").clicked() || crate::chrome::modal_key(ui.ctx(), Key::Enter); cancel |= ui.button("Cancel").clicked(); }); }); @@ -322,7 +322,7 @@ impl Snippets { return; }; let name = self.list.iter().find(|x| x.id == id).map(|x| x.name.clone()).unwrap_or_default(); - let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); + let (mut yes, mut no) = (false, crate::chrome::modal_key(ctx, Key::Escape)); crate::chrome::modal(ctx, "snippet-delete", 360.0, |ui| { ui.label(RichText::new("Delete snippet?").font(theme::semibold(15.0))); ui.label(format!("\u{201c}{name}\u{201d} will be permanently deleted."));