refactor(ui-egui): split the detail view, mask overlay and denoise panel

This commit is contained in:
Dylan M. Taylor
2026-10-28 03:25:18 +00:00
parent 4ed9a5c869
commit f858578f8c
3 changed files with 802 additions and 515 deletions
+43 -21
View File
@@ -319,6 +319,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V
if status["enabled"] != true {
return;
}
speed_section(app, ui, t, list, &status);
photos_section(app, ui, t, list, &status);
cache_section(app, ui, t, list, &status);
}
/// Where the work runs.
fn speed_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) {
heading(ui, t, "Speed");
let current = list["runOn"].as_str().unwrap_or("auto").to_string();
let mut run_on = current.as_str();
@@ -342,6 +349,10 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V
app.caches.denoise.epoch += 1;
}
}
}
/// Whether photos are made as they are looked at, and the progress of what is being made.
fn photos_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) {
heading(ui, t, "Photos");
let mut auto = list["auto"].as_bool().unwrap_or(true);
if check(ui, "settings.denoiseAuto", &mut auto, "Make the denoised picture of the photos I am looking at") {
@@ -350,27 +361,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V
}
hint(ui, t, "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.");
let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued);
match running {
Some((done, total)) => {
let name = status["running"]["photo"]
.as_u64()
.and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone()))
.unwrap_or_default();
let text = tr_format!(
"Denoising {name}: {done} of {total} tiles{}",
if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() },
name = name,
done = done,
total = total
);
settings_bar(ui, done, total, text);
}
None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")),
None => {
let ready = status["ready"].as_u64().unwrap_or(0);
hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture."))
}
}
progress_line(app, ui, t, status, running, queued);
ui.horizontal(|ui| {
let r = ui.button(tr("Denoise all photos with an Amount"));
register(ui, "denoise:queueAll", r.rect);
@@ -385,6 +376,37 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V
}
}
});
}
/// "Denoising {photo}: {done} of {total} tiles", and how many photos wait.
fn running_text(app: &LightcraftApp, status: &Value, done: u64, total: u64, queued: u64) -> String {
let name =
status["running"]["photo"].as_u64().and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())).unwrap_or_default();
tr_format!("Denoising {name}: {done} of {total} tiles{}", waiting_suffix(queued), name = name, done = done, total = total)
}
/// " · N waiting" after the running photo, when any wait.
fn waiting_suffix(queued: u64) -> String {
if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() }
}
/// What is being made now, or how many photos wait or are done.
fn progress_line(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, status: &Value, running: Option<(u64, u64)>, queued: u64) {
match running {
Some((done, total)) => {
let text = running_text(app, status, done, total, queued);
settings_bar(ui, done, total, text);
}
None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")),
None => {
let ready = status["ready"].as_u64().unwrap_or(0);
hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture."))
}
}
}
/// The cache's contents and limit.
fn cache_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) {
heading(ui, t, "Cache");
let (files, bytes) = (status["cache"]["files"].as_u64().unwrap_or(0), status["cache"]["bytes"].as_u64().unwrap_or(0));
hint(
File diff suppressed because it is too large Load Diff
+187 -145
View File
@@ -291,7 +291,6 @@ pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &eg
/// component (click selects its mask, drag moves the component), and brush painting. The mask
/// itself shows as a rendered overlay ([`view_overlay`]).
pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
let long = (map.rect.width().max(map.rect.height())) as f64;
let active = app.session.active_mask;
let clip = app.canvas_rect.unwrap_or(map.rect);
let p = &ui.painter_at(clip);
@@ -310,60 +309,9 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg
// mask so nothing else becomes draggable. The component check keeps this in step with the
// painted region (`view_overlay` at detail.rs:1354 only covers masks with components).
let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty());
let stroke_a: u8 = if sel { 230 } else { 170 };
for (ci, c) in m.components.iter().enumerate() {
match &c.shape {
MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => {
let pts: Vec<Pos2> = (0..64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a.cos(), ry * a.sin());
let l = frame_long_norm(map);
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
})
.collect();
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a))));
if sel {
let l = frame_long_norm(map);
for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() {
let a_local = a_deg.to_radians();
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a_local.cos(), ry * a_local.sin());
let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1));
p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0)));
grips.push((m.id, ci, i as u8 + 1, q));
}
let (s, co) = angle.to_radians().sin_cos();
let rot_r = rx + 0.08;
let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1));
let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1));
p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200)));
p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE));
register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0)));
grips.push((m.id, ci, 5, rot_q));
}
}
MaskShape::Linear { start, end } if show_outline => {
let (a, b) = (map.screen(*start), map.screen(*end));
let dir = (b - a).normalized();
let perp = vec2(-dir.y, dir.x) * 3000.0;
let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) };
for (q, alpha) in [(a, a1), (b, a2)] {
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha)));
}
if sel {
for (i, q) in [a, b].iter().enumerate() {
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
grips.push((m.id, ci, i as u8 + 1, *q));
}
}
}
_ => {}
if show_outline {
component_outline(ui, p, map, (m.id, ci), &c.shape, sel, &mut grips);
}
if app.ui.mask_pins
&& mode.shows(sel, live)
@@ -377,8 +325,98 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg
}
}
mask_keys(app, ui, map, d, &grips);
// Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude
// (clicks still on their way to the model included)
object_marks(app, p, map, d, active);
// Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction
// never runs while it is open), so the eyedropper is handled here too.
if app.ui.tool == "colorRange" {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
color_range_pick(app, ui, resp, map);
return;
}
if app.ui.tool == "object" {
object_tool(app, ui, resp, map);
return;
}
if app.ui.tool == "brush" {
brush_tool(app, ui, resp, map, p, clip);
return;
}
grip_gestures(app, ui, resp, map, d, &grips);
}
/// The outline of one mask component, and (on the selected mask) its grips, registered and
/// collected into `grips`.
fn component_outline(
ui: &egui::Ui,
p: &egui::Painter,
map: &CanvasMap,
at: (u32, usize),
shape: &MaskShape,
sel: bool,
grips: &mut Vec<(u32, usize, u8, Pos2)>,
) {
let (mask, ci) = at;
let stroke_a: u8 = if sel { 230 } else { 170 };
match shape {
MaskShape::Radial { center, rx, ry, angle, .. } => {
let pts: Vec<Pos2> = (0..64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a.cos(), ry * a.sin());
let l = frame_long_norm(map);
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
})
.collect();
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a))));
if sel {
let l = frame_long_norm(map);
for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() {
let a_local = a_deg.to_radians();
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a_local.cos(), ry * a_local.sin());
let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1));
p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui, format!("maskHandle:{}:{ci}:{}", mask, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0)));
grips.push((mask, ci, i as u8 + 1, q));
}
let (s, co) = angle.to_radians().sin_cos();
let rot_r = rx + 0.08;
let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1));
let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1));
p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200)));
p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE));
register(ui, format!("maskHandle:{}:{ci}:5", mask), Rect::from_center_size(rot_q, vec2(14.0, 14.0)));
grips.push((mask, ci, 5, rot_q));
}
}
MaskShape::Linear { start, end } => {
let (a, b) = (map.screen(*start), map.screen(*end));
let dir = (b - a).normalized();
let perp = vec2(-dir.y, dir.x) * 3000.0;
let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) };
for (q, alpha) in [(a, a1), (b, a2)] {
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha)));
}
if sel {
for (i, q) in [a, b].iter().enumerate() {
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui, format!("maskHandle:{}:{ci}:{i}", mask), Rect::from_center_size(*q, vec2(14.0, 14.0)));
grips.push((mask, ci, i as u8 + 1, *q));
}
}
}
_ => {}
}
}
/// Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude
/// (clicks still on their way to the model included).
fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &DevelopSettings, active: Option<u32>) {
let pending = app.session.segmenter.pending_clicks().cloned();
for m in d.masks.iter().filter(|m| Some(m.id) == active) {
for (k, c) in m.components.iter().enumerate() {
@@ -397,100 +435,104 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg
}
}
}
// Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction
// never runs while it is open), so the eyedropper is handled here too.
if app.ui.tool == "colorRange" {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
color_range_pick(app, ui, resp, map);
return;
}
/// Object tool: a click includes what's under it, ⌥-click leaves it out.
fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
// Object tool: a click includes what's under it, ⌥-click leaves it out
if app.ui.tool == "object" {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let exclude = ui.input(|i| i.modifiers.alt);
match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) {
Ok(_) => {
if let Some(mid) = app.session.active_mask {
app.ui.detail_due = Some((app.tick_now() + 1.0, mid));
}
}
Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))),
}
}
return;
}
// brush tool
if app.ui.tool == "brush" {
let r = (app.ui.brush_size as f64 * long) as f32;
if let Some(h) = resp.hover_pos() {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120)));
}
if let Some(q) = resp.interact_pointer_pos()
&& (resp.drag_started() || resp.dragged() || resp.clicked())
{
let n = map.norm(q);
let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None };
match &mut app.gesture {
// a held, unmoving pointer records nothing: coverage builds by passes, not time (#518)
Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {}
Some(Gesture::Brush { points, pressure }) => {
record_pressure(pressure, points.len(), force);
points.push(n);
}
_ => {
let mut pressure = Vec::new();
record_pressure(&mut pressure, 0, force);
app.gesture = Some(Gesture::Brush { points: vec![n], pressure });
if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let exclude = ui.input(|i| i.modifiers.alt);
match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) {
Ok(_) => {
if let Some(mid) = app.session.active_mask {
app.ui.detail_due = Some((app.tick_now() + 1.0, mid));
}
}
Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))),
}
// the stroke being painted (the render shows it once it's committed), with the committed
// stroke's dab spacing, feather and flow (issue #517)
if let Some(Gesture::Brush { points, pressure }) = &app.gesture {
let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect();
let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
let params = crate::brush_live::LiveParams {
clip,
scale: ui.ctx().pixels_per_point(),
r,
hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)),
flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0),
density: 1.0,
color: app.ui.mask_overlay_color,
opacity: opacity.clamp(0.0, 1.0),
};
crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure);
} else {
app.brush_live = None;
}
if (resp.drag_stopped() || resp.clicked())
&& let Some(Gesture::Brush { points, pressure }) = app.gesture.take()
{
app.brush_live = None;
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
// holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing
let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift));
let erase = app.ui.brush_erase != alt;
// a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom
let connect = shift && resp.clicked() && pts.len() == 1;
let _ = app.run(
"mask.brushStroke",
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}),
);
}
return;
}
}
/// Brush tool: the cursor, the stroke being painted and its commit.
fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) {
let long = (map.rect.width().max(map.rect.height())) as f64;
let r = (app.ui.brush_size as f64 * long) as f32;
if let Some(h) = resp.hover_pos() {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120)));
}
if let Some(q) = resp.interact_pointer_pos()
&& (resp.drag_started() || resp.dragged() || resp.clicked())
{
let n = map.norm(q);
let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None };
match &mut app.gesture {
// a held, unmoving pointer records nothing: coverage builds by passes, not time (#518)
Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {}
Some(Gesture::Brush { points, pressure }) => {
record_pressure(pressure, points.len(), force);
points.push(n);
}
_ => {
let mut pressure = Vec::new();
record_pressure(&mut pressure, 0, force);
app.gesture = Some(Gesture::Brush { points: vec![n], pressure });
}
}
}
// the stroke being painted (the render shows it once it's committed), with the committed
// stroke's dab spacing, feather and flow (issue #517)
if let Some(Gesture::Brush { points, pressure }) = &app.gesture {
let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect();
let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
let params = crate::brush_live::LiveParams {
clip,
scale: ui.ctx().pixels_per_point(),
r,
hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)),
flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0),
density: 1.0,
color: app.ui.mask_overlay_color,
opacity: opacity.clamp(0.0, 1.0),
};
crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure);
} else {
app.brush_live = None;
}
if (resp.drag_stopped() || resp.clicked())
&& let Some(Gesture::Brush { points, pressure }) = app.gesture.take()
{
app.brush_live = None;
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
// holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing
let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift));
let erase = app.ui.brush_erase != alt;
// a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom
let connect = shift && resp.clicked() && pts.len() == 1;
let _ = app.run(
"mask.brushStroke",
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}),
);
}
}
/// Grabbing mask grips and component bodies: select, drag, release.
fn grip_gestures(
app: &mut LightcraftApp,
ui: &egui::Ui,
resp: &egui::Response,
map: &CanvasMap,
d: &DevelopSettings,
grips: &[(u32, usize, u8, Pos2)],
) {
let active = app.session.active_mask;
// the grip under the pointer: the selected mask's first, then the nearest
let hit = |q: Pos2| {
grips