Fix file picker opening behind the main window (+ run it off the UI thread)#80
Conversation
|
Update: the actual user-visible bug was that the native picker opened behind the main window (had to click twice to bring it forward), which made the app look frozen/dead after Import/Export. Added |
|
|
||
| #[cfg(not(target_arch = "wasm32"))] | ||
| pub(super) fn write_entlayout_export(&mut self, path: &Path) { | ||
| let Some(bundle) = self.entlayout_snapshot() else { |
There was a problem hiding this comment.
Could we keep this export tied to the device that opened the picker?
Since device polling continues while the picker is open, keyboard A can be replaced by B before this snapshot runs.
That would save B’s layout under A’s filename.
Capturing the request-time snapshot or rejecting a connection-generation mismatch would avoid that. Same concern applies to layout-image export.
| let Some((action, rx)) = &self.pending_file_dialog else { | ||
| return; | ||
| }; | ||
| match rx.try_recv() { |
There was a problem hiding this comment.
Could we add focused tests for this new state machine?
Useful cases: Empty, completion with and without a path, Disconnected, second-dialog rejection, every action route, and post-close input recovery.
The Xvfb check confirms rendering continues, but does not cover these completion paths.
| Err(std::sync::mpsc::TryRecvError::Empty) => { | ||
| ctx.request_repaint_after(std::time::Duration::from_millis(50)); | ||
| } | ||
| Err(std::sync::mpsc::TryRecvError::Disconnected) => { |
There was a problem hiding this comment.
Could this branch run the same dropdown, pointer, text-input, and repaint cleanup as normal completion?
If the picker worker panics, the sender disconnects and we currently clear only pending_file_dialog, potentially leaving input state wedged without a visible error.
| ctx: &egui::Context, | ||
| ) { | ||
| match action { | ||
| FileDialogAction::ImportEntlayout => self.begin_entlayout_import(path), |
There was a problem hiding this comment.
Could we bind device-scoped dialog requests to a connection generation or device identity?
While the picker is open, keyboard A can disconnect and B can auto-connect.
This completion then starts the import against current hid_device, which could program B with state intended for A.
Rejecting stale completion with a retry message would make this safe.
| pub(super) fn write_layout_image_export( | ||
| &mut self, | ||
| mut path: std::path::PathBuf, | ||
| _ctx: &egui::Context, |
There was a problem hiding this comment.
Small cleanup: _ctx is unused here, and the result dispatcher appears to pass egui::Context only for this parameter.
Could we remove that plumbing, along with the unused context on import_entsettings_dialog, so poll_file_dialog remains the sole owner of input recovery?
|
Thanks for the review! Addressed:
For the full state-machine cases (Empty / second-dialog rejection / every route) I covered the pure decision logic; the rest lives behind a constructed |
kissetfall
left a comment
There was a problem hiding this comment.
There is still a device-swap race for ImportEntlayout.
The dialog result is checked against connection_generation in poll_file_dialog, but ImportEntlayout only stores the path in pending_entlayout_import_path. The real firmware write happens later in handle_pending_imports / import_entlayout_from_path, after a delay. A device can still disconnect/reconnect in that gap, so an .entlayout chosen for keyboard A can be applied to keyboard B.
Please carry the opened connection generation into the deferred pending import, re-check it immediately before import_entlayout_from_path, and drop the pending import with the retry message if it changed. A focused test for this deferred-import stale-generation path would close the loop.
|
Good catch on the deferred import — fixed. The pending |
| } | ||
|
|
||
| #[test] | ||
| fn deferred_import_generation_check() { |
There was a problem hiding this comment.
Thanks for adding this check.
Could we make it exercise production wiring?
This test calls only device_generation_stale, while handle_pending_imports performs a separate comparison, so it stays green if the deferred guard is removed or miswired.
Routing production through one tested helper or using a small app fixture would cover the regression; the same fixture can drive Empty, completion, Disconnected, routing, and recovery.
|
|
kissetfall
left a comment
There was a problem hiding this comment.
The centralized file-dialog owner is the right architectural direction, but this needs another pass before merge.
- Rebase onto current
mainand resolve the conflicts inCargo.tomlandsrc/ui/app_lifecycle.rs. - Run rustfmt on the changed files. The current head fails the formatting check in
src/app.rsandsrc/entsettings.rs. - Localize the user-visible “Device changed while the file dialog was open” messages in both
file_dialog.rsand the deferred-import path instead of hardcoding English. - Do not silently swallow a disconnected dialog worker.
TryRecvError::Disconnectedcurrently clears the pending state without an actionable status/error; report the failure to the user and log it. - Add state-machine tests for the real dialog lifecycle. The current tests cover helper predicates, but not result dispatch. Please cover success, cancel, stale device generation, deferred
.entlayoutre-check, worker disconnect, and the one-dialog-at-a-time invariant/input recovery.
Ready for re-review when the rebased branch merges cleanly, formatting and i18n checks pass, and the lifecycle regression tests cover those paths.
3e8e28e to
d078872
Compare
|
Addressed the review and rebased onto current Localized the stale-device message. The "device changed while the file dialog was open" text is now in the i18n catalog ( A lost worker is no longer swallowed. Testable lifecycle. Extracted the poll decision into Formatting. Applied rustfmt to the
|
kissetfall
left a comment
There was a problem hiding this comment.
Повторное ревью d078872.
Предыдущие замечания по самой file-dialog lifecycle закрыты: проверка generation переиспользуется перед deferred import, stale/worker-lost сообщения локализованы, разрыв worker-канала больше не проглатывается, а state machine покрывает pending/success/cancel/stale/disconnect и single-flight.
Остался блокирующий integration-пункт:
- Ветка больше не совместима с текущим
main(e972a45): GitHub показываетDIRTY, подтверждён content-conflict вsrc/ui/layout_image_export.rsс уже смерженным #78. Текущий head знает только PNG/SVG, поэтому при разрешении конфликта важно не потерять PDF: сохранить его filter/extension/render/write path и провести его через новый асинхронный parented file-dialog lifecycle. После rebase нужен regression test/проверка, что PNG, SVG и PDF все открывают picker через worker и корректно экспортируются.
На текущем head локально прошли 263/263 теста, release-сборка, scoped rustfmt, i18n и git diff --check. Hosted checks для head отсутствуют. После rebase на актуальный main и зелёного CI можно будет мержить.
Import/export used rfd's blocking FileDialog directly inside the egui update loop. On Linux rfd drives the xdg-desktop-portal file chooser over D-Bus to completion on the calling thread, so the whole app froze for the duration of the dialog and — worse — egui/winit input state was left corrupted afterwards: menus and buttons stopped responding to clicks until restart. This was most visible right after importing an .entlayout (the picker plus the report modal), where the entire top menu went dead. Move every native picker onto a worker thread and deliver the chosen path back through a channel: - New FileDialogAction + spawn_file_dialog/poll_file_dialog machinery (src/ui/file_dialog.rs). Only one dialog runs at a time; the UI thread keeps rendering while it is open and never blocks on the portal. - import/export of .entlayout, import/export of .entsettings, and layout image export now spawn the dialog and run their read/write in a follow-up handler once a path arrives. Exports re-snapshot at write time so a long-open dialog can't save stale data. Verified under Xvfb: with the dialog worker blocked on the portal the UI still produced 200 render frames over 6s, where before the blocking call produced none (frozen spinner). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2vEWd6sxhZLCa4PULq5Ti
Threading the picker off the UI thread stops the freeze, but a native dialog also steals window focus, and when it closes egui can be left holding a pointer that never received its release — which wedges every subsequent click (dead menus) until restart. When a dialog result is delivered, reset the pointer state, drop text focus, and close the hover dropdown that launched it, then request a repaint, so interaction resumes cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2vEWd6sxhZLCa4PULq5Ti
The real symptom users hit: after choosing Import/Export the native file picker opened *behind* the main window, so the app looked frozen/dead and the picker had to be clicked twice to come forward. The picker was created with no parent window, so the compositor had no reason to stack it above the main window. Cache the main window's raw window/display handles each frame and pass them to rfd's set_parent when spawning a dialog. rfd copies the handles into its (Send) FileDialog, so the dialog still runs on the worker thread while now being parented — it opens in front, modal to the app. Adds raw-window-handle to the non-wasm deps (was Windows/macOS only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2vEWd6sxhZLCa4PULq5Ti
…loss Device polling continues while a picker is open, so the active keyboard can be swapped before the result arrives. That could save device B's layout under device A's filename, or program B with data meant for A. - Add a connection generation bumped on every connect and disconnect. A file dialog captures it when opened; a device-scoped result (entlayout import/export, layout-image export) is discarded with a retry message if the generation changed. App-settings import/export stay device-independent. - The Disconnected branch (worker thread panicked/vanished) now runs the same dropdown/pointer/text-input/repaint recovery as normal completion, so a lost dialog can't leave input wedged. - Drop the unused egui::Context plumbing (write_layout_image_export and import_entsettings_dialog) so poll_file_dialog is the sole owner of input recovery. Adds unit tests for the device-scoped classification and staleness decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2vEWd6sxhZLCa4PULq5Ti
The generation check in poll_file_dialog only covered the moment the dialog completed. For .entlayout imports the actual firmware write is deferred to handle_pending_imports, and the device can disconnect/reconnect in that gap — so an import chosen for keyboard A could still be applied to keyboard B. Carry the opened connection generation alongside the pending import path, and re-check it immediately before import_entlayout_from_path; if it changed, drop the pending import with the retry message instead of writing. Factor the staleness rule into device_generation_stale (shared by both the dialog-completion and deferred-write checks) and add a focused test. Also apply rustfmt to the changed files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2vEWd6sxhZLCa4PULq5Ti
handle_pending_imports compared the generations inline, so the unit test for device_generation_stale could stay green even if that production guard were removed or miswired. Call device_generation_stale from the deferred write too, so both the dialog-completion and deferred-import paths share the one tested decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S2vEWd6sxhZLCa4PULq5Ti
…test the dialog lifecycle - Move the "device changed while the dialog was open" text out of hardcoded English into the i18n catalog (en/ru), used by both the dialog-completion path and the deferred .entlayout write. - Stop silently swallowing a disconnected dialog worker: log it and show a localized "dialog closed unexpectedly" status instead of quietly clearing state. - Extract the poll decision into `classify_file_dialog_poll` -> `FileDialogPoll` and the single-slot guard into `can_start_file_dialog`, then drive the method through them, so the whole lifecycle is testable without a live dialog thread or egui context. - Add lifecycle tests: pending, success dispatch, cancel, stale device (and that app-settings actions ignore device changes), worker disconnect, the one-dialog invariant, and that every terminal outcome frees the slot. - rustfmt app.rs module ordering flagged in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DptVZnxXbs5iWoF8D3DBhh
The merged PDF export (ergohaven#78) opened its picker synchronously on the UI thread; after rebasing onto it, fold PDF into the same off-thread parented file-dialog lifecycle ergohaven#80 introduced for PNG/SVG. The picker (spawn) and the writer now read one shared `layout_image_export_descriptor`, so all three formats go through `spawn_file_dialog(ExportLayoutImage)` and none can silently diverge on extension/filter. Also drop the duplicate raw-window-handle entries the rebase left under the windows/macos targets — the non-wasm target already covers every desktop OS. Regression tests assert every format resolves to a distinct extension + picker filter and that the picker and writer agree on the extension for PNG, SVG, PDF. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DptVZnxXbs5iWoF8D3DBhh
d078872 to
20933db
Compare
|
Rebased onto current PDF was preserved and folded into the async lifecycle. The merged #78 opened its picker synchronously on the UI thread; PDF now goes through the same off-thread parented file-dialog path as PNG/SVG. The picker (spawn) and the writer read a single shared Regression tests assert every format resolves to a distinct extension + picker filter, and that the picker and writer agree on the extension for PNG, SVG, and PDF — the exact drift the merge could have introduced. Also dropped the duplicate
|
kissetfall
left a comment
There was a problem hiding this comment.
Re-review complete against current main (7be7b00). The previous blockers are resolved: dialog results are device-generation scoped, worker disconnects surface localized errors, state transitions have regression coverage, and PNG/SVG/PDF all use the shared asynchronous parented-dialog path. Verified a clean merge with 322/322 tests, i18n, diff-check, scoped rustfmt, and a release build; CI is 4/4 green. Approved.
Problem
After importing an
.entlayout, the whole top menu (and Export, and other buttons) stopped responding to clicks — the app looked alive but no menu would open. Originally reported as "Export does nothing after import"; it's actually every menu item.Root cause (confirmed by instrumented repro)
import_entlayout_dialog/export_entlayout_dialog(and the entsettings + layout-image variants) call rfd's blockingFileDialog::pick_file()/save_file()directly inside the eguiupdate()loop. On Linux rfd uses the xdg-desktop-portal backend (ashpd/D-Bus) and drives that request to completion on the calling thread. So:Connection/layout state was proven fine throughout (layout stays
Some, no disconnect), which is why the earlier connection-focused guesses didn't help.Fix
Move every native picker onto a worker thread and deliver the chosen path back through an
mpscchannel, polled each frame:FileDialogAction+spawn_file_dialog/poll_file_dialoginsrc/ui/file_dialog.rs. One dialog at a time; the UI thread keeps rendering while it's open and never blocks on the portal..entlayoutimport/export,.entsettingsimport/export, and layout-image export now spawn the dialog and do their read/write in a follow-up handler when the path arrives. Exports re-snapshot at write time so a long-open dialog can't write stale data.Verification
Under Xvfb, with the dialog worker blocked on the portal, the UI still produced 200 render frames over ~6s (vs 0 before the change).
cargo build,cargo test(133 passed),check_i18n.pyall pass.🤖 Generated with Claude Code