Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
This commit is contained in:
+479
-46
@@ -14,8 +14,15 @@
|
||||
//! pipeline what parameters it has and builds a control per answer; no code
|
||||
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
||||
|
||||
mod collections_ui;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
mod labels;
|
||||
mod library;
|
||||
mod library_ui;
|
||||
mod trash;
|
||||
#[cfg(live_style)]
|
||||
mod live_style;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -48,10 +55,10 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||||
|
||||
/// Everything loaded for the currently displayed image.
|
||||
struct Loaded {
|
||||
/// A develop session where the file could be decoded to sensor data.
|
||||
/// `None` for a JPEG or a body rawler cannot decode, in which case
|
||||
/// `fallback` carries an embedded preview and the adjust panel is
|
||||
/// disabled rather than shown doing nothing.
|
||||
/// A develop session. `None` only where the file could not be opened for
|
||||
/// editing at all — a body rawler cannot decode, or a corrupt JPEG — in
|
||||
/// which case `fallback` carries an embedded preview and the adjust panel
|
||||
/// is disabled rather than shown doing nothing.
|
||||
session: Option<DevelopSession>,
|
||||
fallback: Option<slint::Image>,
|
||||
meta: Metadata,
|
||||
@@ -77,33 +84,73 @@ fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String>
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||
load_bytes(ctx, &bytes)
|
||||
}
|
||||
|
||||
/// Open already-fetched bytes.
|
||||
///
|
||||
/// Split from [`load`] because a library image has no local file: it arrives
|
||||
/// as a WebDAV response body, and writing it to disk purely to read it back
|
||||
/// would be a round-trip for nothing.
|
||||
fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded, String> {
|
||||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||
|
||||
// Route by what the bytes actually are, not by extension (M-9).
|
||||
//
|
||||
// A JPEG has no sensor data and never will, so trying the RAW decoder
|
||||
// first would be a guaranteed failure whose log line reads like a fault.
|
||||
// It goes straight to the RGB path instead, which is what makes develop
|
||||
// mode work on the JPEGs the library already indexes.
|
||||
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
|
||||
|
||||
// Try sensor data first. A failure here is expected for JPEGs and for
|
||||
// bodies rawler does not know, and must not stop the image displaying
|
||||
// (FR-RAW-4).
|
||||
if let Some(ctx) = ctx {
|
||||
match dr_decode::decode(&bytes) {
|
||||
Ok(raw) => match DevelopSession::open(ctx, &raw) {
|
||||
Ok(session) => {
|
||||
let (width, height) = session.source_size();
|
||||
return Ok(Loaded {
|
||||
session: Some(session),
|
||||
fallback: None,
|
||||
meta,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
|
||||
},
|
||||
Err(e) => log::info!("no sensor data ({e}); showing preview"),
|
||||
let opened = if is_jpeg {
|
||||
dr_decode::decode_jpeg(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|mut p| {
|
||||
// Fit the device before uploading. A film scan runs to
|
||||
// 13728×8928, well past the 8192 a typical GPU can hold,
|
||||
// and refusing it would drop the image back to a
|
||||
// read-only preview — the very thing this path exists to
|
||||
// avoid. 8192 is still four times a 4K long edge.
|
||||
let limit = dr_gpu::DemosaicedImage::max_dimension(ctx);
|
||||
if p.width.max(p.height) > limit {
|
||||
log::info!(
|
||||
"{}×{} exceeds the {limit} texture limit; fitting to it",
|
||||
p.width,
|
||||
p.height
|
||||
);
|
||||
p.downscale_to(limit);
|
||||
}
|
||||
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height)
|
||||
})
|
||||
} else {
|
||||
// A failure here is expected for bodies rawler does not know, and
|
||||
// must not stop the image displaying (FR-RAW-4).
|
||||
dr_decode::decode(bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|raw| DevelopSession::open(ctx, &raw))
|
||||
};
|
||||
|
||||
match opened {
|
||||
Ok(session) => {
|
||||
let (width, height) = session.source_size();
|
||||
return Ok(Loaded {
|
||||
session: Some(session),
|
||||
fallback: None,
|
||||
meta,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the embedded preview, which is all a JPEG has anyway.
|
||||
// Fall back to the embedded preview: no GPU, or a file neither decoder
|
||||
// could open for editing. Read-only, and the adjust panel is disabled.
|
||||
let mut preview =
|
||||
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||
preview.downscale_to(MAX_DISPLAY_DIM);
|
||||
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
||||
@@ -162,6 +209,21 @@ fn is_supported(p: &Path) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Return the view to its opening state for a newly loaded image.
|
||||
///
|
||||
/// Zoom and crop mode are properties of *looking at one photograph*, so
|
||||
/// carrying them to the next one would leave the second image cropped to a
|
||||
/// rect chosen for the first.
|
||||
fn reset_view_state(window: &AppWindow) {
|
||||
window.set_crop_mode(false);
|
||||
window.set_zoom(1.0);
|
||||
window.set_zoomed(false);
|
||||
window.set_crop_x(0.0);
|
||||
window.set_crop_y(0.0);
|
||||
window.set_crop_w(1.0);
|
||||
window.set_crop_h(1.0);
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
///
|
||||
/// The controls are not self-updating: the core clamps values, so what the
|
||||
@@ -186,10 +248,27 @@ fn sync_rows(
|
||||
};
|
||||
|
||||
if current.len() == rows.row_count() {
|
||||
for (i, row) in current.into_iter().enumerate() {
|
||||
for (i, mut row) in current.into_iter().enumerate() {
|
||||
let existing = rows.row_data(i);
|
||||
|
||||
// A curve row carries a *nested* model of point coordinates, and
|
||||
// `rows()` builds a fresh one each call. Swapping it in would
|
||||
// destroy the point elements — including the `TouchArea` holding
|
||||
// the current drag — so the existing model is kept and its values
|
||||
// written through instead.
|
||||
//
|
||||
// It also makes the equality test below meaningful: `ModelRc`
|
||||
// compares by identity, so a brand-new points model would make
|
||||
// every curve row look changed on every event.
|
||||
if let Some(previous) = existing.as_ref() {
|
||||
if update_points_in_place(&previous.points, &row.points) {
|
||||
row.points = previous.points.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Only touch rows that actually changed, so unrelated controls
|
||||
// are not needlessly invalidated.
|
||||
if rows.row_data(i).as_ref() != Some(&row) {
|
||||
if existing.as_ref() != Some(&row) {
|
||||
rows.set_row_data(i, row);
|
||||
}
|
||||
}
|
||||
@@ -204,29 +283,167 @@ fn sync_rows(
|
||||
window.set_curve_samples(slint::ModelRc::new(slint::VecModel::from(samples)));
|
||||
}
|
||||
|
||||
/// Copy `fresh`'s values into `existing`, keeping the model identity.
|
||||
///
|
||||
/// Returns `false` where the two differ in length, in which case the caller
|
||||
/// must take the new model wholesale — the control set itself has changed and
|
||||
/// there is no drag worth preserving.
|
||||
fn update_points_in_place(
|
||||
existing: &slint::ModelRc<f32>,
|
||||
fresh: &slint::ModelRc<f32>,
|
||||
) -> bool {
|
||||
use slint::Model as _;
|
||||
|
||||
if existing.row_count() != fresh.row_count() {
|
||||
return false;
|
||||
}
|
||||
for i in 0..fresh.row_count() {
|
||||
let (Some(new), Some(old)) = (fresh.row_data(i), existing.row_data(i)) else {
|
||||
continue;
|
||||
};
|
||||
// Guarded so an unchanged coordinate does not invalidate its element
|
||||
// — the same reasoning as the row-level check above.
|
||||
if new != old {
|
||||
existing.set_row_data(i, new);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// TRACES: M-13 | M-14
|
||||
/// Build and run the viewer.
|
||||
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let entries = Rc::new(collect(&paths));
|
||||
log::info!("{} image(s) to browse", entries.len());
|
||||
// Mutable because the browsing list has two sources: the command line at
|
||||
// startup, and whatever the library grid is showing when a cell is
|
||||
// clicked. Opening from the grid replaces this so next/previous walk the
|
||||
// library the user is actually looking at rather than the arguments they
|
||||
// launched with.
|
||||
let entries = Rc::new(RefCell::new(collect(&paths)));
|
||||
log::info!("{} image(s) to browse", entries.borrow().len());
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
|
||||
// Set once `show` exists; see where the library grid is wired below.
|
||||
#[allow(clippy::type_complexity)]
|
||||
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> =
|
||||
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)]
|
||||
live_style::apply(&window);
|
||||
|
||||
// The library grid: scan the remote tree into the catalog, then show what
|
||||
// was found. Clicking a cell opens it in develop.
|
||||
//
|
||||
// Declared out here rather than inside the launch block below because the
|
||||
// develop side reads `paths` to rebuild its browsing list when an image is
|
||||
// opened from the grid.
|
||||
let library = library_ui::LibraryController::new();
|
||||
|
||||
// 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
|
||||
// a folder goes straight to their images (FR-NC-1).
|
||||
{
|
||||
let controller = launch_ui::LaunchController::new();
|
||||
let show = controller.should_show(!paths.is_empty());
|
||||
window.set_show_launch(show);
|
||||
launch_ui::wire(&window, controller, |session| {
|
||||
// Opening a remote library needs the scan-and-cache path, which
|
||||
// lands with the catalog. Reporting that plainly beats a button
|
||||
// that silently does nothing.
|
||||
log::info!("open library requested for {}", session.describe());
|
||||
let startup = controller
|
||||
.model
|
||||
.borrow()
|
||||
.startup_action(!paths.is_empty());
|
||||
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
|
||||
|
||||
let library = library.clone();
|
||||
let collections = collections_ui::CollectionsController::new();
|
||||
|
||||
// The click handler needs `show`, which is built further down because
|
||||
// it captures the develop session and the GPU context. This cell is
|
||||
// the knot between them: wired empty here, filled once `show` exists.
|
||||
// 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);
|
||||
},
|
||||
);
|
||||
|
||||
// The collections sidebar shares the library's catalog handle rather
|
||||
// than opening its own: one SQLite connection, so an edit here is
|
||||
// visible to the grid's next read without a reopen.
|
||||
//
|
||||
// The reload closure is the seam between the two controllers. The
|
||||
// sidebar decides *what* is scoped; the library owns the window, the
|
||||
// offset and the thumbnail workers, so it is what actually reloads —
|
||||
// and it must be told the scope before it reads, which is why both
|
||||
// happen here in one place rather than each controller reaching for the
|
||||
// other.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let lib = library.clone();
|
||||
let coll = collections.clone();
|
||||
let lib_ids = library.clone();
|
||||
let lib_session = library.clone();
|
||||
collections_ui::wire(
|
||||
&window,
|
||||
collections.clone(),
|
||||
library.catalog(),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Order matters: `set_scope` clears the trash flag, because
|
||||
// picking a collection is how you leave the trash. Setting
|
||||
// the flag second is what lets selecting the trash itself
|
||||
// survive the call.
|
||||
lib.set_scope(coll.scope());
|
||||
lib.set_viewing_trash(coll.viewing_trash());
|
||||
library_ui::reload(&w, &lib);
|
||||
},
|
||||
move || lib_ids.visible_ids(),
|
||||
// The trash's MOVE and DELETE go to the same account the scan
|
||||
// and thumbnail workers use.
|
||||
move || lib_session.session(),
|
||||
);
|
||||
}
|
||||
|
||||
let weak = window.as_weak();
|
||||
let store_ctl = controller.clone();
|
||||
let lib = library.clone();
|
||||
let coll = collections.clone();
|
||||
launch_ui::wire(&window, controller.clone(), move |session| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
log::info!("opening library for {}", session.describe());
|
||||
library_ui::open(&w, lib.clone(), coll.clone(), &store_ctl.store, session);
|
||||
});
|
||||
if show {
|
||||
log::info!("no library configured — showing the launch screen");
|
||||
|
||||
match startup {
|
||||
launch::Startup::ShowLaunchScreen => {
|
||||
log::info!("no library configured — showing the launch screen");
|
||||
}
|
||||
launch::Startup::ShowLocalFiles => {
|
||||
log::info!("{} file(s) named on the command line", paths.len());
|
||||
}
|
||||
// Skipping the launch screen must not mean skipping the library:
|
||||
// the "Open library" button lives on the screen we just bypassed,
|
||||
// so nothing else would ever start the scan.
|
||||
launch::Startup::OpenLibrary => {
|
||||
let session = controller.model.borrow().session().cloned();
|
||||
if let Some(session) = session {
|
||||
log::info!("resuming library for {}", session.describe());
|
||||
library_ui::open(
|
||||
&window,
|
||||
library.clone(),
|
||||
collections.clone(),
|
||||
&controller.store,
|
||||
session,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +463,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
window.set_total(entries.len() as i32);
|
||||
window.set_total(entries.borrow().len() as i32);
|
||||
let index = Rc::new(RefCell::new(0usize));
|
||||
// The current develop session, if the file yielded sensor data.
|
||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||
@@ -269,10 +486,25 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
let (w, h) = *viewport.borrow();
|
||||
match s.render(w, h) {
|
||||
|
||||
// Crop mode shows the whole frame, or the area being cropped away
|
||||
// would not be on screen for the handles to drag across. The
|
||||
// overlay draws the rect on top of it.
|
||||
let rendered = if window.get_crop_mode() {
|
||||
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
||||
} else {
|
||||
s.render(w, h)
|
||||
};
|
||||
|
||||
match rendered {
|
||||
Ok(image) => {
|
||||
window.set_canvas(image);
|
||||
window.set_load_error("".into());
|
||||
// The readout and the "Fit" button follow the session
|
||||
// rather than the gesture, so a clamped zoom shows the
|
||||
// value that was actually applied.
|
||||
window.set_zoom(s.zoom());
|
||||
window.set_zoomed(s.is_zoomed());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("render failed: {e}");
|
||||
@@ -291,13 +523,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let rows = rows.clone();
|
||||
Rc::new(move |window: &AppWindow| {
|
||||
let i = *index.borrow();
|
||||
let Some(path) = entries.get(i) else { return };
|
||||
// Cloned rather than held: `load` below is slow, and keeping the
|
||||
// list borrowed across it would panic the moment anything else
|
||||
// touched `entries`.
|
||||
let Some(path) = entries.borrow().get(i).cloned() else {
|
||||
return;
|
||||
};
|
||||
let path = path.as_path();
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
reset_view_state(window);
|
||||
window.set_filename(name.clone().into());
|
||||
window.set_index(i as i32);
|
||||
|
||||
@@ -349,6 +588,108 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
})
|
||||
};
|
||||
|
||||
// Now `show` exists, close the knot left open at the library wiring.
|
||||
//
|
||||
// The grid's paths are *remote*: there is no local file to open, so the
|
||||
// click starts a download and the image appears when it lands. That is a
|
||||
// whole RAW file over WebDAV, so the wait is real and has to be visible —
|
||||
// the status line says so rather than leaving a blank frame.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let library = library.clone();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
let gpu = gpu.clone();
|
||||
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
|
||||
reset_view_state(&w);
|
||||
w.set_filename(name.clone().into());
|
||||
w.set_load_error("".into());
|
||||
w.set_camera("".into());
|
||||
w.set_exposure("".into());
|
||||
w.set_dimensions("".into());
|
||||
// The grid is one image at a time, so next/previous have nothing
|
||||
// to walk. Shown as 1 of 1 rather than left reading 0.
|
||||
w.set_index(0);
|
||||
w.set_total(1);
|
||||
|
||||
let Some((creds, user_id)) = library.credentials() else {
|
||||
w.set_load_error("no library session".into());
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!("fetching {path} for develop");
|
||||
w.set_load_error("Downloading…".into());
|
||||
|
||||
let rx = library::spawn_full_fetch(creds, user_id, path.clone());
|
||||
|
||||
// Polled on the UI thread rather than joined: a join would freeze
|
||||
// the window for the length of the download.
|
||||
let weak = w.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
let gpu = gpu.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 };
|
||||
// Landed — this timer has done its job.
|
||||
held.stop();
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let bytes = match got {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("{name}: {e}");
|
||||
w.set_load_error(e.into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("{name}: {} bytes fetched", bytes.len());
|
||||
|
||||
match load_bytes(gpu.as_ref(), &bytes) {
|
||||
Ok(l) => {
|
||||
w.set_load_error("".into());
|
||||
w.set_camera(describe_camera(&l.meta).into());
|
||||
w.set_exposure(describe_exposure(&l.meta).into());
|
||||
w.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||||
match l.session {
|
||||
Some(s) => {
|
||||
w.set_adjust_enabled(true);
|
||||
*session.borrow_mut() = Some(s);
|
||||
sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
}
|
||||
None => {
|
||||
*session.borrow_mut() = None;
|
||||
rows.set_vec(Vec::<ParamRow>::new());
|
||||
w.set_adjust_enabled(false);
|
||||
if let Some(image) = l.fallback {
|
||||
w.set_canvas(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("{name}: {}×{}", l.width, l.height);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{name}: {e}");
|
||||
*session.borrow_mut() = None;
|
||||
w.set_adjust_enabled(false);
|
||||
w.set_load_error(e.into());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Adjustment callbacks ------------------------------------------
|
||||
//
|
||||
// Generic by construction: they carry indices into the capability list,
|
||||
@@ -413,6 +754,96 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- zoom, pan and crop ---------------------------------------------
|
||||
//
|
||||
// Zoom and pan are viewing state and touch no parameter, so unlike the
|
||||
// handlers above they do not `sync_rows`.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_zoom_at(move |factor, at_x, at_y| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.zoom_about(factor, at_x, at_y);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_pan_by(move |dx, dy| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.pan_by(dx, dy);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_zoom_reset(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.reset_zoom();
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// Entering crop mode drops the zoom: the handles are placed against
|
||||
// the whole frame, and a zoomed view would put most of that frame off
|
||||
// screen where it cannot be dragged.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_crop_mode_toggled(move |on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
if on {
|
||||
s.reset_zoom();
|
||||
let c = s.crop();
|
||||
w.set_crop_x(c.x);
|
||||
w.set_crop_y(c.y);
|
||||
w.set_crop_w(c.width);
|
||||
w.set_crop_h(c.height);
|
||||
}
|
||||
}
|
||||
w.set_crop_mode(on);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
// The rect arrives raw from the drag; the session normalises it, and
|
||||
// the properties are written back from what it actually stored. That
|
||||
// round trip is what makes an over-drag slide along the edge rather
|
||||
// than letting the overlay and the pipeline disagree.
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_crop_changed(move |x, y, width, height| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_crop(dr_pipeline::CropRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
let c = s.crop();
|
||||
w.set_crop_x(c.x);
|
||||
w.set_crop_y(c.y);
|
||||
w.set_crop_w(c.width);
|
||||
w.set_crop_h(c.height);
|
||||
}
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let index = index.clone();
|
||||
@@ -420,14 +851,15 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let show = show.clone();
|
||||
window.on_next_image(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if entries.is_empty() {
|
||||
let len = entries.borrow().len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
// Read, then write — `*x.borrow_mut() = *x.borrow() + 1` holds
|
||||
// both borrows at once and panics.
|
||||
let next = {
|
||||
let cur = *index.borrow();
|
||||
(cur + 1) % entries.len()
|
||||
(cur + 1) % len
|
||||
};
|
||||
*index.borrow_mut() = next;
|
||||
show(&w);
|
||||
@@ -440,13 +872,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let show = show.clone();
|
||||
window.on_prev_image(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if entries.is_empty() {
|
||||
let len = entries.borrow().len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let prev = {
|
||||
let cur = *index.borrow();
|
||||
if cur == 0 {
|
||||
entries.len() - 1
|
||||
len - 1
|
||||
} else {
|
||||
cur - 1
|
||||
}
|
||||
@@ -489,7 +922,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
apply_layout_class(&window, size.width as f32 / scale);
|
||||
}
|
||||
|
||||
if !entries.is_empty() {
|
||||
if !entries.borrow().is_empty() {
|
||||
show(&window);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user