From 40652fc5f91ffa4ba7d8b099286762c9f6741e05 Mon Sep 17 00:00:00 2001 From: Hanmo Date: Fri, 9 Oct 2026 13:35:02 +0800 Subject: [PATCH 1/8] M6: move radial masks by dragging inside their ellipses --- crates/ui-egui/src/panels/detail.rs | 24 +++++++++- crates/ui-egui/src/tests_masking.rs | 72 +++++++++++++++++++++++++++++ docs/parity.md | 6 ++- docs/whats-new.md | 5 ++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ea3d782b..2e708da6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1434,6 +1434,22 @@ fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &Canvas } } +/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, +/// so undo the same scaling and rotation used to draw its outline before testing containment. +fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { + let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; + if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { + return false; + } + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let x = (dx * co + dy * s) / rx; + let y = (-dx * s + dy * co) / ry; + x * x + y * y <= 1.0 +} + /// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). @@ -1608,8 +1624,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { - // anywhere on the photo drags the selected linear gradient let m = d.masks.iter().find(|m| Some(m.id) == active)?; + // Handles take precedence; otherwise grab the selected radial component under + // the press, including components after the first in compound masks. + let at = map.norm(q); + if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { + return Some((m.id, ci, 0)); + } + // anywhere on the photo drags the selected linear gradient matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) }); if let Some((mask, comp, handle)) = grip diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0729a854..6a5f59e1 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -39,6 +39,78 @@ fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { (*h.app.session.develop_of(id).unwrap_or_default()).clone() } +#[test] +fn radial_body_drag_moves_rotated_components_and_undoes_once() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "luminanceRange"})); + exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.45, 0.5], "rx": 0.22, "ry": 0.1, "angle": 40})); + let before = develop(&h); + // Well away from the pin and edge/rotation grips, inside a rotated ellipse. + pointer( + &mut h, + json!([{"kind": "down", "x": 0.51, "y": 0.56}, {"kind": "drag", "x": 0.54, "y": 0.58}, + {"kind": "drag", "x": 0.61, "y": 0.66}, {"kind": "up", "x": 0.61, "y": 0.66}]), + ); + let after = develop(&h); + let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &after.masks[0].components[1].shape else { panic!("radial") }; + assert!((center.x - 0.55).abs() < 0.005 && (center.y - 0.6).abs() < 0.005, "{center:?}"); + let MaskShape::Radial { rx: old_rx, ry: old_ry, angle: old_angle, feather: old_feather, invert: old_invert, .. } = + &before.masks[0].components[1].shape + else { + panic!("radial") + }; + assert_eq!((rx, ry, angle, feather, invert), (old_rx, old_ry, old_angle, old_feather, old_invert)); + assert_eq!(after.masks[0].components[0], before.masks[0].components[0], "only the hit component moves"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before, "one undo reverses the entire drag"); + exec(&mut h, "edit.redo", json!({})); + assert_eq!(develop(&h), after); + // The bounding-box corner is outside the ellipse, even for an inverted radial mask. + exec(&mut h, "mask.component", json!({"component": 1, "action": "invert"})); + let outside_before = develop(&h); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.35, "y": 0.75}, {"kind": "drag", "x": 0.4, "y": 0.8}, + {"kind": "up", "x": 0.4, "y": 0.8}]), + ); + assert_eq!(develop(&h), outside_before, "outside the ellipse must not move it"); + // Hiding pins does not disable dragging the selected ellipse body. + exec(&mut h, "view.maskPins", json!({"show": false})); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.61, "y": 0.66}, {"kind": "drag", "x": 0.66, "y": 0.71}, + {"kind": "up", "x": 0.66, "y": 0.71}]), + ); + let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[1].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.6).abs() < 0.005 && (center.y - 0.65).abs() < 0.005, "{center:?}"); +} + +#[test] +fn radial_body_drag_keeps_resize_and_rotation_handles() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.18, "ry": 0.1})); + for handle in [1, 5] { + let widget = format!("maskHandle:1:0:{handle}"); + let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1; + let image = h.app.image_rect.unwrap(); + let q = rect.center(); + let x = (q.x - image.left()) / image.width(); + let y = (q.y - image.top()) / image.height(); + pointer( + &mut h, + json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.03, "y": y + 0.03}, + {"kind": "up", "x": x + 0.03, "y": y + 0.03}]), + ); + let MaskShape::Radial { center, rx, angle, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.5).abs() < 0.005 && (center.y - 0.5).abs() < 0.005, "a handle must not translate the mask"); + if handle == 1 { + assert!(rx > 0.19, "resize grip changes radius"); + } else { + assert!(angle.abs() > 1.0, "rotation grip changes angle"); + } + } +} + #[test] fn mask_overlay_keys_and_pins() { use lightcraft_pipeline::{MaskView, Overlay}; diff --git a/docs/parity.md b/docs/parity.md index 066bd45a..df9d35f0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -59,6 +59,10 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. +Radial-gradient interaction: the selected ellipse can be moved by dragging anywhere inside its +outline (including a radial component of a compound mask); resize/rotation grips take precedence. +Regression coverage includes rotated ellipses, outside clicks, hidden pins and one-step undo. + 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); Nikon CropArea and untagged-preview ColorSpace metadata are respected; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean ΔE slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. @@ -354,7 +358,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | -| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | +| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | | LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | diff --git a/docs/whats-new.md b/docs/whats-new.md index 133770dc..54cb8a97 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Masking +- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated + ellipses and radial components in compound masks. Edge and rotation handles keep their existing + functions, and one Undo restores the entire drag. + ### Albums tree - Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords. - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… From 35718bc92cc13c4e7679a1c0d5d5550cc70c695b Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:04:40 +0900 Subject: [PATCH 2/8] M1: keep deletion selection near the current photo --- crates/engine/src/cmd/library.rs | 14 ++++++++-- crates/engine/src/tests.rs | 43 +++++++++++++++++++++++++++++- crates/ui-egui/src/tests_scroll.rs | 27 +++++++++++++++++++ docs/parity.md | 2 +- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d900eacf..09d19f0d 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -661,9 +661,19 @@ pub fn specs() -> Vec { ), // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { + let before = s.visible_cloned(); + let at = before.iter().position(|id| Some(*id) == s.selection.active); let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; - let vis = s.visible_cloned(); - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); + s.selection.ids.retain(|id| remaining.contains(id)); + s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); + if s.selection.active.is_none() + && let Some(at) = at + { + // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. + let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); + s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); + } Ok(v) }), cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9806a81e..d26f5a0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Value, json}; -use crate::{Session, command_specs}; +use crate::{Selection, Session, command_specs}; fn demo() -> Session { Session::with_demo() @@ -157,6 +157,47 @@ fn filters_and_delete_restore() { assert_eq!(s.visible().len(), 24); } +#[test] +fn deleting_photos_selects_a_neighbour_in_the_current_view() { + for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] { + for at_end in [false, true] { + let mut s = demo(); + s.execute("library.sort", ¶ms).unwrap(); + s.execute("library.filter", &json!({"rating": 2})).unwrap(); + let visible = s.visible_cloned(); + assert!(visible.len() > 5); + let at = if at_end { visible.len() - 2 } else { visible.len() / 2 }; + let deleted = &visible[at..at + 2]; + s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + let expected = if at_end { visible[at - 1] } else { visible[at + 2] }; + assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}"); + assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(deleted.iter().all(|id| !s.catalog.photo(*id).unwrap().deleted)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted)); + } + } + let mut s = demo(); + s.execute("library.selectAll", &json!({})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(s.visible().is_empty()); + assert_eq!(s.selection, Selection::default()); +} + +#[test] +fn deleting_explicit_targets_keeps_the_surviving_selection() { + let mut s = demo(); + let visible = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap(); + let before = s.selection.clone(); + s.execute("photo.delete", &json!({"ids": [visible[3]]})).unwrap(); + assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection"); + s.execute("photo.delete", &json!({"ids": [visible[9]]})).unwrap(); + assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor"); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index 455bd0d8..6ccb0d33 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -84,6 +84,33 @@ fn grid_keeps_the_users_scroll_position() { assert!(grid_y(&h) < y2, "scrolled up to the second photo"); } +#[test] +fn deleting_photos_does_not_jump_the_grid_to_the_top() { + for view in ["photoGrid", "squareGrid"] { + for count in [1, 3] { + let mut h = demo(view); + let ids = h.app.session.visible_cloned(); + let at = ids.len() / 2; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": &ids[at..at + count]}}), T); + assert_eq!(r["ok"], true, "{r}"); + idle(&mut h, 30); + let y = grid_y(&h); + assert!(y > 600.0, "selection is well below the top: {view}, {y}"); + let r = h.request("engine.execute", json!({"command": "photo.delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + idle(&mut h, 60); + let next = ids[at + count]; + assert!(grid_y(&h) > 600.0, "deleting scrolled to the top: {view}, {} → {}", y, grid_y(&h)); + assert_eq!(h.app.session.active(), Some(next), "{view}, count {count}"); + let canvas = h.app.canvas_rect.unwrap(); + assert!(widget(&h, &format!("thumb:{}", next.0)).intersects(canvas), "the next survivor is in view"); + let after = grid_y(&h); + idle(&mut h, 60); + assert_eq!(grid_y(&h), after, "idle frames preserve the new position"); + } + } +} + #[test] fn filmstrip_wheel_scrolls_and_keeps_its_position() { let mut h = demo("detail"); diff --git a/docs/parity.md b/docs/parity.md index c06dad1b..c32f5fbc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -168,7 +168,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Deletion retains surviving selected photos; if none remain, selects the next visible survivor (previous at the end) in the current source/filter/sort instead of jumping to the first photo; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | From bf35a3c6c5b18fcd263961c2a72538064d9c5314 Mon Sep 17 00:00:00 2001 From: supper_yijie Date: Fri, 9 Oct 2026 14:48:00 +0800 Subject: [PATCH 3/8] fix #425: Restore Simplified Chinese font rendering 1.Added Simplified Chinese font support for web and desktop builds. 2.Kept Inter as the default font, with Chinese used only as a fallback. 3.Verified Chinese glyph tests and a macOS universal build. Signed-off-by: supper_yijie --- AGENTS.md | 7 +- CLAUDE.md | 7 +- apps/lightcraft-web/index.html | 12 ++- apps/lightcraft-web/src/web.rs | 14 ++- assets/ATTRIBUTION.md | 4 +- crates/engine/build.rs | 19 +++- crates/ui-egui/src/lib.rs | 15 ++- crates/ui-egui/src/panels/dialogs.rs | 5 +- crates/ui-egui/src/theme.rs | 138 +++++++++++++++++++++++++-- docs/localization-zh-hans.md | 6 +- docs/localization-zh-hant.md | 2 +- docs/parity.md | 2 +- docs/releasing.md | 4 +- docs/web.md | 11 ++- packaging/web/README.md | 2 + xtask/src/web.rs | 62 +++++++++++- 16 files changed, 267 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ab653c7..b199c7f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` - embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI - (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset, - `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build + embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web` + ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI + (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset, + `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). diff --git a/CLAUDE.md b/CLAUDE.md index bdf7eceb..994920d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` - embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI - (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset, - `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build + embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web` + ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI + (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset, + `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). diff --git a/apps/lightcraft-web/index.html b/apps/lightcraft-web/index.html index bc0ad203..b088ad18 100644 --- a/apps/lightcraft-web/index.html +++ b/apps/lightcraft-web/index.html @@ -24,13 +24,21 @@