mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
egui037: preserve modal focus and first-pass text selection
This commit is contained in:
@@ -54,6 +54,8 @@ impl Bench {
|
||||
}
|
||||
}
|
||||
|
||||
/// The render key of the main preview texture, or `None` while that slot holds no texture.
|
||||
/// A changed key means the benchmark's requested main-image render has arrived.
|
||||
fn main_key(app: &LightcraftApp) -> Option<u64> {
|
||||
app.renderer.textures.get(&Slot::Main).map(|t| t.key)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the browser host's `Services`: library backup/restore through browser storage and a
|
||||
/// download, asynchronous file picking, downloads for writes, and PNG encoding. Desktop-only
|
||||
/// services (native pickers, reveal, model folders) stay unset.
|
||||
fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen: Rc<Cell<bool>>, ctx: egui::Context) -> Services {
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
@@ -420,6 +423,9 @@ struct WebApp {
|
||||
}
|
||||
|
||||
impl WebApp {
|
||||
/// Construct the web app from the boot options: install originals and open the library from
|
||||
/// browser storage (a read failure starts an empty session and a blocking `LibraryProblem`),
|
||||
/// load the stored UI state, start render workers and the optional benchmark.
|
||||
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec<u8>) -> Self {
|
||||
let Boot { opts, backend, files, index, lib_dir, load_error } = boot;
|
||||
let originals = Originals::default();
|
||||
@@ -684,6 +690,8 @@ impl WebApp {
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
/// eframe's non-drawing pass for the browser host: drain safety notices and queued inbox work,
|
||||
/// load originals the UI requested, apply the cache budget, run the app's logic, then save.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for text in safety::take_notices() {
|
||||
let now = self.app.tick_now();
|
||||
@@ -700,10 +708,14 @@ impl eframe::App for WebApp {
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// Pass the browser's raw input to the app hook before egui processes it, so prompt barriers
|
||||
/// and synthetic or dropped input are applied consistently with the desktop host.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
/// eframe's UI pass: import queued drops unless a prompt blocks input, draw the app, update the
|
||||
/// page title, and advance the benchmark once per root pass.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Drops are the UI phase's: a hidden `logic`/`run_logic` tick must not import them twice.
|
||||
// A prompt owns the library too: keep taking the queue only after it is gone, so a drop
|
||||
|
||||
@@ -75,6 +75,8 @@ impl eframe::App for App {
|
||||
self.0.logic(ctx);
|
||||
self.1.tick(&mut self.0, ctx);
|
||||
}
|
||||
/// Forwards the host's raw-input pass to the app's hook before egui processes it, keeping
|
||||
/// modal/prompt input barriers and queued synthetic or dropped input ahead of the panels.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
@@ -404,6 +406,9 @@ impl PrefsWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame host tick: records the window's placement while visible, asks for a repaint when
|
||||
/// a remembered size is due, and saves settings/library at most every three seconds or
|
||||
/// immediately after the library path changes; a failed save is reported once and retried.
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
// (asked for before the input is read: the context is not asked from inside its own lock)
|
||||
let zoom = ctx.zoom_factor();
|
||||
|
||||
@@ -170,6 +170,9 @@ fn modifiers(p: &Value) -> egui::Modifiers {
|
||||
egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") }
|
||||
}
|
||||
|
||||
/// Parse a control-protocol key name into an egui key. `egui::Key::from_name` is tried first;
|
||||
/// then lowercase aliases cover `return`/`enter`, `esc`/`escape`, `delete`/`backspace` (both
|
||||
/// Backspace), arrow names, space/tab, and backslash, slash and square brackets.
|
||||
fn key_from(name: &str) -> Option<egui::Key> {
|
||||
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
|
||||
"enter" | "return" => Some(egui::Key::Enter),
|
||||
@@ -226,6 +229,8 @@ pub(crate) struct GestureOutcome {
|
||||
}
|
||||
|
||||
impl GestureOutcome {
|
||||
/// A gesture outcome that injected no widget-targeted events, so delivery has no geometry to
|
||||
/// re-check.
|
||||
fn done(result: Value) -> Self {
|
||||
Self { result, target: None }
|
||||
}
|
||||
@@ -241,6 +246,8 @@ fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64,
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
}
|
||||
|
||||
/// The rectangle registered for automation id `id` in the latest completed pass. The last
|
||||
/// registration wins, so a redrawn widget's current geometry is returned.
|
||||
pub(crate) fn widget_rect(app: &LightcraftApp, id: &str) -> Option<egui::Rect> {
|
||||
app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
@@ -311,6 +318,9 @@ pub(crate) fn widget_target_visible(ctx: &egui::Context, hit: Option<crate::widg
|
||||
top_modal == at_layer && !above_modal
|
||||
}
|
||||
|
||||
/// Dispatch one control request: run engine commands, list menus/widgets, inspect state, queue
|
||||
/// file drops and synthetic input, capture screenshots, or return an error for an unknown or
|
||||
/// invalid request. Requests that need fresh widget geometry are queued as `Outcome` values.
|
||||
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
@@ -774,6 +784,10 @@ pub(crate) fn gesture_events(app: &mut LightcraftApp, ctx: &egui::Context, metho
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `image` as PNG and write it to the capture's pinned target, returning the control
|
||||
/// protocol's JSON result. The destination is revalidated before encoding; a new path is written
|
||||
/// with create-new semantics (never replacing a file that appeared), and a missing encoder or
|
||||
/// writer is an error. `None` only reports the image size without writing.
|
||||
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, target: Option<&lightcraft_engine::originals::WriteTarget>) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let Some(target) = target else {
|
||||
|
||||
@@ -397,6 +397,10 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren
|
||||
Ok(json!({"docking": app.ui.docking}))
|
||||
}
|
||||
|
||||
/// Draw the docked workspace: restore or fall back from an invalid layout, sync panel
|
||||
/// visibility, render the panels and the current view in the canvas, then apply the docking
|
||||
/// actions. If the layout changed during the pass, any in-flight dock drag is cancelled and the
|
||||
/// docking actions it produced are not applied.
|
||||
pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
|
||||
|
||||
@@ -43,6 +43,7 @@ struct Hud {
|
||||
logged: Option<f64>,
|
||||
}
|
||||
|
||||
/// Stable egui data id under which the FPS HUD's per-frame state is stored.
|
||||
fn hud_id() -> egui::Id {
|
||||
egui::Id::unique("fps-hud-state")
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize;
|
||||
native_viewport_pixels(size, pixels_per_point)
|
||||
}
|
||||
|
||||
/// Turn a logical viewport size into native pixels for the headless renderer. Both edges and
|
||||
/// the scale must be finite and positive; each scaled edge is rounded like egui's scissor and
|
||||
/// must be within `1..=MAX_IMAGE_EDGE`, and the product must fit both the raster and screenshot
|
||||
/// pixel budgets. Returns a user-facing error instead of clamping.
|
||||
fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> {
|
||||
let [width, height] = size;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
|
||||
@@ -1397,6 +1397,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail,
|
||||
/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an
|
||||
/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail
|
||||
/// uses its width as height.
|
||||
fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let c = d.candidates[i].clone();
|
||||
|
||||
@@ -596,6 +596,8 @@ pub struct LightcraftApp {
|
||||
}
|
||||
|
||||
impl LightcraftApp {
|
||||
/// Build the app with default UI, renderer, cache and control state, and move AI segmentation
|
||||
/// to a worker so frames apply results instead of waiting.
|
||||
pub fn new(mut session: Session, services: Services) -> Self {
|
||||
// AI mask requests run on the model's worker; frames apply their results (never wait)
|
||||
session.segmenter.background = true;
|
||||
@@ -710,6 +712,8 @@ impl LightcraftApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the control-channel receiver and keep CPU copies of photo textures, which headless
|
||||
/// screenshots need; returns the app for chaining.
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
// keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them
|
||||
@@ -723,6 +727,7 @@ impl LightcraftApp {
|
||||
self.clock.now()
|
||||
}
|
||||
|
||||
/// The app clock in milliseconds, for control deadlines and timers.
|
||||
fn tick_now_ms(&self) -> f64 {
|
||||
self.clock.now() * 1000.0
|
||||
}
|
||||
@@ -1181,6 +1186,9 @@ impl LightcraftApp {
|
||||
self.ui.toast = Some((text.into(), t + 6.0, None));
|
||||
}
|
||||
|
||||
/// Take the control receiver for this frame, run every queued request whose sender still
|
||||
/// wants it, and reply directly or admit its screenshot/gesture to a later completed pass.
|
||||
/// Requests abandoned by their sender are skipped rather than executed late.
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
@@ -2868,10 +2876,12 @@ struct AppClock {
|
||||
}
|
||||
|
||||
impl AppClock {
|
||||
/// Start the clock at the current monotonic instant with no simulated time.
|
||||
fn new() -> Self {
|
||||
Self { start: web_time::Instant::now(), simulated: None }
|
||||
}
|
||||
|
||||
/// Seconds on the clock: the supplied simulated time when set, otherwise time since `new`.
|
||||
fn now(&self) -> f64 {
|
||||
self.simulated.unwrap_or_else(|| self.start.elapsed().as_secs_f64())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const EDGE: f32 = 6.0;
|
||||
/// How fast resting on a scroll arrow scrolls, in points per second.
|
||||
const SPEED: f32 = 600.0;
|
||||
|
||||
/// The egui temp-data id holding the row a menu at `depth` hangs from.
|
||||
fn anchor_id(depth: usize) -> Id {
|
||||
Id::unique(("lc-menu-anchor", depth))
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Show the pending model-setup note for `kind` in the model dialog, with a button that cancels
|
||||
/// the pending action. Does nothing unless a valid denoise setup is waiting.
|
||||
pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) {
|
||||
if kind != "denoise" || !app.model_setup.denoise.as_ref().is_some_and(|r| r.valid(app)) {
|
||||
return;
|
||||
@@ -118,6 +120,11 @@ fn abandoned(app: &mut LightcraftApp) -> bool {
|
||||
!v["downloads"].as_array().into_iter().flatten().any(|d| matches!(d["state"].as_str(), Some("running" | "done" | "installing" | "installed")))
|
||||
}
|
||||
|
||||
/// Advance a pending model setup. An abandoned setup is cancelled on any call (the
|
||||
/// abandonment/download probe is not throttled); while another interaction is open it waits, and
|
||||
/// otherwise it checks the photo's model state at most every 250 ms. A photo that still has no
|
||||
/// model (or cannot use one) keeps waiting; otherwise denoise is enabled and the outcome reported
|
||||
/// in the status line.
|
||||
pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) {
|
||||
if app.model_setup.denoise.is_none() {
|
||||
return;
|
||||
|
||||
@@ -608,6 +608,9 @@ fn count(n: u64, one: &str, many: &str) -> String {
|
||||
tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many }))
|
||||
}
|
||||
|
||||
/// The pace to ask the denoise worker for: "light" when minimized, "normal" while unfocused,
|
||||
/// "pause" shortly after input, then "light"/"full"/"normal" according to move recency and
|
||||
/// whether the Denoise settings tab is being watched.
|
||||
pub(super) fn window_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64, watching: bool) -> &'static str {
|
||||
let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false)));
|
||||
pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching)
|
||||
@@ -665,6 +668,9 @@ pub(super) fn open_page(app: &mut LightcraftApp, url: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the denoise model's terms dialog from the engine's model info: unsupported files show
|
||||
/// why; otherwise the name/size, download host, licence and provenance, an optional page link,
|
||||
/// and the acceptance checkbox in `accepted`.
|
||||
pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info: &Value, accepted: &mut bool) {
|
||||
let kind = info["kind"].as_str().unwrap_or("unsupported");
|
||||
let file = info["fileName"].as_str().unwrap_or("");
|
||||
|
||||
@@ -498,6 +498,10 @@ fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &Window
|
||||
p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
|
||||
/// Draw the Develop loupe: the optional filmstrip, the fitted or zoomed photo (before/after and
|
||||
/// full-screen aware), crop overlays and the render requests for the current view, or, when no
|
||||
/// photo is active, the empty-library message if the library is empty and "No photo selected"
|
||||
/// otherwise.
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.max_rect();
|
||||
@@ -2912,6 +2916,11 @@ fn film_label(name: &str) -> String {
|
||||
if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::<String>()) } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Draw the horizontal filmstrip in `r`: selectable thumbnail cells with names and format when
|
||||
/// film names are on, badges when film badges are on, and stack counts, following the active
|
||||
/// photo and scrolling sideways with the wheel. Clicks select: Command toggles and Shift extends
|
||||
/// the range; in Compare they adjust the pair's selection, and a plain click replaces the active
|
||||
/// side.
|
||||
pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 0.0, t.canvas);
|
||||
|
||||
@@ -341,8 +341,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
})
|
||||
.show(ctx, |ui| {
|
||||
// Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the
|
||||
// first prompt frame cannot toggle a checkbox or press a button underneath it.
|
||||
if blocked {
|
||||
// first prompt frame cannot toggle a checkbox or press a button underneath it. The
|
||||
// blocking library window gets the same treatment: it owns input while it is on screen,
|
||||
// so the dialog behind it must not take focus or accept typed text either.
|
||||
if raw_blocked {
|
||||
ui.disable();
|
||||
}
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
@@ -1172,7 +1174,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
.width(f32::INFINITY)
|
||||
.select_on_focus(true)
|
||||
.show(ui);
|
||||
if just_opened(ui, "keyword-tag") {
|
||||
if ui.is_enabled() && just_opened(ui, "keyword-tag") {
|
||||
r.response.request_focus();
|
||||
}
|
||||
if r.ending == Some(crate::text_field::Ending::Return) {
|
||||
@@ -1217,7 +1219,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
|
||||
.width(f32::INFINITY)
|
||||
.select_on_focus(true)
|
||||
.show(ui);
|
||||
if just_opened(ui, "keyword-set") {
|
||||
if ui.is_enabled() && just_opened(ui, "keyword-set") {
|
||||
r.response.request_focus();
|
||||
}
|
||||
let mut returned = r.ending == Some(crate::text_field::Ending::Return);
|
||||
@@ -2440,8 +2442,10 @@ fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -
|
||||
fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool {
|
||||
let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui);
|
||||
// (and takes it back when nothing has it: after a click on the dialog's background or a check box,
|
||||
// or when Return gave it up on a refusal; a control Tab reached keeps it)
|
||||
if just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none())) {
|
||||
// or when Return gave it up on a refusal; a control Tab reached keeps it). A disabled dialog (a
|
||||
// prompt or the blocking library window owns input) must not request the focus at all: egui's
|
||||
// text edit would otherwise keep editing the draft from host text behind that window.
|
||||
if ui.is_enabled() && (just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none()))) {
|
||||
r.response.request_focus();
|
||||
}
|
||||
r.ending == Some(crate::text_field::Ending::Return)
|
||||
|
||||
@@ -15,6 +15,9 @@ use crate::widgets::register;
|
||||
const SEARCH: &str = "shortcuts-search";
|
||||
const SHOW_ALL: &str = "shortcuts-show-all";
|
||||
|
||||
/// Draw the Keyboard Shortcuts editor: search and "show all", Reset All, one row per bindable
|
||||
/// command that has a shortcut (all bindable commands when "Show commands without a shortcut" is
|
||||
/// on), and the fixed non-command keys. Pending shortcut capture is processed once per frame.
|
||||
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
capture(app, ui.ctx());
|
||||
@@ -79,6 +82,9 @@ fn menu_text(sc: &str, mac: bool) -> String {
|
||||
crate::menubar::shortcut_text(sc, mac)
|
||||
}
|
||||
|
||||
/// One editable command row: its label and id, a click-to-record shortcut button, and buttons
|
||||
/// that clear or restore it. The restore button is enabled when a saved binding exists or the
|
||||
/// declared default is currently yielded to another command.
|
||||
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) {
|
||||
// a declared key that yields to another command's saved key counts as changed: ↺ takes it back
|
||||
let changed = app.ui.settings.keymap.contains_key(b.id) || (sc.is_none() && b.default.is_some());
|
||||
|
||||
@@ -639,6 +639,8 @@ fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the My Photos sidebar: title and open-library name, the scrollable sources, albums,
|
||||
/// folders and sections, with the section visibility menu on the title.
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
|
||||
|
||||
@@ -30,6 +30,7 @@ const DIM_ID: &str = "library-problem-dim";
|
||||
const ERROR_WINDOW_ID: &str = "library-problem";
|
||||
const CHOOSER_WINDOW_ID: &str = "library-chooser";
|
||||
|
||||
/// The named `Foreground` layer used by the dim and the two blocking library windows.
|
||||
fn foreground_layer(id: &str) -> egui::LayerId {
|
||||
egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(id))
|
||||
}
|
||||
@@ -110,6 +111,8 @@ impl WindowBarrier {
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking library window that is present (not dismissed), if any: the launch chooser when
|
||||
/// it is shown, otherwise the error window.
|
||||
fn present_mode(app: &LightcraftApp) -> Option<WindowMode> {
|
||||
let problem = app.library_problem.as_ref().filter(|p| !p.dismissed)?;
|
||||
Some(if problem.chooser.is_some() { WindowMode::Chooser } else { WindowMode::Error })
|
||||
@@ -155,6 +158,8 @@ fn note_first_draw(app: &mut LightcraftApp, mode: WindowMode) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Surrender egui's current keyboard focus, so a background field cannot receive text in the
|
||||
/// window's first shielded pass.
|
||||
fn clear_focus(ctx: &egui::Context) {
|
||||
ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
|
||||
@@ -129,6 +129,8 @@ struct PromptKey {
|
||||
queue_len: usize,
|
||||
}
|
||||
|
||||
/// Identity of the prompt currently owning input: its layer, text and, for the settings-notice
|
||||
/// queue, the number of notices remaining, including the one shown. `None` when no prompt is up.
|
||||
fn prompt_key(app: &LightcraftApp) -> Option<PromptKey> {
|
||||
let (layer, text, queue_len) = match (&app.quit_prompt, app.notices.first()) {
|
||||
(Some(prompt), _) => (prompt_layer(app)?, prompt.text().to_string(), 0),
|
||||
@@ -168,6 +170,8 @@ pub(crate) struct PromptInput {
|
||||
}
|
||||
|
||||
impl PromptInput {
|
||||
/// Reset the prompt input state for `key`, discarding layout, focus, button ids and pending
|
||||
/// activation. A present key arms the shield transition; `None` leaves input unowned.
|
||||
fn reset_to(&mut self, key: Option<PromptKey>) {
|
||||
let transition = key.is_some();
|
||||
self.key = key;
|
||||
@@ -181,11 +185,13 @@ impl PromptInput {
|
||||
self.pointer = None;
|
||||
}
|
||||
|
||||
/// The index of the prompt button whose real egui id matches AccessKit's `node`, if any.
|
||||
fn button_index(&self, accesskit_node: egui::accesskit::NodeId) -> Option<usize> {
|
||||
self.button_ids.iter().position(|(_, id)| id.accesskit_id() == accesskit_node)
|
||||
}
|
||||
}
|
||||
|
||||
/// Surrender egui's current keyboard focus before a chosen prompt button disappears.
|
||||
fn clear_prompt_focus(ctx: &egui::Context) {
|
||||
ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
@@ -194,6 +200,8 @@ fn clear_prompt_focus(ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a key press belongs to the prompt: Tab (optionally with Shift) or unmodified Enter,
|
||||
/// Space or Escape.
|
||||
fn prompt_key_allowed(key: egui::Key, modifiers: egui::Modifiers) -> bool {
|
||||
match key {
|
||||
egui::Key::Tab => modifiers.is_none() || modifiers.shift_only(),
|
||||
@@ -202,6 +210,8 @@ fn prompt_key_allowed(key: egui::Key, modifiers: egui::Modifiers) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an AccessKit action targets a prompt button on the root tree and is a Focus or Click
|
||||
/// request.
|
||||
fn prompt_accesskit_allowed(request: &egui::accesskit::ActionRequest, ids: &[(String, egui::Id)]) -> bool {
|
||||
request.target_tree == egui::accesskit::TreeId::ROOT
|
||||
&& ids.iter().any(|(_, id)| id.accesskit_id() == request.target_node)
|
||||
@@ -426,6 +436,9 @@ pub(crate) fn finish_ui_pass(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw one modal prompt and return the index of the chosen button. The dim takes the modal
|
||||
/// layer and a sizing pass is discarded so the first interactive pass has settled geometry;
|
||||
/// `input` keeps focus on the safe Cancel/OK button unless a keyboard or AccessKit choice arrives.
|
||||
fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)], input: &mut PromptInput) -> Option<usize> {
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
@@ -505,6 +518,9 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Draw the prompt owning input, if any, and act on its answer: quit-while-tasks cancels jobs
|
||||
/// and re-checks for unsaved changes; quit-with-unsaved retries, quits or cancels; a settings
|
||||
/// notice is removed when acknowledged. Returns without drawing when no prompt is up.
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() {
|
||||
let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)];
|
||||
|
||||
@@ -6,6 +6,9 @@ use egui::{Color32, Rect, pos2, vec2};
|
||||
use crate::LightcraftApp;
|
||||
use crate::render::Slot;
|
||||
|
||||
/// Show the optional second window: an immediate viewport titled "LightCraft — Second Window"
|
||||
/// whose body draws the active photo fitted. Closing a real window turns the option off;
|
||||
/// embedded web and headless hosts get a floating window instead.
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.second_window {
|
||||
return;
|
||||
@@ -22,6 +25,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the second window's content: register `view:secondWindow`, request a loupe render sized
|
||||
/// for the window (each edge clamped to 64 through the texture-side limit) and paint the active
|
||||
/// photo fitted, or "No photo selected".
|
||||
fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let area = ui.available_rect_before_wrap();
|
||||
crate::widgets::register(ui, "view:secondWindow", area);
|
||||
|
||||
@@ -45,6 +45,9 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect:
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the top bar: the titlebar drag area, the in-window menus when there is no native menu
|
||||
/// bar, sidebar and back/forward buttons, the centred search field, the filter button with its
|
||||
/// active count, and the right-side action icons.
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// room for the traffic lights; in full screen they are gone, and the sidebar button sits as far
|
||||
|
||||
@@ -363,6 +363,9 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Dispatch the global shortcut pass. It yields while a dialog, prompt, blocking library window,
|
||||
/// text field, name edit or shortcut recording owns input, then fires saved command bindings,
|
||||
/// aliases and fixed photo keys, respecting native menu ownership and open popups.
|
||||
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
|
||||
app.recording_shortcut = None;
|
||||
|
||||
@@ -730,3 +730,85 @@ fn clipboard_pastes_into_the_launch_lists_own_text_prompt() {
|
||||
};
|
||||
assert!(value.contains("pasted-name"), "the paste reached the chooser's own text field: {value:?}");
|
||||
}
|
||||
|
||||
/// The blocking library window owns input while it is on screen, including over an unrelated
|
||||
/// dialog it did not open. Disabling only the dialog widgets that handle pointer input is not
|
||||
/// enough: `dialog_field` must not take the focus back, or egui's text edit keeps consuming host
|
||||
/// text into the draft behind the window. Several settled barrier frames must stay inert, and the
|
||||
/// same field must accept typing again once the window is gone.
|
||||
#[test]
|
||||
fn a_settled_library_window_keeps_a_background_dialogs_text_draft_unchanged() {
|
||||
let mut h = Headless::new(
|
||||
LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }),
|
||||
[1200.0, 760.0],
|
||||
1.0,
|
||||
);
|
||||
h.settle(T);
|
||||
h.app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: false });
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.view.ctx.text_edit_focused(), "the dialog's field takes the focus while it is the top modal");
|
||||
h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error"));
|
||||
assert!(h.settle(T), "the library window settles over the dialog");
|
||||
assert!(has(&h, "button:libraryRetry"), "the library window is laid out");
|
||||
assert!(crate::panels::library_problem::blocks_dialog_input(&h.app), "the library window owns input");
|
||||
assert!(!h.view.ctx.text_edit_focused(), "the background dialog's field must not regain the focus behind the library window");
|
||||
for text in ["leak", "ed", "-draft"] {
|
||||
h.events.push(egui::Event::Text(text.into()));
|
||||
h.step();
|
||||
let draft = match &h.app.ui.dialog {
|
||||
Some(crate::state::Dialog::NewAlbum { name, .. }) => name.as_str(),
|
||||
other => panic!("the background New Album dialog is still open: {other:?}"),
|
||||
};
|
||||
assert_eq!(draft, "Trip", "host text on a settled library-barrier frame must not mutate the dialog draft");
|
||||
}
|
||||
assert!(!h.view.ctx.text_edit_focused(), "several settled barrier frames still leave the field unfocused");
|
||||
// Positive control: with the library window gone, the same field takes focus and typing again.
|
||||
h.app.library_problem = None;
|
||||
assert!(h.settle(T), "the dialog settles once the library window is gone");
|
||||
assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "there is no barrier now");
|
||||
assert!(h.view.ctx.text_edit_focused(), "the dialog's field is usable again");
|
||||
h.events.push(egui::Event::Text("Holiday".into()));
|
||||
h.step();
|
||||
let draft = match &h.app.ui.dialog {
|
||||
Some(crate::state::Dialog::NewAlbum { name, .. }) => name.as_str(),
|
||||
other => panic!("the New Album dialog is still open: {other:?}"),
|
||||
};
|
||||
assert_eq!(draft, "Holiday", "typing reaches the dialog once the library barrier is gone");
|
||||
}
|
||||
|
||||
/// The launch list's own New Library name prompt is not a background dialog: `visible_mode` is
|
||||
/// None while it is open, so the dialog must stay enabled and `dialog_field` must keep giving it
|
||||
/// the focus and accept host text.
|
||||
#[test]
|
||||
fn typing_reaches_the_launch_lists_own_new_library_prompt() {
|
||||
let settings = crate::state::AppSettings {
|
||||
recent_libraries: vec!["/no/such/library-one".into()],
|
||||
library_path: "/no/such/library-one".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = Headless::new(
|
||||
LightcraftApp::new(
|
||||
lightcraft_engine::Session::with_demo(),
|
||||
Services { png: None, pick_folder: Some(Box::new(|| -> Option<String> { None })), ..Default::default() },
|
||||
),
|
||||
[1200.0, 760.0],
|
||||
1.0,
|
||||
);
|
||||
h.settle(T);
|
||||
h.app.library_problem = Some(LibraryProblem::choose(&settings));
|
||||
h.settle(T);
|
||||
assert!(h.app.run("app.newLibrary", json!({})).is_ok(), "New Library opens its name prompt");
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "the launch list's own prompt is not a background barrier");
|
||||
assert!(h.view.ctx.text_edit_focused(), "the prompt's name field has the focus");
|
||||
h.events.push(egui::Event::Text("Fresh".into()));
|
||||
h.step();
|
||||
let Some(crate::state::Dialog::TextPrompt { value, command, .. }) = &h.app.ui.dialog else {
|
||||
panic!("the New Library name dialog closed unexpectedly");
|
||||
};
|
||||
assert_eq!(command, "app.newLibrary");
|
||||
assert_eq!(value, "Fresh", "typing reaches the launch list's own text prompt");
|
||||
}
|
||||
|
||||
@@ -162,6 +162,12 @@ impl<'a> TextField<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Draw the field and return its response, any ending this frame, and whether an edit is
|
||||
/// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the
|
||||
/// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it
|
||||
/// except when the focus went to the field's own menu (a choice from the menu resumes the
|
||||
/// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also
|
||||
/// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean.
|
||||
pub fn show(self, ui: &mut Ui) -> TextFieldResponse {
|
||||
let id = id(self.widget);
|
||||
let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default();
|
||||
@@ -191,6 +197,16 @@ impl<'a> TextField<'a> {
|
||||
Action::Refocus => {}
|
||||
}
|
||||
}
|
||||
// A field focused by the previous pass (a dialog asks after drawing it) must already
|
||||
// select its text on the pass that carries the first keystroke: egui processes text
|
||||
// inside `ui.add`, before the after-add branch below could select, leaving the new text
|
||||
// inserted into the old. A press may go on to drag a selection, so it keeps the
|
||||
// after-add path; an edit already under way keeps its pre-edit text for Esc.
|
||||
let focused_at_start = ui.memory(|m| m.has_focus(id));
|
||||
if focused_at_start && memo.before.is_none() && self.select_on_focus && !ui.input(|i| i.pointer.any_down()) {
|
||||
memo.before = Some(self.text.clone());
|
||||
select_all(ui.ctx(), id, self.text);
|
||||
}
|
||||
let name = self.label.clone().or_else(|| self.hint.clone());
|
||||
let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) };
|
||||
edit = edit.id(id);
|
||||
@@ -433,6 +449,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
self.frame_with(events, false);
|
||||
}
|
||||
|
||||
/// A frame that asks for the field's focus after drawing it, as a dialog does, so the
|
||||
/// focus takes effect on the next frame.
|
||||
fn frame_with(&mut self, events: Vec<Event>, focus_after: bool) {
|
||||
self.time += 1.0 / 60.0;
|
||||
let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events);
|
||||
raw.focused = self.window_focused;
|
||||
@@ -445,6 +467,9 @@ mod tests {
|
||||
let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui);
|
||||
endings.extend(r.ending);
|
||||
*editing = r.editing;
|
||||
if focus_after {
|
||||
ui.memory_mut(|m| m.request_focus(id(FIELD)));
|
||||
}
|
||||
});
|
||||
self.widgets = crate::widgets::take_registry(&self.view.ctx);
|
||||
}
|
||||
@@ -699,6 +724,25 @@ mod tests {
|
||||
assert_eq!(rig.text, "Kyoto");
|
||||
}
|
||||
|
||||
/// A field focused by the pass before (a dialog asks for focus after drawing it) selects its
|
||||
/// text before egui handles the first keystroke of the pass the focus takes effect, so that
|
||||
/// keystroke replaces the text instead of being inserted into it.
|
||||
#[test]
|
||||
fn text_typed_on_the_first_focused_pass_replaces_the_whole_text() {
|
||||
let mut rig = Rig::new("LightCraft Library");
|
||||
rig.select_on_focus = true;
|
||||
// the dialog draws the field, then asks for its focus: effective on the next pass
|
||||
rig.frame_with(vec![], true);
|
||||
assert!(rig.view.ctx.memory(|m| m.has_focus(id(FIELD))), "focused for the next pass");
|
||||
// the pass the focus takes effect already carries the text
|
||||
rig.frame(vec![Event::Text("Fresh".into())]);
|
||||
assert_eq!(rig.text, "Fresh", "the first keystroke replaced the selected text");
|
||||
// the text from before the edit is still what Esc gives back
|
||||
rig.key(Key::Escape, Modifiers::NONE);
|
||||
assert_eq!(rig.text, "LightCraft Library");
|
||||
assert_eq!(rig.endings, vec![Ending::Escape]);
|
||||
}
|
||||
|
||||
/// Esc after using the menu still gives back the text from before the whole edit.
|
||||
#[test]
|
||||
fn escape_after_the_menu_gives_back_the_original_text() {
|
||||
|
||||
Reference in New Issue
Block a user