Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+331
-10
@@ -18,12 +18,14 @@ mod activity;
|
||||
mod collections_ui;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
mod export;
|
||||
mod labels;
|
||||
mod library;
|
||||
mod library_ui;
|
||||
#[cfg(live_style)]
|
||||
mod live_style;
|
||||
mod net_runtime;
|
||||
mod presets;
|
||||
mod settings_store;
|
||||
mod settings_ui;
|
||||
mod trash;
|
||||
@@ -286,6 +288,109 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
|
||||
window.set_crop_h(crop.height);
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-6 | FR-EXP-9
|
||||
/// Render the open image at full resolution and place the result.
|
||||
///
|
||||
/// Synchronous, on the UI thread, and that is a known compromise rather than
|
||||
/// an oversight. A full-resolution render plus a Lanczos reduction plus an
|
||||
/// encode is hundreds of milliseconds on a 24 MP frame, and the window is
|
||||
/// unresponsive for all of it. It is done this way because the alternative —
|
||||
/// moving a `DevelopSession` and its GPU pass onto a worker — is a larger
|
||||
/// change than the button is worth before batch export exists, and a batch is
|
||||
/// what makes the wait intolerable rather than merely noticeable. The status
|
||||
/// line says what is happening in the meantime.
|
||||
fn export_now(
|
||||
window: &AppWindow,
|
||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||
settings: &Rc<settings_ui::SettingsController>,
|
||||
library: &Rc<library_ui::LibraryController>,
|
||||
) -> Result<export::Placed, String> {
|
||||
let mut borrowed = session.borrow_mut();
|
||||
let Some(session) = borrowed.as_mut() else {
|
||||
return Err("nothing is open".into());
|
||||
};
|
||||
|
||||
let stored = settings.snapshot();
|
||||
let frame = session.render_for_export()?;
|
||||
|
||||
// The size has to be resolved before the name, because `{dimensions}` is
|
||||
// one of the tokens the template can carry.
|
||||
let (tw, th) = dr_export::target_size(
|
||||
frame.width,
|
||||
frame.height,
|
||||
stored.export.sizing,
|
||||
stored.export.allow_upscaling,
|
||||
);
|
||||
|
||||
let filename = window.get_filename().to_string();
|
||||
let stem = std::path::Path::new(&filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "export".into());
|
||||
|
||||
let outbox = match library.session() {
|
||||
Some((_, s)) => export::outbox_dir(&s.server, &s.user_id),
|
||||
// No account, so no outbox — a device export still works, and a
|
||||
// remote one is refused below by `place` rather than here, so the
|
||||
// message names the setting rather than the plumbing.
|
||||
None => std::env::temp_dir().join("darkroom-outbox"),
|
||||
};
|
||||
|
||||
let ctx = dr_export::NameContext {
|
||||
source_stem: &stem,
|
||||
sequence: 1,
|
||||
date: "",
|
||||
width: tw,
|
||||
height: th,
|
||||
preset: "",
|
||||
};
|
||||
|
||||
// What counts as "taken" depends on where this is going. A device export
|
||||
// can look at the folder; a queued one is checked against the outbox,
|
||||
// since the server cannot be reached from here and may not be reachable
|
||||
// at all — see `export::stage` for why two queued exports of one name
|
||||
// both survive regardless.
|
||||
let target_dir = std::path::PathBuf::from(&stored.export.destination);
|
||||
let taken = |name: &str| -> bool {
|
||||
match stored.export.target {
|
||||
dr_types::ExportTarget::Device => target_dir.join(name).exists(),
|
||||
dr_types::ExportTarget::Remote => false,
|
||||
}
|
||||
};
|
||||
|
||||
let name = dr_export::resolve_name(
|
||||
&stored.export.filename_template,
|
||||
&ctx,
|
||||
stored.export.format,
|
||||
stored.export.collision,
|
||||
&taken,
|
||||
)
|
||||
.ok_or("a file of that name is already there, and the collision setting is Skip")?;
|
||||
|
||||
let encoded = dr_export::export(&frame, &stored.export, name).map_err(|e| e.to_string())?;
|
||||
|
||||
export::place(
|
||||
&encoded,
|
||||
stored.export.target,
|
||||
&stored.export.destination,
|
||||
&outbox,
|
||||
)
|
||||
}
|
||||
|
||||
/// What the export button should say, given where an export would go.
|
||||
///
|
||||
/// The label carries the destination because the button is the only place the
|
||||
/// distinction is visible from: "Export" alone gives no hint whether the file
|
||||
/// lands on this device or is queued for a server that may be unreachable.
|
||||
fn refresh_export_label(window: &AppWindow, settings: &Rc<settings_ui::SettingsController>) {
|
||||
let stored = settings.snapshot();
|
||||
let label = match stored.export.target {
|
||||
dr_types::ExportTarget::Device => "Export".to_string(),
|
||||
dr_types::ExportTarget::Remote => "Export to Nextcloud".to_string(),
|
||||
};
|
||||
window.set_export_label(label.into());
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
///
|
||||
/// The controls are not self-updating: the core clamps values, so what the
|
||||
@@ -297,7 +402,55 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
|
||||
/// progress. The symptom is a slider that jumps on click but cannot be
|
||||
/// dragged, because each move event destroys the thing that would deliver
|
||||
/// the next one.
|
||||
fn sync_rows(
|
||||
/// TRACES: FR-CAT-8
|
||||
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
|
||||
///
|
||||
/// The sidecar fetch is started beside the image fetch and is three orders of
|
||||
/// magnitude smaller, so it has almost always landed by the time there is a
|
||||
/// session to apply it to — and this takes it straight from the channel. The
|
||||
/// timer covers the case where it has not, which is why this is not simply a
|
||||
/// blocking receive: a slow or stalled sidecar request must not freeze the
|
||||
/// window with the photograph already decoded and on screen.
|
||||
///
|
||||
/// A late arrival redraws, so the image is correct either way; the only
|
||||
/// difference is whether it was ever briefly shown at its defaults.
|
||||
fn apply_when_ready(
|
||||
window: &AppWindow,
|
||||
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||||
) {
|
||||
// Already here — the overwhelmingly common case.
|
||||
if let Ok(got) = rx.try_recv() {
|
||||
if let Some(sidecar) = got {
|
||||
presets::apply_stored_edit(window, &sidecar, session, rows);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let rows = rows.clone();
|
||||
let redraw = redraw.clone();
|
||||
let timer = Rc::new(slint::Timer::default());
|
||||
let held = timer.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(50),
|
||||
move || {
|
||||
let Ok(got) = rx.try_recv() else { return };
|
||||
held.stop();
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(sidecar) = got else { return };
|
||||
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
|
||||
redraw(&w);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn sync_rows(
|
||||
window: &AppWindow,
|
||||
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||
@@ -451,6 +604,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// TRACES: FR-CAT-8
|
||||
// The same knot, for the other direction: leaving develop has to persist
|
||||
// the edit, and the grid's "‹ Library" button is wired before the develop
|
||||
// session exists to save from. Empty until then, and calling it is a no-op
|
||||
// rather than a panic — there is nothing open to lose.
|
||||
#[allow(clippy::type_complexity)]
|
||||
let leave_develop: Rc<RefCell<Option<Rc<dyn Fn()>>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// Before anything binds to a token: the compiled palette is already in
|
||||
// place, so this only overwrites what style.yaml currently says.
|
||||
#[cfg(live_style)]
|
||||
@@ -463,6 +624,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// develop side reads `paths` to rebuild its browsing list when an image is
|
||||
// opened from the grid.
|
||||
let library = library_ui::LibraryController::new(activity.clone());
|
||||
// Hoisted out of the launch block below because the settings clipboard
|
||||
// needs it too: a paste onto "the selection" reads the selection from
|
||||
// here, and that wiring happens once the develop session exists.
|
||||
let collections = collections_ui::CollectionsController::new(activity.clone());
|
||||
|
||||
// Launch screen: shown when there is nothing to display — no local paths
|
||||
// and no configured library. A user who has already signed in and chosen
|
||||
@@ -473,7 +638,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
|
||||
|
||||
let library = library.clone();
|
||||
let collections = collections_ui::CollectionsController::new(activity.clone());
|
||||
let collections = collections.clone();
|
||||
|
||||
// The click handler needs `show`, which is built further down because
|
||||
// it captures the develop session and the GPU context. This cell is
|
||||
@@ -481,13 +646,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// A click before then is a no-op rather than a panic — the grid cannot
|
||||
// be reached until the window is running, by which point it is set.
|
||||
let open_from_library = open_from_library.clone();
|
||||
library_ui::wire(&window, library.clone(), collections.clone(), move |path| {
|
||||
let Some(f) = open_from_library.borrow().clone() else {
|
||||
log::warn!("open requested before the viewer was ready: {path}");
|
||||
return;
|
||||
};
|
||||
f(path);
|
||||
});
|
||||
let leave_develop = leave_develop.clone();
|
||||
library_ui::wire(
|
||||
&window,
|
||||
library.clone(),
|
||||
collections.clone(),
|
||||
move |path| {
|
||||
let Some(f) = open_from_library.borrow().clone() else {
|
||||
log::warn!("open requested before the viewer was ready: {path}");
|
||||
return;
|
||||
};
|
||||
f(path);
|
||||
},
|
||||
Rc::new(move || {
|
||||
if let Some(f) = leave_develop.borrow().clone() {
|
||||
f();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// The collections sidebar shares the library's catalog handle rather
|
||||
// than opening its own: one SQLite connection, so an edit here is
|
||||
@@ -567,14 +743,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// Wired independently of every view above. It reads no library and holds no
|
||||
// session, so it has nothing to be sequenced against — which is the reason
|
||||
// it is a page reachable from anywhere rather than a panel inside one view.
|
||||
// Hoisted out of the block below: the export action needs the same record
|
||||
// the settings page edits, and a controller scoped to the wiring block
|
||||
// would be gone by the time that callback is built.
|
||||
let settings = settings_ui::SettingsController::new();
|
||||
{
|
||||
let settings = settings_ui::SettingsController::new();
|
||||
// What the cache actually holds, so the ceiling above it is a figure
|
||||
// the user can judge rather than an abstract one.
|
||||
settings.set_usage_label(describe_cache_usage(&library));
|
||||
// Rendered once up front so the page is correct the first time it is
|
||||
// opened, rather than on the second open after a callback has run.
|
||||
settings_ui::render(&window, &settings);
|
||||
// The export button carries its destination, so it has to be correct
|
||||
// before the first click rather than after the first settings edit.
|
||||
refresh_export_label(&window, &settings);
|
||||
|
||||
// Apply what is on disk before anything can use it. Without this the
|
||||
// controller's defaults stand until the user happens to open the
|
||||
@@ -595,6 +777,9 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// cache would sit over budget indefinitely.
|
||||
lib.set_cache_budget(s.cache.original_budget_bytes);
|
||||
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
|
||||
if let Some(w) = weak.upgrade() {
|
||||
refresh_export_label(&w, &ctl);
|
||||
}
|
||||
|
||||
// Lowering the ceiling evicts, so the figure beside it has just
|
||||
// changed — leaving the old one would show the cache still over a
|
||||
@@ -627,6 +812,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// The current develop session, if the file yielded sensor data.
|
||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// TRACES: FR-DEV-6 | FR-CAT-8
|
||||
// The settings clipboard, and where the open image's edit is stored.
|
||||
//
|
||||
// Both live for the life of the window rather than the view: a copy is
|
||||
// taken in develop and may be pasted onto a selection back in the grid, so
|
||||
// a clipboard owned by the develop view would be emptied by the very
|
||||
// navigation that carries it to its destination.
|
||||
let clipboard = presets::Clipboard::new();
|
||||
let open_image: presets::OpenImage = Rc::new(RefCell::new(presets::Stored::Nowhere));
|
||||
|
||||
// One model for the lifetime of the window. Rows are mutated in place;
|
||||
// see `sync_rows` for why replacing it breaks dragging.
|
||||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
||||
@@ -793,7 +988,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let redraw = redraw.clone();
|
||||
let gpu = gpu.clone();
|
||||
let rows = rows.clone();
|
||||
let open_image = open_image.clone();
|
||||
let library_for_show = library.clone();
|
||||
Rc::new(move |window: &AppWindow| {
|
||||
// The image about to be replaced is the last chance to persist its
|
||||
// edit — stepping to the next frame is as much a departure as
|
||||
// going back to the grid.
|
||||
presets::save_open_edit(window, &open_image.borrow(), &session, &library_for_show);
|
||||
|
||||
let i = *index.borrow();
|
||||
// Cloned rather than held: `load` below is slow, and keeping the
|
||||
// list borrowed across it would panic the moment anything else
|
||||
@@ -825,6 +1027,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
Some(s) => {
|
||||
window.set_adjust_enabled(true);
|
||||
*session.borrow_mut() = Some(s);
|
||||
|
||||
// A local file stores its edit beside itself. Read
|
||||
// *before* the first render, so an edited
|
||||
// photograph never flashes up at its defaults.
|
||||
*open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf());
|
||||
if let Some(sidecar) = presets::load_local(path) {
|
||||
presets::apply_stored_edit(window, &sidecar, &session, &rows);
|
||||
}
|
||||
|
||||
// Through `sync_rows` rather than setting rows
|
||||
// directly, so the curve's drawn shape is
|
||||
// refreshed by the same path that refreshes the
|
||||
@@ -837,6 +1048,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// the controls rather than offering sliders that
|
||||
// would do nothing.
|
||||
*session.borrow_mut() = None;
|
||||
*open_image.borrow_mut() = presets::Stored::Nowhere;
|
||||
rows.set_vec(Vec::<ParamRow>::new());
|
||||
window.set_adjust_enabled(false);
|
||||
if let Some(image) = l.fallback {
|
||||
@@ -874,9 +1086,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let rows = rows.clone();
|
||||
let gpu = gpu.clone();
|
||||
let activity = activity.clone();
|
||||
let open_image = open_image.clone();
|
||||
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
// Whatever was open before is being replaced; persist its edit
|
||||
// before the identity below is overwritten.
|
||||
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
|
||||
|
||||
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
reset_view_state(&w);
|
||||
w.set_filename(name.clone().into());
|
||||
@@ -894,6 +1111,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
return;
|
||||
};
|
||||
|
||||
// TRACES: FR-CAT-8 | FR-NC-8
|
||||
// Where this image's edit belongs. The uuid comes from the catalog
|
||||
// so every device names the same version; without one there is
|
||||
// nowhere to save to, and the image opens read-only as far as
|
||||
// persistence is concerned rather than writing to an invented
|
||||
// identity that would never merge.
|
||||
*open_image.borrow_mut() = match library.version_uuid_for_path(&path) {
|
||||
Some(version_uuid) => presets::Stored::Remote {
|
||||
path: path.clone(),
|
||||
version_uuid,
|
||||
},
|
||||
None => {
|
||||
log::debug!("no catalog version for {path}; edits will not persist");
|
||||
presets::Stored::Nowhere
|
||||
}
|
||||
};
|
||||
|
||||
// The sidecar is fetched alongside the image rather than after it.
|
||||
// It is a few kilobytes against tens of megabytes, so it costs
|
||||
// nothing to have in hand by the time there is a session to apply
|
||||
// it to — and starting it here means the edit is ready when the
|
||||
// photograph is, instead of the image appearing at its defaults
|
||||
// and visibly changing a moment later.
|
||||
let sidecar_rx =
|
||||
library::spawn_sidecar_fetch(creds.clone(), user_id.clone(), path.clone());
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
// The cache is consulted first, so a second open of the same
|
||||
// photograph is a disk read rather than a second download of tens
|
||||
@@ -924,6 +1167,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
let gpu = gpu.clone();
|
||||
let sidecar_rx = Rc::new(sidecar_rx);
|
||||
let timer = Rc::new(slint::Timer::default());
|
||||
let held = timer.clone();
|
||||
timer.start(
|
||||
@@ -967,6 +1211,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
Some(s) => {
|
||||
w.set_adjust_enabled(true);
|
||||
*session.borrow_mut() = Some(s);
|
||||
// TRACES: FR-CAT-8
|
||||
// The stored edit, if it has landed. It
|
||||
// was started before the download of a
|
||||
// file thousands of times its size, so in
|
||||
// practice it has; `apply_when_ready`
|
||||
// covers the case where it has not rather
|
||||
// than blocking the UI thread on a socket.
|
||||
apply_when_ready(
|
||||
&w,
|
||||
sidecar_rx.clone(),
|
||||
&session,
|
||||
&rows,
|
||||
&redraw,
|
||||
);
|
||||
sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
}
|
||||
@@ -993,10 +1251,73 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- copying settings between photographs (FR-DEV-6) ----------------
|
||||
//
|
||||
// Wired after the develop session and the library both exist, because a
|
||||
// paste reaches both: onto the image on screen, or onto the grid's
|
||||
// selection through its sidecars.
|
||||
{
|
||||
presets::wire(
|
||||
&window,
|
||||
clipboard.clone(),
|
||||
presets::Develop {
|
||||
session: session.clone(),
|
||||
rows: rows.clone(),
|
||||
redraw: redraw.clone(),
|
||||
open: open_image.clone(),
|
||||
},
|
||||
settings.clone(),
|
||||
library.clone(),
|
||||
collections.clone(),
|
||||
);
|
||||
|
||||
// Close the knot left open beside `open_from_library`: the grid's
|
||||
// "‹ Library" button was wired before there was a session to save.
|
||||
let weak = window.as_weak();
|
||||
let open_image = open_image.clone();
|
||||
let session = session.clone();
|
||||
let library = library.clone();
|
||||
*leave_develop.borrow_mut() = Some(Rc::new(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
presets::save_open_edit(&w, &open_image.borrow(), &session, &library);
|
||||
}));
|
||||
|
||||
presets::render(&window, &clipboard, &settings);
|
||||
}
|
||||
|
||||
// ---- Adjustment callbacks ------------------------------------------
|
||||
//
|
||||
// Generic by construction: they carry indices into the capability list,
|
||||
// so adding an operation needs no change here (FR-DEV-3c).
|
||||
// --- export ---------------------------------------------------------
|
||||
//
|
||||
// Renders its own frame at full resolution rather than encoding what is
|
||||
// on screen: the display render is deliberately viewport-sized
|
||||
// (FR-DSP-1), and exporting that would hand the user a soft, screen-sized
|
||||
// file with no indication anything had been lost (FR-EXP-9).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let settings = settings.clone();
|
||||
let library = library.clone();
|
||||
window.on_export_image(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_export_busy(true);
|
||||
// Pushed before the work rather than after: the render blocks the
|
||||
// UI thread, so a label set afterwards would never be drawn in
|
||||
// the "Exporting…" state at all.
|
||||
w.set_export_status("Rendering…".into());
|
||||
|
||||
let result = export_now(&w, &session, &settings, &library);
|
||||
match result {
|
||||
Ok(placed) => w.set_export_status(placed.describe().into()),
|
||||
Err(e) => w.set_export_status(format!("Export failed: {e}").into()),
|
||||
}
|
||||
w.set_export_busy(false);
|
||||
refresh_export_label(&w, &settings);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
|
||||
Reference in New Issue
Block a user