lightcraft/crates/ui-egui/src/panels/masking.rs

914 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The Masking panel: create masks, list them, edit components and local adjustments.
use egui::{Rect, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};
use serde_json::json;
use super::edit::apply_slider_out;
use super::right::header;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{divider, icon_button, one_line, register, slider, text_button};
const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {
ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }
}
/// Local adjustment sliders (key = `LocalAdjustments` field name).
pub const LOCAL: &[ControlSpec] = &[
spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp),
spec("tint", "Tint", -100.0, 100.0, 1.0, 0, Track::Tint),
spec("exposure", "Exposure", -4.0, 4.0, 0.01, 2, Track::Centered),
spec("contrast", "Contrast", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("highlights", "Highlights", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("shadows", "Shadows", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("whites", "Whites", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("blacks", "Blacks", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("texture", "Texture", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("clarity", "Clarity", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("dehaze", "Dehaze", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("hue", "Hue", -100.0, 100.0, 1.0, 0, Track::Rainbow),
spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),
spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered),
];
pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 {
serde_json::to_value(a).ok().and_then(|v| v.get(key).and_then(|x| x.as_f64())).unwrap_or(0.0)
}
fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
match s {
MaskShape::Brush { .. } => ("Brush", Icon::Brush),
MaskShape::Linear { .. } => ("Linear Gradient", Icon::Linear),
MaskShape::Radial { .. } => ("Radial Gradient", Icon::Radial),
MaskShape::ColorRange { .. } => ("Color Range", Icon::Picker),
MaskShape::LuminanceRange { .. } => ("Luminance Range", Icon::Sliders),
MaskShape::DepthRange { .. } => ("Depth Range", Icon::Sliders),
MaskShape::Subject => ("Subject", Icon::Subject),
MaskShape::Sky => ("Sky", Icon::Sky),
MaskShape::Background => ("Background", Icon::Subject),
MaskShape::Object { .. } => ("Object", Icon::Subject),
MaskShape::Prompt { .. } => ("Describe", Icon::Subject),
MaskShape::People { .. } => ("People", Icon::Subject),
MaskShape::Landscape { .. } => ("Landscape", Icon::Sky),
}
}
const TILE: f32 = 52.0;
const TILE_GAP: f32 = 6.0;
/// Room left and right of a tile's label.
const TILE_PAD: f32 = 3.0;
/// Columns, tile width and label size of the Create New Mask grid in `width`, `label(size)` being
/// the widest label at a font size: four tiles of up to 52 pt (at least 48), else three, else two —
/// the first that fits the labels at 10.5 pt, or at 9.5 pt before a column is dropped. A tile grows
/// past 52 pt for a long (translated) label. In a panel too narrow even for that, two tiles whose
/// labels are cut short.
fn tile_layout(width: f32, label: impl Fn(f32) -> f32) -> (usize, f32, f32) {
let room = |n: usize| ((width - (n as f32 - 1.0) * TILE_GAP) / n as f32).floor();
for n in [4, 3, 2] {
let w = room(n);
if n == 4 && w < 48.0 {
continue;
}
for size in [10.5, 9.5] {
let need = (label(size) + 2.0 * TILE_PAD).ceil();
if need <= w {
return (n, w.min(TILE.max(need)), size);
}
}
}
(2, room(2).max(24.0), 9.5)
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Masking");
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Create New Mask")).color(t.text_dim));
ui.add_space(6.0);
let tiles: [(&str, &str, Icon); 10] = [
("object", "Object", Icon::Subject),
("prompt", "Describe", Icon::Subject),
("subject", "Subject", Icon::Subject),
("sky", "Sky", Icon::Sky),
("background", "Background", Icon::Subject),
("brush", "Brush", Icon::Brush),
("linear", "Linear", Icon::Linear),
("radial", "Radial", Icon::Radial),
("luminanceRange", "Luminance", Icon::Sliders),
("colorRange", "Color", Icon::Picker),
];
// four 52 pt tiles a row when they and their labels fit, else fewer (wider for a long label)
let widest = |size: f32| {
let font = t.font(size);
tiles
.iter()
.map(|(_, l, _)| ui.painter().layout_no_wrap(crate::i18n::tr(l).to_string(), font.clone(), t.text_dim).size().x)
.fold(0.0, f32::max)
};
let (cols, tile, size) = tile_layout(ui.available_width(), widest);
egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| {
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click());
register(ui.ctx(), format!("maskNew:{kind}"), r);
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
// (cut short only in a panel too narrow for the label: the full name on hover)
let label = crate::i18n::tr(label);
let g = one_line(ui.painter(), label, t.font(size), t.text_dim, tile - 2.0 * TILE_PAD);
let elided = g.elided;
let at = pos2(r.center().x, r.bottom() - 9.0) - g.size() / 2.0;
register(ui.ctx(), format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size()));
ui.painter().galley(at, g, t.text_dim);
let resp = if elided { resp.on_hover_text(label) } else { resp };
if resp.clicked() {
match *kind {
"colorRange" => {
// an empty colour range; clicking the photo samples it
let _ = app.run("mask.add", json!({"kind": "colorRange"}));
app.ui.tool = "colorRange".into();
app.toast(ui.ctx(), crate::i18n::tr("Click the photo to pick a colour · ⇧-click adds more"));
}
"object" => start_object(app, ui.ctx(), "new"),
"prompt" => start_describe(app, "new"),
"brush" => {
let _ = app.run("tool.brush", json!({"new": true}));
}
"linear" | "radial" => {
app.ui.tool = kind.to_string();
let _ = app.run("mask.add", json!({"kind": kind}));
}
k => {
let _ = app.run("mask.add", json!({"kind": k}));
}
}
}
if i % cols == cols - 1 {
ui.end_row();
}
}
});
describe_field(app, ui, true);
let seg = &app.session.segmenter;
let status = if seg.analyzing() {
Some("Analyzing the photo for AI masks…")
} else if seg.busy() {
Some("Selecting…")
} else if seg.detail_busy() || app.ui.detail_due.is_some() {
Some("Refining the mask's detail…")
} else {
None
};
if let Some(status) = status {
ui.add_space(4.0);
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap());
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
}
// the model download, while its dialog is closed
let download = seg.download_status();
if download.running && app.ui.dialog.is_none() {
ui.add_space(4.0);
ui.horizontal(|ui| {
let pct = if download.total > 0 { download.done as f64 / download.total as f64 } else { 0.0 };
let r = ui.add(
egui::ProgressBar::new(pct as f32).desired_width(ui.available_width() - 70.0).text(format!("SAM 3: {} %", (pct * 100.0).floor())),
);
register(ui.ctx(), "maskSamProgress", r.rect);
if text_button(ui, "maskSamDetails", crate::i18n::tr("Details"), false).clicked() {
app.offer_sam_download(None);
}
});
}
});
divider(ui);
// mask list
egui::Frame::NONE.inner_margin(egui::Margin { left: 16, right: 16, top: 8, bottom: 8 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Masks")).font(t.semibold(13.0)).color(t.text_label));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let on = app.ui.mask_overlay;
if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() {
app.ui.mask_overlay = !on;
}
let all = app.ui.mask_overlay_all;
let all_mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
let all_base = crate::i18n::tr("Show all overlays");
let all_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.maskOverlayAll") {
Some(sc) => format!("{all_base} ({})", crate::menubar::shortcut_text(sc, all_mac)),
None => all_base.to_string(),
};
if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, &all_tip).clicked() {
app.ui.mask_overlay_all = !all;
}
});
});
if d.masks.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr("No masks yet. Choose a mask type above.")).color(t.text_dim));
}
let count = d.masks.len();
for (index, m) in d.masks.iter().enumerate() {
let sel = app.session.active_mask == Some(m.id);
if let Some((rid, name)) = app.ui.renaming_mask.as_mut().filter(|(rid, _)| *rid == m.id) {
// inline rename: Enter (or leaving the field) commits, Escape cancels
let rid = *rid;
let r = ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width()).id_salt(("maskRename", rid)));
register(ui.ctx(), format!("maskRename:{rid}"), r.rect);
if !r.has_focus() && !r.lost_focus() {
r.request_focus();
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.renaming_mask = None;
} else if r.lost_focus() {
let name = name.trim().to_string();
app.ui.renaming_mask = None;
if !name.is_empty() && name != m.name {
let _ = app.run("mask.rename", json!({"id": rid, "name": name}));
}
}
continue;
}
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
register(ui.ctx(), format!("mask:{}", m.id), r);
ui.painter().rect_filled(
r,
4.0,
if sel {
t.tool_active
} else if resp.hovered() {
t.hover.gamma_multiply(0.7)
} else {
t.chrome
},
);
let icon = m.components.first().map(|c| kind_label(&c.shape).1).unwrap_or(Icon::Mask);
paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, t.text_label);
// a long name is cut short (with …) before the eye, the full name on hover
let color = if m.visible { t.text } else { t.text_disabled };
let g = one_line(ui.painter(), &m.name, t.font(13.0), color, r.width() - 32.0 - 32.0);
let elided = g.elided;
let at = pos2(r.left() + 32.0, r.center().y - g.size().y / 2.0);
register(ui.ctx(), format!("maskName:{}", m.id), Rect::from_min_size(at, g.size()));
ui.painter().galley(at, g, color);
let resp = if elided { resp.on_hover_text(&m.name) } else { resp };
// show / hide on hover (and always while hidden)
// (the pointer test, not `hovered`: over the eye, the row itself no longer counts as hovered)
if ui.rect_contains_pointer(r) || !m.visible {
let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0));
register(ui.ctx(), format!("maskVisible:{}", m.id), er);
let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click());
paint(
ui.painter(),
er.shrink(3.0),
if m.visible { Icon::Eye } else { Icon::EyeOff },
if eye.hovered() { t.text } else { t.text_dim },
);
if eye.clicked() {
let _ = app.run("mask.visible", json!({"id": m.id}));
}
}
if resp.clicked() {
let _ = app.run("mask.select", json!({"id": m.id}));
}
if resp.double_clicked() {
app.ui.renaming_mask = Some((m.id, m.name.clone()));
}
resp.context_menu(|ui| mask_menu(app, ui, m.id, &m.name, m.visible, index, count));
if sel {
// right under the selected mask: + adds to it, − takes away from it (any mask
// type; Describe… opens its field here)
ui.horizontal(|ui| {
ui.add_space(30.0);
let plus = text_button(ui, &format!("maskPlus:{}", m.id), "+", false).on_hover_text(crate::i18n::tr("Add to this mask"));
egui::Popup::menu(&plus).show(|ui| component_menu(app, ui, "add"));
let minus = text_button(ui, &format!("maskMinus:{}", m.id), "−", false).on_hover_text(crate::i18n::tr("Subtract from this mask"));
egui::Popup::menu(&minus).show(|ui| component_menu(app, ui, "subtract"));
});
describe_field(app, ui, false);
}
}
if !d.masks.is_empty() {
ui.add_space(6.0);
overlay_options(app, ui);
}
});
let Some(mid) = app.session.active_mask else { return };
let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return };
divider(ui);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 8 }).show(ui, |ui| {
for (i, c) in m.components.iter().enumerate() {
let (kind, icon) = kind_label(&c.shape);
let label = c.name.clone().unwrap_or_else(|| match &c.shape {
MaskShape::Prompt { text, .. } => format!("“{text}”"),
_ => menu_label(kind).to_string(),
});
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
paint(ui.painter(), r, icon, t.text_label);
let op = match c.op {
lightcraft_develop::MaskOp::Add => "",
lightcraft_develop::MaskOp::Subtract => "− ",
lightcraft_develop::MaskOp::Intersect => "∩ ",
};
if let Some((_, _, name)) = app.ui.renaming_component.as_mut().filter(|(mid, k, _)| *mid == m.id && *k == i) {
// inline rename: Enter (or leaving the field) commits, Escape cancels
let r = ui.add(egui::TextEdit::singleline(name).desired_width(ui.available_width() - 30.0).id_salt(("compRename", m.id, i)));
register(ui.ctx(), format!("componentRename:{i}"), r.rect);
if !r.has_focus() && !r.lost_focus() {
r.request_focus();
}
if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) {
app.ui.renaming_component = None;
} else if r.lost_focus() {
let name = name.trim().to_string();
app.ui.renaming_component = None;
if Some(&name) != c.name.as_ref() {
let _ = app.run("mask.component", json!({"id": m.id, "component": i, "action": "rename", "name": name}));
}
}
return;
}
// a long name is cut short (with …) before the options button, not past the panel
let text = format!("{op}{label}{}", if c.invert { crate::i18n::tr(" (inverted)") } else { "" });
let room = (ui.available_width() - 30.0).max(0.0);
let resp = ui.scope(|ui| {
ui.set_max_width(room);
ui.add(egui::Label::new(text).truncate().sense(Sense::click()))
});
let resp = resp.inner;
register(ui.ctx(), format!("component:{i}"), resp.rect);
if resp.double_clicked() {
app.ui.renaming_component = Some((m.id, i, label.clone()));
}
resp.context_menu(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let more = ui.small_button("…").on_hover_text(crate::i18n::tr("Component options"));
register(ui.ctx(), format!("button:componentMenu{i}"), more.rect);
egui::Popup::menu(&more).show(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len()));
});
});
range_controls(app, ui, i, &c.shape);
}
ui.add_space(6.0);
// wraps: the five actions are wider than a narrow panel
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.y = 6.0;
let add = text_button(ui, "maskAddComp", crate::i18n::tr("Add"), false);
egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add"));
let sub = text_button(ui, "maskSubComp", crate::i18n::tr("Subtract"), false);
egui::Popup::menu(&sub).show(|ui| component_menu(app, ui, "subtract"));
let int = text_button(ui, "maskIntComp", crate::i18n::tr("Intersect"), false);
egui::Popup::menu(&int).show(|ui| component_menu(app, ui, "intersect"));
if text_button(ui, "maskInvert", crate::i18n::tr("Invert"), m.invert).clicked() {
let _ = app.run("mask.invert", json!({}));
}
if icon_button(ui, "maskDelete", Icon::Trash, vec2(26.0, 24.0), false, true, "Delete mask").clicked() {
let _ = app.run("mask.delete", json!({}));
}
});
});
if app.ui.tool == "brush" {
divider(ui);
brush_settings(app, ui);
}
divider(ui);
for s in LOCAL {
let v = local_get(&m.adjust, s.id);
let out = slider(ui, s, v, true, None);
apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}})));
}
let amt = ControlSpec {
id: "amount",
label: "Amount",
section: Section::Light,
min: 0.0,
max: 200.0,
default: 100.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &amt, m.adjust.amount, true, None);
apply_slider_out(app, &amt, out, |app, v| app.run("mask.adjust", json!({"values": {"amount": v}})));
let refine = ControlSpec {
id: "refine",
label: "Refine Edges",
section: Section::Light,
min: 0.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &refine, m.refine, true, None);
apply_slider_out(app, &refine, out, |app, v| app.run("mask.refine", json!({"value": v})));
ui.add_space(30.0);
let _ = Stroke::NONE;
}
/// Translate fixed action labels, but never mutate or translate user-entered mask names.
fn menu_label(source: &str) -> &str {
crate::i18n::tr(source)
}
/// The right-click menu of a mask in the Masks list.
fn mask_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: u32, name: &str, visible: bool, index: usize, count: usize) {
let mut run = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, p: serde_json::Value| {
if ui.add_enabled(enabled, egui::Button::new(menu_label(label))).clicked() {
let _ = app.run(cmd, p);
ui.close();
}
};
run(ui, "Duplicate Mask", true, "mask.duplicate", json!({"id": id}));
run(ui, "Duplicate and Invert Mask", true, "mask.duplicate", json!({"id": id, "invert": true}));
run(ui, "Invert Mask", true, "mask.invert", json!({"id": id}));
run(ui, if visible { "Hide Mask" } else { "Show Mask" }, true, "mask.visible", json!({"id": id}));
ui.separator();
run(ui, "Move Up", index > 0, "mask.move", json!({"id": id, "delta": -1}));
run(ui, "Move Down", index + 1 < count, "mask.move", json!({"id": id, "delta": 1}));
ui.separator();
if ui.button(crate::i18n::tr("Rename…")).clicked() {
app.ui.renaming_mask = Some((id, name.to_string()));
ui.close();
}
if ui.button(crate::i18n::tr("Delete Mask")).clicked() {
let _ = app.run("mask.delete", json!({"id": id}));
ui.close();
}
}
/// Overlay colours offered as swatches (the colour of the selected one is used for the tint).
pub const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]];
/// How the selected mask is shown: overlay mode, colour, opacity, pins.
fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use lightcraft_pipeline::MaskView;
let t = Tokens::get(ui.ctx());
let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Overlay")).color(t.text_dim));
let r = crate::widgets::dropdown(ui, "maskOverlayMode", menu_label(view.label()), t.font(12.5), t.text_label);
egui::Popup::menu(&r).show(|ui| {
for v in MaskView::ALL {
if ui.selectable_label(v == view, menu_label(v.label())).clicked() {
let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()}));
}
}
});
});
let colored = matches!(view, MaskView::Color | MaskView::ColorOnBw);
if colored {
ui.horizontal(|ui| {
for c in OVERLAY_COLORS {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r);
ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
if app.ui.mask_overlay_color == c {
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside);
}
if resp.clicked() {
let _ = app.run("view.maskOverlayColor", json!({"color": c}));
}
}
});
}
let spec = ControlSpec {
id: "ui.maskOverlayOpacity",
label: "Opacity",
section: Section::Light,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None);
if let Some(v) = out.value {
app.ui.mask_overlay_opacity = v as f32;
}
let mut pins = app.ui.mask_pins;
if ui.checkbox(&mut pins, crate::i18n::tr("Show Pins")).changed() {
let _ = app.run("view.maskPins", json!({"show": pins}));
}
}
/// Controls for a range component: the selected range (two handles over a dark → light bar),
/// Smoothness, and Show Luminance Map; colour ranges get Refine.
fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape: &MaskShape) {
let t = Tokens::get(ui.ctx());
let update = |app: &mut LightcraftApp, shape: MaskShape| {
let _ = app.run("mask.update", json!({"component": comp, "shape": shape}));
};
match shape {
MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => {
ui.label(egui::RichText::new(crate::i18n::tr("Select Luminance Range")).color(t.text_dim).size(11.5));
let (lo, hi) = (*lo, *hi);
let w = ui.available_width().min(240.0);
let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag());
register(ui.ctx(), format!("lumRange:{comp}"), rect);
let p = ui.painter();
// dark → light ramp, the selected span outlined
let n = 24;
for k in 0..n {
let a = k as f32 / n as f32;
let r = Rect::from_min_max(
pos2(rect.left() + a * w, rect.top() + 4.0),
pos2(rect.left() + (a + 1.0 / n as f32) * w + 0.5, rect.bottom() - 4.0),
);
let g = (a * 255.0) as u8;
p.rect_filled(r, 0.0, egui::Color32::from_gray(g));
}
let x = |v: f64| rect.left() + v.clamp(0.0, 1.0) as f32 * w;
p.rect_stroke(
Rect::from_min_max(pos2(x(lo), rect.top() + 2.0), pos2(x(hi), rect.bottom() - 2.0)),
2.0,
Stroke::new(1.5, t.accent),
egui::StrokeKind::Middle,
);
for v in [lo, hi] {
p.circle_filled(pos2(x(v), rect.center().y), 5.0, egui::Color32::WHITE);
p.circle_stroke(pos2(x(v), rect.center().y), 5.0, Stroke::new(1.0, egui::Color32::from_gray(40)));
}
// drag the nearer handle; one undo step per drag
if resp.drag_started() {
let _ = app.run("develop.beginInteraction", json!({"label": "Luminance Range"}));
}
if (resp.dragged() || resp.clicked())
&& let Some(pos) = resp.interact_pointer_pos()
{
let v = (((pos.x - rect.left()) / w) as f64).clamp(0.0, 1.0);
let (nlo, nhi) = if (v - lo).abs() <= (v - hi).abs() { (v.min(hi - 0.01), hi) } else { (lo, v.max(lo + 0.01)) };
update(app, MaskShape::LuminanceRange { lo: nlo, hi: nhi, lo_feather: *lo_feather, hi_feather: *hi_feather });
}
if resp.drag_stopped() {
let _ = app.run("develop.endInteraction", json!({}));
}
// Smoothness: both falloffs at once
let smooth = ControlSpec {
id: "smoothness",
label: "Smoothness",
section: Section::Light,
min: 0.0,
max: 100.0,
default: 20.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let cur = ((lo_feather + hi_feather) / 2.0 / 0.5 * 100.0).clamp(0.0, 100.0);
let out = slider(ui, &smooth, cur, true, None);
apply_slider_out(app, &smooth, out, |app, v| {
let f = v / 100.0 * 0.5;
app.run("mask.update", json!({"component": comp, "shape": MaskShape::LuminanceRange { lo, hi, lo_feather: f, hi_feather: f }}))
});
let mut map = app.ui.mask_overlay && app.ui.mask_overlay_mode == "colorOnBw";
let r = ui.checkbox(&mut map, crate::i18n::tr("Show Luminance Map"));
register(ui.ctx(), format!("check:lumMap{comp}"), r.rect);
if r.changed() {
// the luminance map: the photo in black & white with the selected range tinted
if map {
app.ui.luminance_map_restore = Some((app.ui.mask_overlay, app.ui.mask_overlay_mode.clone()));
app.ui.mask_overlay = true;
app.ui.mask_overlay_mode = "colorOnBw".into();
} else {
let (on, mode) = app.ui.luminance_map_restore.take().unwrap_or((false, "color".into()));
app.ui.mask_overlay = on;
app.ui.mask_overlay_mode = if mode == "colorOnBw" { "color".into() } else { mode };
}
}
}
MaskShape::Object { edge, .. } | MaskShape::Prompt { edge, .. } => {
// Edge: how crisp the selection's border is (−100 hard … 0 as computed … 100 soft)
let spec = ControlSpec {
id: "edge",
label: "Edge",
section: Section::Light,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Track::Centered,
};
let out = slider(ui, &spec, *edge, true, None);
let base = shape.clone();
apply_slider_out(app, &spec, out, |app, v| {
let mut s = base.clone();
if let MaskShape::Object { edge, .. } | MaskShape::Prompt { edge, .. } = &mut s {
*edge = v.clamp(-100.0, 100.0);
}
app.run("mask.update", json!({"component": comp, "shape": s}))
});
ui.label(egui::RichText::new(crate::i18n::tr("− harder border · + softer border")).color(t.text_dim).size(11.0));
}
MaskShape::ColorRange { samples, refine } => {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(crate::i18n::tr_format!("{} sample{}", samples.len(), if samples.len() == 1 { "" } else { "s" }))
.color(t.text_dim)
.size(11.5),
);
let picking = app.ui.tool == "colorRange";
if text_button(ui, &format!("colorPick{comp}"), crate::i18n::tr("Pick"), picking)
.on_hover_text(crate::i18n::tr("Click the photo to pick a colour; ⇧-click adds more (up to 5)"))
.clicked()
{
app.ui.tool = if picking { String::new() } else { "colorRange".into() };
}
});
let spec = ControlSpec {
id: "refine",
label: "Refine",
section: Section::Color,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &spec, *refine, true, None);
let samples = samples.clone();
apply_slider_out(app, &spec, out, |app, v| {
app.run("mask.update", json!({"component": comp, "shape": MaskShape::ColorRange { samples: samples.clone(), refine: v }}))
});
}
_ => {}
}
}
/// The right-click / "…" menu of one component of the selected mask.
fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: usize, label: &str, count: usize) {
let mut run = |ui: &mut egui::Ui, text: &str, p: serde_json::Value| {
if ui.button(menu_label(text)).clicked() {
let mut p = p;
p["id"] = json!(mask);
p["component"] = json!(k);
let _ = app.run("mask.component", p);
ui.close();
}
};
run(ui, "Invert", json!({"action": "invert"}));
run(ui, &crate::i18n::tr_format!("Duplicate \"{label}\"", label = label), json!({"action": "duplicate"}));
if k > 0 {
ui.menu_button(crate::i18n::tr("Mode"), |ui| {
for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] {
run(ui, text, json!({"action": "op", "op": op}));
}
});
}
ui.menu_button(crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect"));
ui.menu_button(crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract"));
ui.separator();
if ui.button(crate::i18n::tr("Rename…")).clicked() {
app.ui.renaming_component = Some((mask, k, label.to_string()));
ui.close();
}
let del = if count == 1 {
crate::i18n::tr_format!("Delete \"{label}\" (and the mask)", label = label)
} else {
crate::i18n::tr_format!("Delete \"{label}\"", label = label)
};
if ui.button(del).clicked() {
let _ = app.run("mask.component", json!({"id": mask, "component": k, "action": "delete"}));
ui.close();
}
}
/// Whether the SAM 3 model is missing in a build that could use it: then the download is
/// offered (with `then` to start afterwards) instead of starting an AI mask.
fn needs_model(app: &mut LightcraftApp, kind: &str, op: &str) -> bool {
let seg = &app.session.segmenter;
let missing = lightcraft_engine::segment::Segmenter::AVAILABLE && seg.remote_endpoint().is_none() && seg.dir.is_some() && !seg.installed();
if missing {
app.offer_sam_download(Some((kind, op)));
}
missing
}
/// Start an Object selection (SAM 3 clicks): a new mask, or a component of the selected one
/// combined by `op`; the photo is analyzed meanwhile (in the background).
pub(crate) fn start_object(app: &mut LightcraftApp, ctx: &egui::Context, op: &str) {
if needs_model(app, "object", op) {
return;
}
let r =
if op == "new" { app.run("mask.add", json!({"kind": "object"})) } else { app.run("mask.addComponent", json!({"op": op, "kind": "object"})) };
match r {
Ok(_) => {
app.ui.tool = "object".into();
app.toast(ctx, crate::i18n::tr("Click the object to select it · ⌥-click leaves a part out"));
}
Err(e) => app.ai_error(ctx, e, Some(("object", op))),
}
}
/// Start an AI mask of `kind` (object|prompt) combined by `op` (new|add|subtract|intersect),
/// after the model was installed.
pub(crate) fn begin_ai(app: &mut LightcraftApp, kind: &str, op: &str) -> Result<serde_json::Value, String> {
match kind {
"object" => {
let r = if op == "new" {
app.run("mask.add", json!({"kind": "object"}))
} else {
app.run("mask.addComponent", json!({"op": op, "kind": "object"}))
}?;
app.ui.tool = "object".into();
Ok(r)
}
_ => {
app.ui.describe = Some((op.to_string(), String::new()));
Ok(serde_json::Value::Null)
}
}
}
/// Open the Describe field (a new mask, or a component combined by `op`).
pub(crate) fn start_describe(app: &mut LightcraftApp, op: &str) {
if needs_model(app, "prompt", op) {
return;
}
app.ui.describe = Some((op.to_string(), String::new()));
}
/// The Describe field: type what to select ("sky", "the red car") and press Return.
fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
let Some((op, mut text)) = app.ui.describe.clone() else { return };
// a new mask's field sits under the tiles; one that combines, under the selected mask
if (op == "new") != new_mask {
return;
}
ui.add_space(8.0);
let prompt = match op.as_str() {
"subtract" => "Describe what to leave out",
"intersect" => "Describe what to keep",
_ => "Describe what to select",
};
ui.label(crate::i18n::tr(prompt));
let mut submit = false;
// the (translated) Select button is placed first, at the right; the field takes what is left
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
submit = true;
}
let r = ui.add(
egui::TextEdit::singleline(&mut text)
.hint_text(crate::i18n::tr("e.g. sky · the red car · car, road"))
.desired_width(ui.available_width()),
);
register(ui.ctx(), "maskDescribe", r.rect);
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
r.request_focus();
}
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
submit = true;
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.describe = None;
return;
}
app.ui.describe = Some((op.clone(), text.clone()));
if submit && !text.trim().is_empty() {
let r = if op == "new" {
app.run("mask.add", json!({"kind": "prompt", "text": text}))
} else {
app.run("mask.addComponent", json!({"op": op, "kind": "prompt", "text": text}))
};
match r {
// the mask appears when the model has found it (a detail pass follows)
Ok(_) => app.ui.describe = None,
Err(e) => app.ai_error(ui.ctx(), e, Some(("prompt", op.as_str()))),
}
}
}
fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) {
let b = ui.button(crate::i18n::tr("Object"));
register(ui.ctx(), format!("maskComp:{op}:object"), b.rect);
if b.clicked() {
start_object(app, ui.ctx(), op);
}
let b = ui.button(crate::i18n::tr("Describe…"));
register(ui.ctx(), format!("maskComp:{op}:prompt"), b.rect);
if b.clicked() {
start_describe(app, op);
}
for (kind, label) in [
("brush", "Brush"),
("linear", "Linear Gradient"),
("radial", "Radial Gradient"),
("sky", "Sky"),
("subject", "Subject"),
("luminanceRange", "Luminance Range"),
] {
// painting only adds or erases
if kind == "brush" && op == "intersect" {
continue;
}
let b = ui.button(menu_label(label));
register(ui.ctx(), format!("maskComp:{op}:{kind}"), b.rect);
if b.clicked() {
if kind == "brush" {
let _ = app.run("tool.brush", json!({"op": op}));
} else {
let _ = app.run("mask.addComponent", json!({"op": op, "kind": kind}));
}
}
}
}
fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 0 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Brush")).font(t.semibold(13.0)));
if text_button(ui, "brushAdd", crate::i18n::tr("Add"), !app.ui.brush_erase).clicked() {
app.ui.brush_erase = false;
}
if text_button(ui, "brushErase", crate::i18n::tr("Erase"), app.ui.brush_erase).clicked() {
app.ui.brush_erase = true;
}
});
let mut auto = app.ui.brush_auto_mask;
if ui
.checkbox(&mut auto, crate::i18n::tr("Auto Mask"))
.on_hover_text(crate::i18n::tr("Paint only areas like the one under the brush"))
.changed()
{
app.ui.brush_auto_mask = auto;
}
});
for (id, label, min, max, get) in [
("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64),
("ui.brushFeather", "Feather", 0.0, 100.0, app.ui.brush_feather as f64),
("ui.brushFlow", "Flow", 1.0, 100.0, app.ui.brush_flow as f64),
] {
let s = ControlSpec { id, label, section: Section::Light, min, max, default: min, step: 1.0, decimals: 0, track: Track::Plain };
let out = slider(ui, &s, get, true, None);
if let Some(v) = out.value {
match id {
"ui.brushSize" => app.ui.brush_size = (v / 400.0) as f32,
"ui.brushFeather" => app.ui.brush_feather = v as f32,
_ => app.ui.brush_flow = v as f32,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn masking_context_actions_use_catalog_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (english, translated) in [
("Duplicate Mask", "Dupliquer le masque"),
("Invert Mask", "Inverser le masque"),
("Hide Mask", "Masquer le masque"),
("Move Up", "Monter"),
("Brush", "Pinceau"),
("Luminance Range", "Plage de luminance"),
] {
assert_eq!(menu_label(english), translated, "{english}");
}
// An arbitrary user name has no catalog entry and is left unchanged.
assert_eq!(menu_label("My custom mask"), "My custom mask");
crate::i18n::set_language(saved);
}
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
/// room by dropping a column rather than drawing it past its tile.
#[test]
fn tile_labels_get_the_room_they_need() {
let short = |size: f32| size * 4.0; // "Luminance"-ish: 42 pt at 10.5
let long = |size: f32| size * 5.6; // "Background"/"Hintergrund": 59 pt at 10.5, 53 at 9.5
assert_eq!(tile_layout(224.0, short), (4, 51.0, 10.5));
// too wide for four at either size: three tiles, wider than 52 so the label fits
let (n, w, size) = tile_layout(224.0, long);
assert_eq!((n, size), (3, 10.5));
assert!(w >= long(10.5) + 2.0 * TILE_PAD && w <= (224.0 - 2.0 * TILE_GAP) / 3.0, "{w}");
// a smaller label keeps a column: 9.5 pt fits where 10.5 doesn't
let mid = |size: f32| size * 4.6; // 48.3 at 10.5, 43.7 at 9.5
assert_eq!(tile_layout(224.0, mid), (4, 51.0, 9.5));
// a wide panel: tiles stay 52 pt (or as wide as the label)
assert_eq!(tile_layout(474.0, short).0, 4);
assert_eq!(tile_layout(474.0, short).1, TILE);
// far too narrow for any label: two tiles (labels cut short), never a negative width
let (n, w, _) = tile_layout(60.0, long);
assert_eq!(n, 2);
assert!(w >= 24.0);
assert_eq!(tile_layout(f32::NAN, long).0, 2);
}
}