Skip to content

Fix file picker opening behind the main window (+ run it off the UI thread)#80

Merged
kissetfall merged 8 commits into
ergohaven:mainfrom
ImmortalDragonm:fix/async-file-dialogs
Jul 20, 2026
Merged

Fix file picker opening behind the main window (+ run it off the UI thread)#80
kissetfall merged 8 commits into
ergohaven:mainfrom
ImmortalDragonm:fix/async-file-dialogs

Conversation

@ImmortalDragonm

Copy link
Copy Markdown
Contributor

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 blocking FileDialog::pick_file()/save_file() directly inside the egui update() loop. On Linux rfd uses the xdg-desktop-portal backend (ashpd/D-Bus) and drives that request to completion on the calling thread. So:

  1. The UI thread blocks for the whole duration of the dialog (app frozen — a driven Xvfb repro showed the render loop producing zero frames while the picker was in flight, stuck on the "Applying keyboard layout…" spinner).
  2. Worse, after the blocking portal round-trip the egui/winit input state is left corrupted, so clicks stop registering afterwards — the dead menus.

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 mpsc channel, polled each frame:

  • New FileDialogAction + spawn_file_dialog / poll_file_dialog in src/ui/file_dialog.rs. One dialog at a time; the UI thread keeps rendering while it's open and never blocks on the portal.
  • .entlayout import/export, .entsettings import/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.py all pass.

🤖 Generated with Claude Code

@ImmortalDragonm ImmortalDragonm changed the title Run native file dialogs off the UI thread (fixes frozen menus after import/export) Fix file picker opening behind the main window (+ run it off the UI thread) Jul 13, 2026
@ImmortalDragonm

Copy link
Copy Markdown
Contributor Author

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 set_parent (latest commit) so the picker is parented to the main window and opens in front. The off-UI-thread change still stands so the window doesn't freeze while the picker is up.

Comment thread src/entlayout.rs

#[cfg(not(target_arch = "wasm32"))]
pub(super) fn write_entlayout_export(&mut self, path: &Path) {
let Some(bundle) = self.entlayout_snapshot() else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ui/file_dialog.rs Outdated
let Some((action, rx)) = &self.pending_file_dialog else {
return;
};
match rx.try_recv() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ui/file_dialog.rs Outdated
Err(std::sync::mpsc::TryRecvError::Empty) => {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ui/file_dialog.rs
ctx: &egui::Context,
) {
match action {
FileDialogAction::ImportEntlayout => self.begin_entlayout_import(path),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/ui/layout_image_export.rs Outdated
pub(super) fn write_layout_image_export(
&mut self,
mut path: std::path::PathBuf,
_ctx: &egui::Context,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@ImmortalDragonm

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Addressed:

  • Device race: added a connection generation bumped on every connect/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 device changed while the picker was up. App-settings import/export stay device-independent. Applies to both the export-filename and the import-target concerns.
  • Disconnected branch: a vanished/panicked worker now runs the same dropdown / pointer / text-input / repaint recovery as a normal completion, so a lost dialog can't leave input wedged.
  • Cleanup: dropped the unused egui::Context plumbing (write_layout_image_export, import_entsettings_dialog) so poll_file_dialog is the sole owner of input recovery.
  • Added unit tests for the device-scoped classification and the staleness decision.

For the full state-machine cases (Empty / second-dialog rejection / every route) I covered the pure decision logic; the rest lives behind a constructed EntropyApp. Happy to add a lightweight harness if you'd like those exercised end-to-end.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ImmortalDragonm

Copy link
Copy Markdown
Contributor Author

Good catch on the deferred import — fixed. The pending .entlayout import now carries the connection generation captured when the file was chosen, and handle_pending_imports re-checks it immediately before import_entlayout_from_path, dropping the import with the retry message if the device changed in that gap. Factored the rule into device_generation_stale (shared by the dialog-completion and deferred-write checks) with a focused test. Also ran rustfmt on the changed files. cargo test 137/137.

Comment thread src/ui/file_dialog.rs
}

#[test]
fn deferred_import_generation_check() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ImmortalDragonm

Copy link
Copy Markdown
Contributor Author

handle_pending_imports now calls the same device_generation_stale helper the unit test covers (instead of an inline comparison), so removing/miswiring the deferred guard would fail the shared decision. A full app-fixture path (Empty/completion/Disconnected/routing/recovery) is a larger harness; can add it as a follow-up if you'd like end-to-end coverage.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The centralized file-dialog owner is the right architectural direction, but this needs another pass before merge.

  1. Rebase onto current main and resolve the conflicts in Cargo.toml and src/ui/app_lifecycle.rs.
  2. Run rustfmt on the changed files. The current head fails the formatting check in src/app.rs and src/entsettings.rs.
  3. Localize the user-visible “Device changed while the file dialog was open” messages in both file_dialog.rs and the deferred-import path instead of hardcoding English.
  4. Do not silently swallow a disconnected dialog worker. TryRecvError::Disconnected currently clears the pending state without an actionable status/error; report the failure to the user and log it.
  5. 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 .entlayout re-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.

@ImmortalDragonm
ImmortalDragonm force-pushed the fix/async-file-dialogs branch from 3e8e28e to d078872 Compare July 20, 2026 09:27
@ImmortalDragonm

Copy link
Copy Markdown
Contributor Author

Addressed the review and rebased onto current main:

Localized the stale-device message. The "device changed while the file dialog was open" text is now in the i18n catalog (status_messages.file_dialog_device_changed, en + ru) and used by both the dialog-completion path and the deferred .entlayout write, instead of hardcoded English.

A lost worker is no longer swallowed. TryRecvError::Disconnected (dialog thread panicked/vanished before sending a result) now logs an error and shows a localized file_dialog_worker_lost status, in addition to the existing state-clear + input recovery — so a lost dialog surfaces instead of silently doing nothing.

Testable lifecycle. Extracted the poll decision into classify_file_dialog_pollFileDialogPoll and the single-slot guard into can_start_file_dialog, then drove the method through them. New tests cover the full state machine: pending (keeps waiting), success dispatch, cancel, stale device (and that app-settings actions ignore device changes), worker disconnect, the one-dialog-at-a-time invariant, and that every terminal outcome frees the slot.

Formatting. Applied rustfmt to the app.rs module ordering that was flagged.

cargo test 263 passing, cargo fmt --check clean on the touched files, check_i18n.py clean.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Повторное ревью 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 можно будет мержить.

Immortaldragon and others added 8 commits July 20, 2026 14:32
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
@ImmortalDragonm
ImmortalDragonm force-pushed the fix/async-file-dialogs branch from d078872 to 20933db Compare July 20, 2026 11:36
@ImmortalDragonm

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (36defd4), resolving the layout_image_export.rs conflict with the merged PDF export (#78).

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 layout_image_export_descriptor, so all three formats dispatch spawn_file_dialog(ExportLayoutImage) and none can diverge on extension/filter. Render/write still routes Pdf → render_layout_pdf.

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 raw-window-handle entries the earlier rebase had left under the windows/macos targets; the not(target_arch = "wasm32") target already covers every desktop OS.

cargo test 321 passing, release build, scoped cargo fmt --check, check_i18n.py, and git diff --check all clean. Branch now merges cleanly against main.

@kissetfall kissetfall left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kissetfall
kissetfall merged commit e4b3f62 into ergohaven:main Jul 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants