fix: load designs off-thread and cache recent thumbnails as PNGs

This commit is contained in:
bherbruck committed 2026-10-10 20:55:14 -04:00
1 parent b375c2a92a
commit 4b7e1d317d
23 files changed
+688 -29

No files matched your search

+11
View File
@@ -127,13 +127,24 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
"frame_ms": app.frame_ms,
"file_busy": app.file_load.busy(),
"build_ms": app.viewport.build_ms,
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
"recent_thumbnails_ready": app.home.ready_recents(),
"documents": app.docs.count(),
"quit_requested": app.quit_requested,
"quitting": app.docs.quitting.is_some(),
})),
"ui.file" => {
let Some(path) = s("path") else { return err("path is required") };
if p.get("insert").and_then(Value::as_bool).unwrap_or(false) {
app.insert_path_async(path);
} else {
app.open_path_async(path);
}
ok(json!({"busy": app.file_load.busy()}))
}
"ui.documents" => ok(crate::documents::control(app, p)),
"ui.home" => {
match p.get("open").and_then(Value::as_bool) {
+2 -2
View File
@@ -91,7 +91,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
};
put_view(app, slot.view);
// Views cache by revision: the incoming design must not look unchanged.
app.session.revision = app.session.revision.max(out_rev) + 1;
app.session.invalidate_view(out_rev);
app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() };
out
}
@@ -141,7 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
crate::dialogs::cancel(app);
app.tool = None;
app.session = session;
app.session.revision = app.session.revision.max(rev) + 1;
app.session.invalidate_view(rev);
app.cam = Camera::default();
app.fit_view();
} else if let Err(e) = open_tab(app, session) {
+261
View File
@@ -0,0 +1,261 @@
//! Interactive file operations run off the window thread. The old design stays intact until
//! the command and display meshes are ready; failures and cancelled jobs never replace it.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU8, Ordering},
mpsc::{Receiver, TryRecvError},
};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use solvecraft_engine::{Session, doc::Document};
use crate::SolveApp;
struct Request {
path: String,
insert: bool,
}
struct Made {
session: Session,
result: Value,
image: Option<egui::ColorImage>,
}
type Job = Result<Made, String>;
struct Running {
request: Request,
base: Arc<Document>,
before: Vec<String>,
rx: Receiver<Job>,
phase: Arc<AtomicU8>,
cancelled: Arc<AtomicBool>,
}
#[derive(Default)]
pub struct FileLoad {
queued: VecDeque<Request>,
running: Option<Running>,
}
impl FileLoad {
pub fn busy(&self) -> bool {
self.running.as_ref().is_some_and(|r| !r.cancelled.load(Ordering::Relaxed)) || !self.queued.is_empty()
}
}
pub fn begin(app: &mut SolveApp, path: &str, insert: bool) {
#[cfg(target_arch = "wasm32")]
{
if insert {
app.insert_path(path);
} else {
app.open_path(path);
}
}
#[cfg(not(target_arch = "wasm32"))]
{
if app.file_load.queued.len() >= crate::documents::MAX_OPEN {
app.set_status("too many queued files", true);
return;
}
app.file_load.queued.push_back(Request { path: path.into(), insert });
start(app);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn start(app: &mut SolveApp) {
if app.file_load.running.is_some() {
return;
}
let Some(request) = app.file_load.queued.pop_front() else { return };
if !request.insert && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) {
app.set_status("too many open designs; close a tab before opening another", true);
return;
}
let before = if request.insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
let mut scratch = if request.insert { app.session.scratch() } else { Session::default() };
if request.insert {
scratch.selection = app.session.selection.clone();
}
let base = app.session.doc.clone();
let phase = Arc::new(AtomicU8::new(0));
let worker_phase = phase.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let worker_cancelled = cancelled.clone();
let (tx, rx) = std::sync::mpsc::channel();
let path = request.path.clone();
let insert = request.insert;
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled)))
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
let _ = tx.send(result);
});
match spawned {
Ok(_) => app.file_load.running = Some(Running { request, base, before, rx, phase, cancelled }),
Err(e) => app.set_status(format!("could not start file operation: {e}"), true),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
let command = if !insert {
"doc.open"
} else if solvecraft_engine::io::is_mesh_path(path) {
"file.insert_mesh"
} else {
"file.insert_step"
};
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
phase.store(1, Ordering::Relaxed);
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &session.world_state().bodies {
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
let _ = b.mesh();
}
phase.store(2, Ordering::Relaxed);
let image = if insert { None } else { crate::home::remember(&session) };
Ok(Made { session, result, image })
}
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() {
Ok(job) => Some(job),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("the file operation stopped; the current design is unchanged".into())),
});
if let Some(job) = result
&& let Some(running) = app.file_load.running.take()
{
match job {
_ if running.cancelled.load(Ordering::Relaxed) => {}
Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => {
if running.request.insert {
app.session.accept_computed(made.session);
app.big_operation = true;
} else {
crate::documents::adopt(app, made.session);
app.home.add_recent(&running.request.path);
crate::home::install(&mut app.home, ctx, &running.request.path, made.image);
}
app.home.open = false;
app.fit_view();
crate::stand_up::offer(app, &running.request.path, &running.before);
let warnings = made.result.get("warnings").and_then(Value::as_array);
app.set_status(
if let Some(w) = warnings.filter(|w| !w.is_empty()) {
format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or(""))
} else {
format!("{} done", if running.request.insert { "Insert" } else { "Open" })
},
false,
);
}
Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true),
Err(e) => app.set_status(e, true),
}
}
#[cfg(not(target_arch = "wasm32"))]
start(app);
if app.file_load.busy() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(r) = &app.file_load.running else { return };
let cancelled = r.cancelled.load(Ordering::Relaxed);
if cancelled && app.file_load.queued.is_empty() {
return;
}
let name = std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy()).unwrap_or_default();
let phase = r.phase.load(Ordering::Relaxed);
let mut cancel = false;
egui::Modal::new(egui::Id::new("file_loading")).show(ctx, |ui| {
ui.heading(if cancelled {
"Waiting for file operation"
} else if r.request.insert {
"Inserting geometry"
} else {
"Opening design"
});
ui.label(name.as_ref());
ui.horizontal(|ui| {
ui.spinner();
ui.label(if cancelled {
"Waiting for the previous operation to stop..."
} else {
match phase {
1 => "Preparing the view...",
2 => "Preparing the thumbnail...",
_ => "Reading and building geometry...",
}
});
});
cancel = ui.button("Cancel").clicked();
});
if cancel {
// Keep the running job until it exits: cancelling must not allow another large
// import to overlap it in memory. The user can continue editing the current design.
if let Some(r) = &app.file_load.running {
r.cancelled.store(true, Ordering::Relaxed);
}
app.file_load.queued.clear();
app.set_status("file operation cancelled", false);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_import_keeps_the_current_document() {
let mut app = SolveApp::new(Session::default(), Default::default());
let old = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Err("bad STEP".into())).unwrap();
app.file_load.running = Some(Running {
request: Request { path: "bad.step".into(), insert: true },
base: old.clone(),
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled: Arc::new(AtomicBool::new(false)),
});
tick(&mut app, &egui::Context::default());
assert!(Arc::ptr_eq(&app.session.doc, &old));
assert!(!app.file_load.busy());
assert!(app.status.as_ref().unwrap().0.contains("bad STEP"));
}
#[test]
fn cancelling_discards_a_finished_result_and_keeps_the_worker_slot_until_completion() {
let mut app = SolveApp::new(Session::default(), Default::default());
let old = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(true));
app.file_load.running = Some(Running {
request: Request { path: "cancelled.step".into(), insert: true },
base: old.clone(),
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
cancelled,
});
assert!(!app.file_load.busy());
assert!(app.file_load.running.is_some());
let mut computed = Session::default();
computed.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap();
tx.send(Ok(Made { session: computed, result: Value::Null, image: None })).unwrap();
tick(&mut app, &egui::Context::default());
assert!(app.file_load.running.is_none());
assert!(Arc::ptr_eq(&old, &app.session.doc));
}
}
+164 -5
View File
@@ -31,11 +31,17 @@ pub struct HomeState {
queued: Vec<String>,
#[cfg(not(target_arch = "wasm32"))]
rx: Option<std::sync::mpsc::Receiver<Made>>,
#[cfg(not(target_arch = "wasm32"))]
remembering: Option<std::sync::mpsc::Receiver<Made>>,
/// A sample picked before its design was ready.
wanted: Option<usize>,
}
impl HomeState {
pub fn ready_recents(&self) -> usize {
self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count()
}
pub fn show(&mut self, _rev: u64) {
self.open = true;
}
@@ -67,8 +73,104 @@ pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
(c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba))
}
/// File identity, not just its path: saving or replacing a design invalidates its picture.
#[cfg(not(target_arch = "wasm32"))]
fn cache_path(key: &str) -> Option<std::path::PathBuf> {
use std::hash::{Hash, Hasher};
let meta = std::fs::metadata(key).ok()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
meta.len().hash(&mut h);
meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.hash(&mut h);
let root = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("XDG_CACHE_HOME"))
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
Some(root.join("SolveCraft").join("thumbnails").join(format!("{:016x}.png", h.finish())))
}
#[cfg(not(target_arch = "wasm32"))]
fn cached(key: &str) -> Option<egui::ColorImage> {
let path = cache_path(key)?;
if std::fs::metadata(&path).ok()?.len() > 512 * 1024 {
return None;
}
let bytes = std::fs::read(path).ok()?;
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(THUMB[0] as u32);
limits.max_image_height = Some(THUMB[1] as u32);
limits.max_alloc = Some((THUMB[0] * THUMB[1] * 8) as u64);
reader.limits(limits);
let image = reader.decode().ok()?.to_rgba8();
(image.width() as usize == THUMB[0] && image.height() as usize == THUMB[1])
.then(|| egui::ColorImage::from_rgba_unmultiplied(THUMB, image.as_raw()))
}
/// Render from the model already built by Open/Save, rather than evaluating its file again.
pub(crate) fn remember(s: &Session) -> Option<egui::ColorImage> {
#[cfg(not(target_arch = "wasm32"))]
let identity = s.path.as_deref().and_then(cache_path);
remember_at(
s,
#[cfg(not(target_arch = "wasm32"))]
identity,
)
}
fn remember_at(s: &Session, #[cfg(not(target_arch = "wasm32"))] identity: Option<std::path::PathBuf>) -> Option<egui::ColorImage> {
let img = thumbnail(s)?;
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = identity.filter(|p| s.path.as_deref().and_then(cache_path).as_ref() == Some(p)) {
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
if let Some(bytes) = solvecraft_engine::render::png(THUMB[0], THUMB[1], rgba)
&& let Some(dir) = path.parent()
&& std::fs::create_dir_all(dir).is_ok()
{
let _ = solvecraft_engine::io::write_atomic(&path, &bytes, false);
}
}
Some(img)
}
pub(crate) fn remember_session(h: &mut HomeState, s: &Session) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = &s.path {
let identity = cache_path(path);
let mut snapshot = s.scratch();
snapshot.path = Some(path.clone());
let key = path.clone();
let (tx, rx) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("saved-thumbnail".into())
.spawn(move || {
let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, identity))).ok().flatten();
let _ = tx.send((key, img, None));
})
.is_ok()
{
h.remembering = Some(rx);
h.queued.retain(|k| k != path);
h.thumbs.insert(path.clone(), None);
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (h, s);
}
}
pub(crate) fn install(h: &mut HomeState, ctx: &egui::Context, key: &str, img: Option<egui::ColorImage>) {
let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR));
h.thumbs.insert(key.to_string(), tex);
}
/// Build what a key stands for (runs off the UI thread where threads exist).
fn make(key: &str) -> Made {
#[cfg(not(target_arch = "wasm32"))]
if let Some(img) = cached(key) {
return (key.to_string(), Some(img), None);
}
let mut s = Session::default();
let built = match key.strip_prefix("sample:").and_then(|i| i.parse::<usize>().ok()) {
Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| {
@@ -83,7 +185,7 @@ fn make(key: &str) -> Made {
if !built {
return (key.to_string(), None, None);
}
let img = thumbnail(&s);
let img = remember(&s);
(key.to_string(), img, key.starts_with("sample:").then_some(s))
}
@@ -92,7 +194,18 @@ fn start_jobs(h: &mut HomeState) {
if h.rx.is_some() || h.queued.is_empty() {
return;
}
let keys = std::mem::take(&mut h.queued);
let mut keys = std::mem::take(&mut h.queued);
// Cached recents first, then cold recents, then samples. A sample must never hold up
// pictures of the user's work; a cold file must not hold up already cached pictures.
keys.sort_by_key(|k| {
if k.starts_with("sample:") {
2
} else if cache_path(k).is_some_and(|p| p.exists()) {
0
} else {
1
}
});
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || {
for k in keys {
@@ -112,12 +225,31 @@ fn start_jobs(h: &mut HomeState) {
fn poll(app: &mut SolveApp, ctx: &egui::Context) {
let mut done: Vec<Made> = Vec::new();
#[cfg(not(target_arch = "wasm32"))]
let mut remembered = None;
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(rx) = &app.home.remembering {
match rx.try_recv() {
Ok(m) => {
remembered = Some(m);
app.home.remembering = None;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => app.home.remembering = None,
Err(std::sync::mpsc::TryRecvError::Empty) => {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
}
start_jobs(&mut app.home);
if let Some(rx) = &app.home.rx {
loop {
match rx.try_recv() {
Ok(m) => done.push(m),
Ok(m) => {
// A cold job started before a save/open cannot overwrite its fresh picture.
if m.0.starts_with("sample:") || !app.home.thumbs.get(&m.0).is_some_and(Option::is_some) {
done.push(m);
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.home.rx = None;
@@ -126,6 +258,9 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) {
}
}
}
if let Some(m) = remembered {
done.push(m);
}
if app.home.rx.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
@@ -185,7 +320,7 @@ pub fn open_recent(app: &mut SolveApp, path: &str) {
return;
}
app.home.open = false;
app.open_path(path);
app.open_path_async(path);
}
/// The page itself (in place of the toolbar, browser and viewport).
@@ -250,7 +385,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
action = Some(Box::new(|app: &mut SolveApp| {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.home.open = false;
app.open_path(&p);
app.open_path_async(&p);
}
}));
}
@@ -416,6 +551,30 @@ fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::
mod tests {
use super::*;
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("solvecraft-thumb-{}-{nonce}.solvecraft", std::process::id()));
// Deliberately invalid design: a cached recent must not parse/evaluate it.
let bytes = b"{\"thumbnail_test\":true}";
std::fs::write(&path, bytes).unwrap();
let key = path.to_string_lossy().to_string();
let mut s = Session::default();
s.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap();
s.path = Some(key.clone());
let image = remember(&s).unwrap();
let png = cache_path(&key).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let (_, cached_image, session) = make(&key);
assert!(session.is_none());
assert_eq!(cached_image.unwrap().pixels, image.pixels);
std::fs::write(&path, b"changed file identity").unwrap();
assert!(cached(&key).is_none());
std::fs::remove_file(path).unwrap();
std::fs::remove_file(png).unwrap();
}
#[test]
fn recent_list_is_newest_first_and_bounded() {
let mut h = HomeState::default();
+21 -3
View File
@@ -54,6 +54,7 @@ mod status_tests {
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
}
}
mod file_load;
pub mod palette;
pub mod params_dialog;
pub mod prefs;
@@ -230,6 +231,7 @@ pub struct SolveApp {
fitted: bool,
/// The start page.
pub home: home::HomeState,
pub file_load: file_load::FileLoad,
/// The other open designs (tabs).
pub docs: documents::Documents,
}
@@ -281,6 +283,7 @@ impl SolveApp {
styled: false,
fitted: false,
home: home::HomeState::default(),
file_load: file_load::FileLoad::default(),
docs: documents::Documents::default(),
}
}
@@ -388,6 +391,9 @@ impl SolveApp {
/// Run a command programmatically (JSON parameters, no dialogs).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if self.file_load.busy() {
return Err("a file operation is in progress".into());
}
let t0 = now_ms();
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
@@ -406,6 +412,9 @@ impl SolveApp {
&& let Some(p) = self.session.path.clone()
{
self.home.add_recent(&p);
if matches!(id, "file.save" | "file.save_as") {
home::remember_session(&mut self.home, &self.session);
}
}
}
Err(e) => {
@@ -441,7 +450,7 @@ impl SolveApp {
}
if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
self.insert_path_async(&p);
}
return;
}
@@ -622,6 +631,13 @@ impl SolveApp {
}
}
pub fn open_path_async(&mut self, path: &str) {
file_load::begin(self, path, false);
}
pub fn insert_path_async(&mut self, path: &str) {
file_load::begin(self, path, true);
}
/// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the
/// current design.
pub fn insert_path(&mut self, path: &str) {
@@ -641,9 +657,9 @@ impl SolveApp {
pub fn drop_path(&mut self, path: &str) {
let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path);
if import && !self.session.doc.features.is_empty() {
self.insert_path(path);
self.insert_path_async(path);
} else {
self.open_path(path);
self.open_path_async(path);
}
}
@@ -733,6 +749,7 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
file_load::tick(self, ctx);
self.esc_handled = false;
prefs::apply(self, ctx);
if !self.styled || theme::is_dark() != self.ui.dark {
@@ -798,6 +815,7 @@ impl SolveApp {
let t0 = now_ms();
scenario::clear_handles();
toolbar::app_bar(self, ui);
file_load::show(self, ui.ctx());
documents::prompt(self, ui.ctx());
if self.home.open {
home::show(self, ui);
+1 -1
View File
@@ -865,7 +865,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
}
}
}
"quit_requested" | "quitting" => {
"file_busy" | "quit_requested" | "quitting" => {
if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k]));
}
+5 -2
View File
@@ -161,7 +161,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
if item(ui, "Open…", "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
app.open_path_async(&p);
}
close = true;
}
@@ -531,6 +531,9 @@ fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
/// Global keyboard shortcuts (when no text field has focus).
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
if app.file_load.busy() {
return;
}
// The Keyboard Shortcuts window is waiting for a key: it is not a shortcut.
if crate::keymap::capturing(app) {
return;
@@ -554,7 +557,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
Key::Comma => crate::prefs::open_at(app, 0),
Key::O => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path(&p);
app.open_path_async(&p);
}
}
_ => {