photocraft/crates/ui-egui/src/canvas.rs

1475 lines
70 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.

//! Document canvas: display texture cache, view transform, tool input, extra document windows.
//!
//! Rendering is CPU (`photocraft-compose`) for now, uploaded into an egui texture. Brush strokes
//! update only their damage rectangle (`set_partial`). The wgpu compositor (M5) will replace this
//! with direct GPU rendering behind the same `CanvasCache` interface.
use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureOptions, Vec2, pos2, vec2};
use photocraft_doc::{Document, LayerContent};
use photocraft_geom::Rect as DRect;
use serde_json::json;
use crate::state::{Tool, View};
use crate::PhotocraftApp;
/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands.
pub const MAX_TEXTURE: u32 = 4096;
pub struct CanvasCache {
pub revision: u64,
pub texture: Option<egui::TextureHandle>,
/// Display pixels per document pixel of the cached image (≤ 1).
pub scale: f32,
/// Hash of the live-adjust preview used for this render (0 = none).
pub preview_key: u64,
/// The current image lives in the GPU canvas (`gpu_canvas`) rather than `texture`.
pub on_gpu: bool,
/// Revision and preview key `texture` was rendered at. Tracked apart from the GPU canvas's
/// `revision`, so the Navigator never shows a stale CPU image after GPU-path edits.
pub tex_revision: u64,
pub tex_preview_key: u64,
}
/// An in-progress pointer gesture on the canvas.
#[derive(Clone, Debug)]
pub struct Drag {
pub tool: Tool,
pub start: [f64; 2],
pub points: Vec<[f64; 3]>,
pub modifiers: egui::Modifiers,
}
/// Abstract tool event, produced by the mouse or by automation (`ui.pointer`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ToolEvent {
Down { x: f64, y: f64, pressure: f32 },
Move { x: f64, y: f64, pressure: f32 },
Up { x: f64, y: f64 },
}
/// Document ↔ screen mapping for a canvas rect and a view.
#[derive(Clone, Copy, Debug)]
pub struct ViewXform {
pub rect: Rect,
pub zoom: f32,
pub center: [f32; 2],
/// View › Flip Horizontal: the view is mirrored left-right (the document is not).
pub flip: bool,
}
impl ViewXform {
pub fn to_screen(&self, x: f32, y: f32) -> Pos2 {
let sx = if self.flip { -1.0 } else { 1.0 };
self.rect.center() + vec2((x - self.center[0]) * self.zoom * sx, (y - self.center[1]) * self.zoom)
}
pub fn to_doc(&self, p: Pos2) -> [f64; 2] {
let d = (p - self.rect.center()) / self.zoom;
let dx = if self.flip { -d.x } else { d.x };
[(dx + self.center[0]) as f64, (d.y + self.center[1]) as f64]
}
pub fn doc_rect(&self, r: DRect) -> Rect {
Rect::from_two_pos(self.to_screen(r.x0 as f32, r.y0 as f32), self.to_screen(r.x1 as f32, r.y1 as f32))
}
}
pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
let zoom = ((area.x - 40.0) / w).min((area.y - 40.0) / h).clamp(0.01, 1.0);
view.zoom = zoom;
view.center = [w / 2.0, h / 2.0];
view.fit_pending = false;
}
/// Zoom steps like Photoshop's (⌘+ / ⌘−).
pub fn zoom_step(z: f32, dir: i32) -> f32 {
const STEPS: [f32; 22] = [0.01, 0.02, 0.03, 0.05, 0.0667, 0.1, 0.125, 0.1667, 0.25, 0.333, 0.5, 0.6667, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0, 32.0];
if dir > 0 {
STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(32.0)
} else {
STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.01)
}
}
fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId {
// Preferences › Transparency & Gamut colours (the texture is rebuilt when they change).
let [ca, cb] = app.session.prefs().transparency_and_gamut.colors();
if app.prefs_rt.checker_key != Some([ca, cb]) {
app.prefs_rt.checker_key = Some([ca, cb]);
app.checker = None;
}
app.checker
.get_or_insert_with(|| {
let (a, b) = (Color32::from_rgb(ca[0], ca[1], ca[2]), Color32::from_rgb(cb[0], cb[1], cb[2]));
let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]);
let opts = TextureOptions { magnification: egui::TextureFilter::Nearest, minification: egui::TextureFilter::Nearest, wrap_mode: egui::TextureWrapMode::Repeat, mipmap_mode: None };
ctx.load_texture("checker", img, opts)
})
.id()
}
fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage {
let img = buf.to_rgba8();
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels)
}
/// Box-downsample a composite for display.
fn downsample(buf: &photocraft_compose::Buffer, factor: u32) -> photocraft_compose::Buffer {
let (w, h) = (buf.rect.width(), buf.rect.height());
let (nw, nh) = ((w / factor).max(1), (h / factor).max(1));
let mut out = photocraft_compose::Buffer::transparent(DRect::from_xywh(0, 0, nw, nh));
for y in 0..nh {
for x in 0..nw {
let mut acc = [0.0f32; 4];
let mut n = 0.0;
for dy in 0..factor {
for dx in 0..factor {
let (sx, sy) = (x * factor + dx, y * factor + dy);
if sx < w && sy < h {
let p = buf.px[(sy * w + sx) as usize];
// premultiplied average
acc[0] += p[0] * p[3];
acc[1] += p[1] * p[3];
acc[2] += p[2] * p[3];
acc[3] += p[3];
n += 1.0;
}
}
}
let a = acc[3] / n;
let px = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], a] } else { [0.0; 4] };
out.px[(y * nw + x) as usize] = px;
}
}
out
}
/// The document to render: the committed one, or a clone with the live adjustment preview applied.
fn display_doc(app: &PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>, u64) {
let st = &app.session.documents()[idx];
// Puppet / Perspective Warp previews hide the layer they draw on a mesh.
if let Some(shown) = crate::distort_ui::display_doc(app, idx) {
return shown;
}
if let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview)
&& app.session.active_index() == Some(idx)
&& st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some()
{
return (pv.doc.clone(), (1 << 40) + pv.session);
}
if let Some((layer, params)) = &app.live_adjust
&& let Some(l) = st.doc.layer(*layer)
&& let LayerContent::Adjustment(a) = &l.content
{
let kind = photocraft_engine::commands::adjustment_kind(a);
let mut doc = (*st.doc).clone();
if let Some(lm) = doc.layer_mut(*layer) {
lm.content = LayerContent::Adjustment(photocraft_engine::commands::adjustment_from_params(kind, params));
}
let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
return (std::sync::Arc::new(doc), key);
}
// Duotone documents display through their inks.
if let Some(shown) = photocraft_engine::mode_cmds::display_document(&st.doc) {
return (std::sync::Arc::new(shown), 1 << 41);
}
(st.doc.clone(), 0)
}
/// Make sure the canvas texture for document `idx` is current; returns (texture id, scale).
pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option<(egui::TextureId, f32)> {
let (revision, last_damage, id) = {
let st = app.session.documents().get(idx)?;
(st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)
};
let (doc, preview_key) = display_doc(app, idx);
let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false, tex_revision: 0, tex_preview_key: 0 });
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
let partial = cache.texture.is_some() && cache.tex_preview_key == preview_key && cache.tex_revision + 1 == revision && cache.scale == 1.0 && last_damage.is_some();
let t0 = crate::gpu_canvas::now_ms();
if partial {
let r = last_damage.unwrap().intersect(&doc.bounds());
if !r.is_empty() {
let buf = photocraft_compose::render(&doc, r);
let t1 = crate::gpu_canvas::now_ms();
if let Some(t) = cache.texture.as_mut() {
t.set_partial([r.x0 as usize, r.y0 as usize], buf_image(&buf), TextureOptions::LINEAR);
}
app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
} else {
let full = photocraft_compose::flatten(&doc);
let t1 = crate::gpu_canvas::now_ms();
let longest = doc.size.width.max(doc.size.height);
let factor = longest.div_ceil(MAX_TEXTURE).max(1);
let (img, scale) = if factor > 1 { (buffer_to_image(&downsample(&full, factor)), 1.0 / factor as f32) } else { (buffer_to_image(&full), 1.0) };
match cache.texture.as_mut() {
Some(t) if t.size() == img.size => t.set(img, TextureOptions::LINEAR),
_ => cache.texture = Some(ctx.load_texture(format!("canvas-{}", id.0), img, TextureOptions::LINEAR)),
}
cache.scale = scale;
app.perf.record("full", doc.size.width as u64 * doc.size.height as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
cache.tex_revision = revision;
cache.tex_preview_key = preview_key;
}
Some((cache.texture.as_ref()?.id(), cache.scale))
}
/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows,
/// strokes, …) on the edited layer and on the groups around it reach that far.
fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
layers
.iter()
.filter(|l| l.visible)
.map(|l| {
let own = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 };
let inner = match &l.content {
LayerContent::Group(g) => effect_reach(&g.children),
_ => 0,
};
own + inner
})
.max()
.unwrap_or(0)
}
/// GPU path: make sure document `idx` is current in the GPU canvas. Brush strokes re-composite
/// and upload only their damage rect; everything else re-composites the whole document.
/// Returns false if there is no GPU canvas.
fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
let Some(gpu) = app.gpu.clone() else { return false };
let Some((revision, last_damage, id)) = app.session.documents().get(idx).map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)) else { return false };
let (doc, preview_key) = display_doc(app, idx);
let size = [doc.size.width, doc.size.height];
let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false, tex_revision: 0, tex_preview_key: 0 });
let present = cache.on_gpu && gpu.has(id.0, size);
if present && cache.revision == revision && cache.preview_key == preview_key {
return true;
}
let t0 = crate::gpu_canvas::now_ms();
let partial = present && cache.preview_key == preview_key && cache.revision + 1 == revision && last_damage.is_some();
let mut done = false;
// Preferred: the wgpu compositor renders straight into the display texture (damage rect for
// strokes, everything otherwise). Falls back to the CPU compositor below when unsupported.
let region = if partial { last_damage.unwrap_or(doc.bounds()).intersect(&doc.bounds()) } else { doc.bounds() };
match gpu.composite(&doc, region) {
Ok(stats) => {
done = true;
let kind = if partial { "gpu-rect" } else { "gpu-full" };
app.perf.record(kind, region.width() as u64 * region.height() as u64, crate::gpu_canvas::now_ms() - t0, 0.0);
app.perf.gpu_uploads = stats.tiles_uploaded as u64;
}
Err(e) => {
if app.perf.gpu_fallback.as_deref() != Some(e.0.as_str()) {
log::info!("{e}; using the CPU compositor");
}
app.perf.gpu_fallback = Some(e.0);
}
}
if partial && !done {
let r = last_damage.unwrap().intersect(&doc.bounds());
if r.is_empty() {
done = true;
} else {
let buf = photocraft_compose::render(&doc, r);
let t1 = crate::gpu_canvas::now_ms();
done = gpu.upload_buffer_rect(id.0, &buf);
app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
}
if !done {
// CPU fallback (documents the GPU compositor doesn't cover yet, e.g. layer effects).
// TODO: render only the visible region at display resolution when zoomed out.
let full = photocraft_compose::flatten(&doc);
let t1 = crate::gpu_canvas::now_ms();
gpu.upload_buffer_full(id.0, &full);
app.perf.record("full", size[0] as u64 * size[1] as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
let cache = app.canvases.get_mut(&id).expect("inserted above");
cache.revision = revision;
cache.preview_key = preview_key;
cache.on_gpu = true;
cache.texture = None;
true
}
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
let d = app.ui.dialogs.iter().find(|d| d.fields.contains_key("__filter"))?;
if d.fields.get("__preview").and_then(serde_json::Value::as_bool) != Some(true) {
return None;
}
let cmd = d.fields.get("__command")?.as_str()?.to_string();
let params = crate::filter_dialog::params_of(&d.fields);
let (doc_id, revision, doc, active) = {
let st = app.session.documents().get(idx)?;
(st.doc.id, st.revision, st.doc.clone(), st.active_layer)
};
let k = crate::proxy::factor(&doc);
let hash = format!("{cmd}{params}").bytes().fold(k as u64 ^ revision.wrapping_mul(0x9e37), |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
let key = doc_id.0 ^ (1u64 << 61);
let fresh = matches!(&app.filter_preview, Some(p) if p.doc == doc_id && p.hash == hash);
if !fresh {
let t0 = crate::gpu_canvas::now_ms();
let result = crate::filter_dialog::preview_document(&doc, active, &cmd, &params, k).map(std::sync::Arc::new);
if let Some(r) = &result {
let buf = photocraft_compose::flatten(r);
let t1 = crate::gpu_canvas::now_ms();
app.gpu.as_ref()?.upload_buffer_full(key, &buf);
app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
app.filter_preview = Some(crate::filter_dialog::FilterPreview { doc: doc_id, revision, hash, k, result });
}
app.filter_preview.as_ref().filter(|p| p.result.is_some()).map(|p| (p.k, key))
}
/// If a live adjustment preview is active on a large document, composite it on the proxy and upload
/// it under its own GPU key. Returns (factor, gpu key) when the proxy should be drawn.
fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
let (layer, params) = app.live_adjust.clone()?;
let (doc_id, revision, doc) = {
let st = app.session.documents().get(idx)?;
(st.doc.id, st.revision, st.doc.clone())
};
let k = crate::proxy::factor(&doc);
if k <= 1 {
return None;
}
let fresh = matches!(&app.proxy, Some((d, r, kk, _)) if *d == doc_id && *r == revision && *kk == k);
if !fresh {
app.proxy = Some((doc_id, revision, k, std::sync::Arc::new(crate::proxy::proxy_document(&doc, k))));
}
let proxy = app.proxy.as_ref()?.3.clone();
let key = doc_id.0 ^ (1u64 << 62);
let hash = params.to_string().bytes().fold(k as u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
if app.proxy_uploaded != Some((doc_id, hash)) {
let l = proxy.layer(layer)?;
let LayerContent::Adjustment(a) = &l.content else { return None };
let kind = photocraft_engine::commands::adjustment_kind(a);
let mut p = (*proxy).clone();
if let Some(lm) = p.layer_mut(layer) {
lm.content = LayerContent::Adjustment(photocraft_engine::commands::adjustment_from_params(kind, &params));
}
let t0 = crate::gpu_canvas::now_ms();
let buf = photocraft_compose::flatten(&p);
let t1 = crate::gpu_canvas::now_ms();
app.gpu.as_ref()?.upload_buffer_full(key, &buf);
app.perf.record("proxy", p.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1);
app.proxy_uploaded = Some((doc_id, hash));
}
Some((k, key))
}
fn buf_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage {
buffer_to_image(buf)
}
/// Tabs + canvas for the active document, or the start screen.
pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if let Some(gpu) = &app.gpu {
// Keep each document's texture plus its preview textures (filter preview, adjustment proxy);
// retaining only document ids freed the previews every frame (blank canvas while previewing).
let live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
gpu.retain(&live);
}
if app.session.documents().is_empty() {
start_screen(app, ui);
return;
}
if !app.ui.view.hides_tabs() {
tabs(app, ui);
}
let Some(idx) = app.session.active_index() else { return };
let rect = ui.available_rect_before_wrap();
app.last_canvas_rect = rect;
let n = app.session.documents().len();
// Window › Arrange: tiled / n-up layouts show several documents side by side; the active one
// takes input, a click elsewhere activates that document.
if let Some(cells) = crate::view_cmds::cells(&app.ui.view.arrange, rect, n) {
let order: Vec<usize> = (0..n).map(|k| (idx + k) % n).collect();
let mut shown: Vec<(usize, Rect)> = order.into_iter().zip(cells).collect();
shown.sort_by_key(|(d, _)| *d);
let t = crate::theme::Tokens::get(ui.ctx());
for (d, cell) in shown {
let cell = cell.shrink(1.0);
let view = app.ui.views[d].clone();
let v = canvas_view(app, ui, d, cell, view, d == idx);
if d != idx {
app.ui.views[d] = v;
if ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p))) {
app.session.set_active(d);
}
}
let stroke = if d == idx { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, Color32::from_black_alpha(160)) };
ui.painter().rect_stroke(cell, 0, stroke, egui::StrokeKind::Inside);
}
return;
}
let view = app.ui.views[idx].clone();
canvas_view(app, ui, idx, rect, view, true);
}
fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
if t.pro {
return pro_tabs(app, ui);
}
let active = app.session.active_index();
let mut activate = None;
let mut close = None;
egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (i, st) in app.session.documents().iter().enumerate() {
let sel = Some(i) == active;
let name = format!("{}{}", st.doc.name, if st.is_dirty() { " *" } else { "" });
let meta = format!("{}/{}", mode_label(&st.doc), st.doc.depth.bits());
let name_g = ui.painter().layout_no_wrap(name, crate::theme::medium(12.5), t.text);
let meta_g = ui.painter().layout_no_wrap(meta, egui::FontId::proportional(10.5), t.text_faint);
let w = name_g.size().x + meta_g.size().x + 44.0;
let (r, resp) = ui.allocate_exact_size(egui::vec2(w, 26.0), Sense::click());
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.card);
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.5));
}
let color = if sel { t.text } else { t.text_dim };
let ny = r.center().y - name_g.size().y / 2.0;
ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 10.0, ny), name_g.clone(), color);
ui.painter().galley(egui::pos2(r.left() + 16.0 + name_g.size().x, r.center().y - meta_g.size().y / 2.0), meta_g, t.text_faint);
let xr = Rect::from_center_size(egui::pos2(r.right() - 12.0, r.center().y), egui::vec2(16.0, 16.0));
let xresp = ui.interact(xr, ui.id().with(("tabx", i)), Sense::click());
if xresp.hovered() {
ui.painter().rect_filled(xr, 4.0, t.hover);
}
crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_faint });
if xresp.clicked() {
close = Some(i);
} else if resp.clicked() {
activate = Some(i);
}
}
});
});
if let Some(i) = activate {
app.session.set_active(i);
}
if let Some(i) = close {
let _ = app.run("file.close", json!({"document": i}));
}
}
/// Photoshop document tabs: "name @ 33.3% (RGB/8)" on a dark strip; active tab matches panels.
fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let active = app.session.active_index();
let (mut activate, mut close) = (None, None);
let (strip, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 26.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
let mut x = strip.left();
for (i, st) in app.session.documents().iter().enumerate() {
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
// Photoshop: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask is targeted;
// the Background layer's name is omitted.
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
let is_active_doc = app.session.active_index() == Some(i);
let mask = is_active_doc && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some());
let model = if mask { "Layer Mask".to_string() } else { mode_label(&st.doc).to_string() };
let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default();
let title = format!("{} @ {}% ({layer}{model}/{}){}", st.doc.name, fmt_zoom(zoom), st.doc.depth.bits(), if st.is_dirty() { "*" } else { "" });
let g = ui.painter().layout_no_wrap(title, egui::FontId::proportional(11.5), t.text);
let r = Rect::from_min_size(egui::pos2(x, strip.top()), egui::vec2(g.size().x + 42.0, strip.height()));
let resp = ui.interact(r, ui.id().with(("ptab", i)), Sense::click());
let sel = Some(i) == active;
if sel {
ui.painter().rect_filled(r, 0.0, t.chrome);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.35));
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator));
let xr = Rect::from_center_size(egui::pos2(r.left() + 13.0, r.center().y), egui::vec2(14.0, 14.0));
let xresp = ui.interact(xr, ui.id().with(("ptabx", i)), Sense::click());
crate::icons::paint(ui, xr, "x", 10.0, if xresp.hovered() { t.text } else { t.text_faint });
ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 26.0, r.center().y - g.size().y / 2.0), g, if sel { t.text } else { t.text_faint });
if xresp.clicked() {
close = Some(i);
} else if resp.clicked() {
activate = Some(i);
}
x = r.right();
}
if let Some(i) = activate {
app.session.set_active(i);
}
if let Some(i) = close {
let _ = app.run("file.close", json!({"document": i}));
}
}
/// Photoshop-style zoom label: "33.3", "100", "12.5".
pub fn fmt_zoom(pct: f32) -> String {
if (pct - pct.round()).abs() < 0.05 { format!("{}", pct.round() as i64) } else { format!("{pct:.1}") }
}
/// Repeating dot-grid texture for the canvas surround.
fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option<egui::TextureId> {
if t.bevel {
return None;
}
let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind)));
if let Some(tex) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(tex.id());
}
let n = 22usize;
let mut px = vec![Color32::TRANSPARENT; n * n];
for (dx, dy, a) in [(0usize, 0usize, 255u8), (1, 0, 110), (0, 1, 110), (1, 1, 60)] {
let c = t.canvas_dot;
px[(n / 2 + dy) * n + n / 2 + dx] = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a);
}
let opts = TextureOptions { magnification: egui::TextureFilter::Linear, minification: egui::TextureFilter::Linear, wrap_mode: egui::TextureWrapMode::Repeat, mipmap_mode: None };
let tex = ctx.load_texture("canvas-dots", egui::ColorImage::new([n, n], px), opts);
let id = tex.id();
ctx.data_mut(|d| d.insert_temp(key, tex));
Some(id)
}
fn paint_dots(ui: &egui::Ui, rect: Rect) {
let t = crate::theme::Tokens::get(ui.ctx());
if let Some(id) = dots(ui.ctx(), &t) {
let uv = Rect::from_min_max(Pos2::ZERO, pos2(rect.width() / 22.0, rect.height() / 22.0));
ui.painter_at(rect).image(id, rect, uv, Color32::WHITE);
}
}
pub fn mode_label(doc: &Document) -> &'static str {
match doc.mode {
photocraft_doc::ColorMode::Rgb => "RGB",
photocraft_doc::ColorMode::Grayscale => "Gray",
photocraft_doc::ColorMode::Cmyk => "CMYK",
photocraft_doc::ColorMode::Lab => "Lab",
photocraft_doc::ColorMode::Indexed => "Indexed",
photocraft_doc::ColorMode::Bitmap => "Bitmap",
photocraft_doc::ColorMode::Duotone => "Duotone",
photocraft_doc::ColorMode::Multichannel => "Multichannel",
}
}
fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let area = ui.available_rect_before_wrap();
paint_dots(ui, area);
let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 250.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| {
ui.vertical_centered(|ui| {
ui.horizontal(|ui| {
let title = ui.painter().layout_no_wrap("Photocraft".into(), crate::theme::semibold(38.0), t.text);
let by = ui.painter().layout_no_wrap("open source".into(), egui::FontId::proportional(13.0), t.text_faint);
let total = title.size().x + by.size().x + 10.0;
ui.add_space(((card.width() - total) / 2.0).max(0.0));
let (r, _) = ui.allocate_exact_size(title.size(), Sense::hover());
ui.painter().galley(r.min, title, t.text);
let (r2, _) = ui.allocate_exact_size(egui::vec2(by.size().x + 10.0, r.height()), Sense::hover());
ui.painter().galley(egui::pos2(r2.left() + 10.0, r.bottom() - by.size().y - 8.0), by, t.text_faint);
});
ui.add_space(6.0);
ui.label(egui::RichText::new("Create a new document or open an existing file.").color(t.text_dim).size(14.0));
ui.add_space(22.0);
ui.horizontal(|ui| {
ui.add_space(((card.width() - 2.0 * 190.0 - 12.0) / 2.0).max(0.0));
ui.spacing_mut().item_spacing.x = 12.0;
if crate::widgets::primary_button(ui, "New document… ⌘N", 190.0).clicked() {
app.ui.open_dialog(crate::state::DialogKind::NewDocument, crate::state::UiState::new_document_fields());
}
if crate::widgets::secondary_button(ui, "Open… ⌘O", 190.0).clicked() {
app.open_dialog_file();
}
});
ui.add_space(22.0);
ui.horizontal(|ui| {
let msg = "Drop an image or PSD anywhere to open it.";
let g = ui.painter().layout_no_wrap(msg.into(), egui::FontId::proportional(12.5), t.text_faint);
ui.add_space(((card.width() - g.size().x - 24.0) / 2.0).max(0.0));
let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), Sense::hover());
crate::icons::paint(ui, r, "image", 15.0, t.text_faint);
ui.label(egui::RichText::new(msg).color(t.text_faint));
});
});
});
}
/// Lattice size of the canvas display LUT (Proof Colors / Gamut Warning).
const DISPLAY_LUT: usize = 33;
/// Keep the GPU display LUT of `doc` in step with its View › Proof Colors / Gamut Warning state.
/// Returns the canvas `display` mode (0 none, 1 proof, 2 proof + gamut warning).
fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocraft_doc::Document) -> u8 {
let Some(gpu) = app.gpu.as_ref() else { return 0 };
let pv = app.session.color.proof(doc.id);
let mode = if pv.gamut_warning { 2 } else { u8::from(pv.enabled) };
// Rebuild only when anything feeding the LUT changes.
let sig = if mode == 0 {
String::new()
} else {
format!("{mode} {} {:?} {} {} {} {:?} {:?}", pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper, pv.gamut_threshold, doc.mode, doc.icc_profile.as_ref().map(|p| p.len()))
};
let key = egui::Id::new(("pc-display-lut", doc.id.0));
if ctx.data(|d| d.get_temp::<String>(key)).as_deref() == Some(sig.as_str()) {
return mode;
}
let lut = if mode == 0 { Ok(None) } else { app.session.color.canvas_lut(doc, DISPLAY_LUT) };
match lut {
Ok(bytes) => gpu.set_display_lut(doc.id.0, DISPLAY_LUT as u32, bytes.as_deref()),
Err(e) => {
app.ui.status = format!("Proof Colors: {e}");
gpu.set_display_lut(doc.id.0, DISPLAY_LUT as u32, None);
}
}
ctx.data_mut(|d| d.insert_temp(key, sig));
mode
}
/// Draw one canvas view and handle its input. `primary` = main window (tools active).
pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View {
let ctx = ui.ctx().clone();
let full = rect;
let rect = if primary { crate::rulers::content_rect(app, rect) } else { rect };
let doc = app.session.documents()[idx].doc.clone();
let size = [doc.size.width, doc.size.height];
if view.doc_size != size {
// Like Photoshop: keep the zoom level, re-centre the resized document.
if view.doc_size != [0, 0] {
view.center = [size[0] as f32 / 2.0, size[1] as f32 / 2.0];
}
view.doc_size = size;
}
if view.fit_pending && rect.width() > 50.0 {
fit_view(&mut view, &doc, rect.size());
}
let flip = app.ui.view.flip_horizontal;
let xf = ViewXform { rect, zoom: view.zoom, center: view.center, flip };
let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
let painter = ui.painter_at(rect);
match crate::prefs_ui::pasteboard_color(app) {
Some(c) => {
ui.painter_at(rect).rect_filled(rect, 0.0, c);
}
None => paint_dots(ui, rect),
}
let border = app.session.prefs().interface.canvas_border;
let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow;
// Drop shadow, checkerboard, document image.
let img_rect = xf.doc_rect(doc.bounds());
// Live adjustment previews on big documents use a downsampled proxy (see proxy.rs).
let mut on_gpu = false;
// A flipped view draws through the CPU path (the GPU canvas shader has no mirroring).
if app.gpu.is_some()
&& !flip
&& let Some((k, key)) = ensure_filter_preview(app, idx).or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let params = crate::gpu_canvas::ViewParams {
doc: key,
doc_size: [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)],
zoom: view.zoom * k as f32,
center: [view.center[0] / k as f32, view.center[1] / k as f32],
shadow: { let t = crate::theme::Tokens::get(&ctx); !t.bevel && !t.pro && drop_shadow },
pixel_grid: false,
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
display: 0,
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else if !flip && ensure_gpu(app, idx) {
on_gpu = true;
app.perf.gpu = true;
// Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs).
let params = crate::gpu_canvas::ViewParams {
doc: doc.id.0,
doc_size: [doc.size.width, doc.size.height],
zoom: view.zoom,
center: view.center,
shadow: { let t = crate::theme::Tokens::get(&ctx); !t.bevel && !t.pro && drop_shadow },
pixel_grid,
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &ctx, &doc),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else {
if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow {
painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0));
}
match app.session.prefs().transparency_and_gamut.square() {
Some(square) => {
let checker_id = checker(app, &ctx);
let tiles = img_rect.size() / (2.0 * square);
painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE);
}
None => {
painter.rect_filled(img_rect, 0.0, Color32::WHITE);
}
}
if let Some((tex, _scale)) = ensure_texture(app, &ctx, idx) {
let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) };
painter.image(tex, img_rect, uv, Color32::WHITE);
}
}
// Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs).
if let Some(tex) = crate::channel_view::ensure(app, &ctx, idx) {
let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) };
painter.image(tex, img_rect, uv, Color32::WHITE);
}
// Artboards: pasteboard between the boards, outlines and names (artboard_ui.rs).
if doc.has_artboards() {
let t = crate::theme::Tokens::get(&ctx);
let pasteboard = crate::prefs_ui::pasteboard_color(app);
let dot_tex = dots(&ctx, &t);
for r in crate::artboard_ui::pasteboard_rects(&xf, &doc) {
let r = r.intersect(rect);
if !r.is_positive() {
continue;
}
match pasteboard {
Some(c) => {
painter.rect_filled(r, 0.0, c);
}
None => {
painter.rect_filled(r, 0.0, t.canvas);
if let Some(id) = dot_tex {
// Same phase as the dots around the document.
let uv = Rect::from_min_max(((r.min - rect.min) / 22.0).to_pos2(), ((r.max - rect.min) / 22.0).to_pos2());
painter.image(id, r, uv, Color32::WHITE);
}
}
}
}
if primary {
crate::artboard_ui::draw_frames(app, &painter, &xf);
}
}
// Pixel grid at high zoom (the GPU path draws its own).
if !on_gpu && pixel_grid && view.zoom >= 12.0 {
let vis = img_rect.intersect(rect);
let a = xf.to_doc(vis.min);
let b = xf.to_doc(vis.max);
let grid = Stroke::new(1.0, Color32::from_white_alpha(40));
for x in (a[0].floor() as i32)..=(b[0].ceil() as i32) {
let sx = xf.to_screen(x as f32, 0.0).x;
painter.line_segment([pos2(sx, vis.top()), pos2(sx, vis.bottom())], grid);
}
for y in (a[1].floor() as i32)..=(b[1].ceil() as i32) {
let sy = xf.to_screen(0.0, y as f32).y;
painter.line_segment([pos2(vis.left(), sy), pos2(vis.right(), sy)], grid);
}
}
// View › Show › Layer Edges: the active layer's content bounds.
if app.ui.view.shows(app.ui.view.show.layer_edges)
&& let Some(st) = app.session.documents().get(idx)
&& let Some(b) = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()).map(|s| s.content_bounds())
&& !b.is_empty()
{
painter.rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, Color32::from_rgb(0x2d, 0x8c, 0xeb)), egui::StrokeKind::Outside);
}
// Selection outline: true boundary, animated marching ants (cached per revision).
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges)) {
// Trace at display resolution over the visible part only; key by the mask's tile identity
// (not the document revision) so unrelated edits don't re-trace it.
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let tl = xf.to_doc(rect.min);
let br = xf.to_doc(rect.max);
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
let vis = photocraft_geom::Rect::new(
(tl[0].floor() as i32).div_euclid(q) * q - q,
(tl[1].floor() as i32).div_euclid(q) * q - q,
((br[0].ceil() as i32).div_euclid(q) + 2) * q,
((br[1].ceil() as i32).div_euclid(q) + 2) * q,
);
let key = crate::surface_fingerprint(sel) ^ (step as u64) << 56 ^ doc.id.0.rotate_left(17) ^ (vis.x0 as u64) << 8 ^ (vis.y0 as u64) << 24 ^ (vis.x1 as u64) << 36 ^ (vis.y1 as u64) << 48;
let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key);
if !fresh {
let t0 = crate::gpu_canvas::now_ms();
let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis);
let segs = crate::outline::outline_scaled(sel, b, step);
app.perf.span("outline", crate::gpu_canvas::now_ms() - t0);
app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs)));
}
if let Some((_, _, segs)) = &app.outline_cache {
let time = ui.input(|i| i.time);
marching_ants_segments(&painter, &xf, segs, time);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
// Navigation: scroll pans, pinch / ⌘-scroll zooms around the pointer.
if response.hovered() {
let (scroll, zoom_delta, pointer) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos()));
if zoom_delta != 1.0
&& let Some(p) = pointer
{
let nz = (view.zoom * zoom_delta).clamp(0.01, 64.0);
zoom_about(&mut view, &xf, p, nz);
} else if scroll.y != 0.0
&& app.session.prefs().general.zoom_with_scroll_wheel
&& let Some(p) = pointer
{
// Preferences › General › Zoom with Scroll Wheel.
let nz = (view.zoom * (scroll.y / 200.0).exp()).clamp(0.01, 64.0);
zoom_about(&mut view, &xf, p, nz);
} else if scroll != Vec2::ZERO {
view.center[0] -= scroll.x / view.zoom * if flip { -1.0 } else { 1.0 };
view.center[1] -= scroll.y / view.zoom;
}
}
let space_pan = ui.input(|i| i.key_down(egui::Key::Space));
let middle = ui.input(|i| i.pointer.middle_down());
let tool = if space_pan || middle { Tool::Hand } else { app.ui.tool };
if tool == Tool::Hand && response.dragged() {
let d = response.drag_delta();
view.center[0] -= d.x / view.zoom * if flip { -1.0 } else { 1.0 };
view.center[1] -= d.y / view.zoom;
} else if primary {
let mods = ui.input(|i| i.modifiers);
if response.drag_started()
&& let Some(p) = response.interact_pointer_pos()
{
let d = xf.to_doc(p);
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods);
}
if response.dragged()
&& let Some(p) = response.interact_pointer_pos()
{
let d = xf.to_doc(p);
tool_event(app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, mods);
}
if response.drag_stopped() {
let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]])));
if let Some(d) = p {
tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods);
}
}
if response.clicked()
&& let Some(p) = response.interact_pointer_pos()
{
let d = xf.to_doc(p);
match tool {
Tool::Zoom => {
let nz = zoom_step(view.zoom, if mods.alt { -1 } else { 1 });
zoom_about(&mut view, &xf, p, nz);
}
_ => {
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods);
tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods);
}
}
}
if app.ui.transform.is_some() && response.double_clicked() {
crate::transform_tool::commit(app);
}
if tool == Tool::Type && response.double_clicked() {
crate::type_tool::select_word(app);
}
if app.ui.extras.grid && app.ui.view.extras {
crate::rulers::draw_grid(app, &painter, &xf, &doc);
}
if app.ui.view.shows(app.ui.view.show.canvas_guides) {
crate::rulers::draw_guides(app, &painter, &xf, &doc);
}
draw_drag_preview(app, &painter, &xf);
crate::snap_ui::draw(app, &painter, &xf);
if border == photocraft_engine::prefs::CanvasBorder::Line {
painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside);
}
crate::type_tool::draw_overlay(app, &painter, &xf);
crate::transform_tool::draw_overlay(app, &painter, &xf);
crate::distort_ui::draw_overlay(app, &painter, &xf);
crate::retouch_ui::draw_source_marker(app, &painter, &xf);
crate::vector_ui::draw_overlay(app, &painter, &xf, &doc);
// Tool cursors (Photoshop-style).
let guide_hover = response.hover_pos().filter(|_| tool == Tool::Move).and_then(|p| {
let d = xf.to_doc(p);
crate::rulers::guide_at(app, d[0], d[1])
});
if let Some((vertical, _)) = guide_hover {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
} else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p))) {
ui.ctx().set_cursor_icon(c);
} else if let Some(p) = response.hover_pos() {
let alt = ui.input(|i| i.modifiers.alt);
let icon = match tool {
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Preferences › Cursors: brush tip outline (normal = the 50% contour, or
// full size), precise crosshair, or the standard pointer.
use photocraft_engine::prefs::PaintingCursor;
let cur = app.session.prefs().cursors.clone();
let painting = app.drag.is_some();
let brush = &app.session.tools.brush;
let full = (brush.size / 2.0 * view.zoom).max(1.0);
let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full };
let crosshair = |len: f32| {
for (w, c) in [(2.5, Color32::from_black_alpha(140)), (1.0, Color32::from_white_alpha(220))] {
painter.line_segment([p - vec2(len, 0.0), p + vec2(len, 0.0)], Stroke::new(w, c));
painter.line_segment([p - vec2(0.0, len), p + vec2(0.0, len)], Stroke::new(w, c));
}
};
match cur.painting {
PaintingCursor::Standard => egui::CursorIcon::Default,
PaintingCursor::Precise => {
crosshair(6.0);
egui::CursorIcon::None
}
_ if painting && cur.show_only_crosshair_while_painting => {
crosshair(5.0);
egui::CursorIcon::None
}
_ => {
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
if cur.show_crosshair_in_brush_tip || r > 6.0 {
crosshair(3.0);
}
egui::CursorIcon::None
}
}
}
// Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool.
Tool::Move | Tool::Type | Tool::Eyedropper if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => egui::CursorIcon::Crosshair,
Tool::Move => egui::CursorIcon::Move,
Tool::Hand => {
if response.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
}
Tool::Zoom => {
if alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
}
Tool::Type => egui::CursorIcon::Text,
_ => egui::CursorIcon::Crosshair,
};
ui.ctx().set_cursor_icon(icon);
}
}
if primary {
app.hover_doc = response.hover_pos().map(|p| xf.to_doc(p));
}
if primary && app.ui.extras.rulers {
crate::rulers::draw_rulers(app, ui, full, &xf);
}
if primary {
app.ui.views[idx] = view.clone();
}
view
}
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32) {
let before = xf.to_doc(p);
view.zoom = new_zoom;
let d = (p - xf.rect.center()) / new_zoom;
let dx = if xf.flip { -d.x } else { d.x };
view.center = [before[0] as f32 - dx, before[1] as f32 - d.y];
}
/// Draw boundary segments as marching ants: white base, black dashes phased along x + y.
fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate::outline::Segment], time: f64) {
let dash = 4.0f32;
let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32;
let white = Stroke::new(1.0, Color32::WHITE);
let black = Stroke::new(1.0, Color32::BLACK);
let clip = painter.clip_rect();
for (a, b) in segs {
let pa = xf.to_screen(a[0] as f32, a[1] as f32);
let pb = xf.to_screen(b[0] as f32, b[1] as f32);
if !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) {
continue;
}
let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5);
let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5);
painter.line_segment([pa, pb], white);
let len = pa.distance(pb);
if len < 0.5 {
continue;
}
let dir = (pb - pa) / len;
// Phase by screen position so dashes line up across joined segments.
let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0);
let mut t = -start;
while t < len {
let s0 = t.max(0.0);
let s1 = (t + dash).min(len);
if s1 > s0 {
painter.line_segment([pa + dir * s0, pa + dir * s1], black);
}
t += dash * 2.0;
}
}
}
#[allow(dead_code)]
fn marching_ants(painter: &egui::Painter, r: Rect, time: f64) {
let dash = 4.0;
let offset = ((time * 8.0) % (dash as f64 * 2.0)) as f32;
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
for w in pts.windows(2) {
let (a, b) = (w[0], w[1]);
let len = a.distance(b);
let dir = (b - a) / len.max(1e-3);
let mut t = -offset;
while t < len {
let s = t.max(0.0);
let e = (t + dash).min(len);
if e > s {
painter.line_segment([a + dir * s, a + dir * e], Stroke::new(1.0, Color32::BLACK));
}
t += dash * 2.0;
}
}
}
/// Photoshop crop overlay: dimmed outside, bright frame, rule-of-thirds grid, corner handles.
fn crop_overlay(painter: &egui::Painter, r: Rect) {
let clip = painter.clip_rect();
let dim = Color32::from_black_alpha(130);
for band in [
Rect::from_min_max(clip.min, egui::pos2(clip.max.x, r.min.y)),
Rect::from_min_max(egui::pos2(clip.min.x, r.max.y), clip.max),
Rect::from_min_max(egui::pos2(clip.min.x, r.min.y), egui::pos2(r.min.x, r.max.y)),
Rect::from_min_max(egui::pos2(r.max.x, r.min.y), egui::pos2(clip.max.x, r.max.y)),
] {
painter.rect_filled(band, 0.0, dim);
}
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
let thin = Stroke::new(1.0, Color32::from_white_alpha(90));
for i in 1..3 {
let fx = r.left() + r.width() * i as f32 / 3.0;
let fy = r.top() + r.height() * i as f32 / 3.0;
painter.line_segment([egui::pos2(fx, r.top()), egui::pos2(fx, r.bottom())], thin);
painter.line_segment([egui::pos2(r.left(), fy), egui::pos2(r.right(), fy)], thin);
}
let h = Stroke::new(3.0, Color32::WHITE);
let l = 14.0f32.min(r.width() / 3.0).min(r.height() / 3.0);
for (c, dx, dy) in [(r.left_top(), 1.0, 1.0), (r.right_top(), -1.0, 1.0), (r.left_bottom(), 1.0, -1.0), (r.right_bottom(), -1.0, -1.0)] {
painter.line_segment([c, c + vec2(l * dx, 0.0)], h);
painter.line_segment([c, c + vec2(0.0, l * dy)], h);
}
}
/// Overlays that persist between gestures: polygonal lasso in progress, pending crop box.
fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover: Option<Pos2>) {
if !app.ui.polygon.is_empty() {
let mut pts: Vec<Pos2> = app.ui.polygon.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
if let Some(h) = hover {
pts.push(h);
}
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
for p in &pts[..app.ui.polygon.len()] {
painter.rect_filled(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
}
}
if let Some(c) = app.ui.crop_rect
&& app.drag.is_none()
{
let r = Rect::from_two_pos(xf.to_screen(c[0] as f32, c[1] as f32), xf.to_screen(c[2] as f32, c[3] as f32));
crop_overlay(painter, r);
}
}
fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
draw_tool_state(app, painter, xf, painter.ctx().input(|i| i.pointer.hover_pos()));
let Some(d) = &app.drag else { return };
let last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start);
match d.tool {
Tool::Brush | Tool::Eraser => {
let c = if d.tool == Tool::Eraser { [1.0, 1.0, 1.0, 0.6] } else { app.session.tools.foreground };
let color = Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, (c[3] * 255.0) as u8);
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
let w = (app.session.tools.brush.size * xf.zoom).max(1.0);
if pts.len() == 1 {
painter.circle_filled(pts[0], w / 2.0, color);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(w, color)));
}
}
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Retouching strokes preview as a translucent trail of the brush footprint.
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
let w = (app.session.tools.brush.size * xf.zoom).max(1.0);
let col = Color32::from_white_alpha(if t == Tool::QuickSelection { 40 } else { 60 });
if pts.len() == 1 {
painter.circle_filled(pts[0], w / 2.0, col);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(w, col)));
}
}
Tool::Line => {
painter.line_segment([xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)], Stroke::new(1.0, crate::theme::Tokens::get(painter.ctx()).accent));
}
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon => {
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
if matches!(d.tool, Tool::EllipseMarquee | Tool::EllipseShape) {
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE)));
} else {
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
}
}
Tool::Lasso => {
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
if pts.len() > 1 {
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
}
}
Tool::Gradient => {
let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32);
let b = xf.to_screen(last[0] as f32, last[1] as f32);
painter.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140)));
painter.line_segment([a, b], Stroke::new(1.0, Color32::WHITE));
painter.circle_filled(a, 3.0, Color32::WHITE);
painter.circle_filled(b, 3.0, Color32::WHITE);
}
Tool::Crop => {
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
crop_overlay(painter, r);
}
Tool::Type => {
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE)));
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 3.0, 3.0));
}
Tool::Move => {
let off = vec2(((last[0] - d.start[0]) as f32) * xf.zoom, ((last[1] - d.start[1]) as f32) * xf.zoom);
painter.arrow(xf.to_screen(d.start[0] as f32, d.start[1] as f32), off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent));
}
_ => {}
}
}
/// Tool state machine. Shared by mouse input and automation.
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
// View › Snap / Snap To and smart guides (snap_ui.rs).
let ev = crate::snap_ui::filter_event(app, ev, mods);
if crate::transform_tool::pointer(app, ev, mods) {
return;
}
if crate::distort_ui::pointer(app, ev, mods) {
return;
}
let tool = app.ui.tool;
// Move tool over a guide drags the guide (off the canvas deletes it).
match ev {
ToolEvent::Down { x, y, .. } if tool == Tool::Move => {
if let Some((vertical, i)) = crate::rulers::guide_at(app, x, y) {
app.guide_drag = Some(crate::rulers::GuideDrag { vertical, index: Some(i), pos: if vertical { x } else { y } });
return;
}
}
ToolEvent::Move { x, y, .. } => {
if let Some(d) = app.guide_drag.as_mut().filter(|d| d.index.is_some()) {
d.pos = if d.vertical { x } else { y };
return;
}
}
ToolEvent::Up { x, y } => {
if let Some(mut d) = app.guide_drag.filter(|d| d.index.is_some()) {
app.guide_drag = None;
d.pos = if d.vertical { x } else { y };
crate::rulers::finish_drag(app, d);
return;
}
}
_ => {}
}
match ev {
ToolEvent::Down { x, y, pressure } => {
if tool == Tool::Eyedropper {
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
let c: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
if c.len() == 4 && c[3] > 0.0 {
let key = if mods.alt { "background" } else { "foreground" };
let _ = app.run("tools.setColors", json!({ key: [c[0], c[1], c[2], 1.0] }));
}
}
return;
}
match tool {
Tool::Pen => {
crate::vector_ui::pen_down(app, x, y);
return;
}
Tool::CloneStamp | Tool::Healing if mods.alt => {
crate::retouch_ui::set_source(app, x, y);
return;
}
Tool::MagicWand => {
let o = app.ui.tool_options.clone();
let mode = selection_mode(app, mods);
let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "mode": mode}));
return;
}
Tool::PaintBucket => {
let o = app.ui.tool_options.clone();
let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "opacity": o.fill_opacity, "target": paint_target(app)}));
return;
}
Tool::PolygonLasso => {
// Click adds a vertex; clicking near the first vertex closes the polygon.
let close = app.ui.polygon.first().is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.current_zoom().max(0.01) as f64);
if close {
commit_polygon(app, mods);
} else {
app.ui.polygon.push([x, y]);
}
return;
}
Tool::Type if crate::type_tool::pointer_down(app, x, y, mods.shift) => return,
_ => {}
}
app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods });
}
ToolEvent::Move { x, y, pressure } => {
if tool == Tool::Type && app.drag.is_none() {
crate::type_tool::pointer_move(app, x, y);
}
if tool == Tool::Pen {
crate::vector_ui::pen_move(app, x, y);
}
if let Some(d) = &mut app.drag
&& d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25)
{
d.points.push([x, y, pressure as f64]);
}
}
ToolEvent::Up { x, y } => {
if tool == Tool::Type
&& let Some(e) = app.ui.text_edit.as_mut()
{
e.dragging = false;
}
if tool == Tool::Pen {
crate::vector_ui::pen_up(app);
}
let Some(mut d) = app.drag.take() else { return };
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
}
finish_gesture(app, d);
}
}
}
fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]);
if crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) {
return;
}
match d.tool {
Tool::ObjectSelection => crate::retouch_ui::finish_object_selection(app, d.start, [end[0], end[1]], d.modifiers),
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.modifiers),
Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]),
Tool::Type => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]),
Tool::Brush | Tool::Eraser => {
let pts: Vec<[f64; 3]> = d.points.clone();
let _ = app.run("paint.stroke", json!({ "points": pts, "erase": d.tool == Tool::Eraser, "smoothing": 0.3, "target": paint_target(app) }));
}
Tool::RectMarquee | Tool::EllipseMarquee => {
let (x0, y0) = (d.start[0].min(end[0]).floor(), d.start[1].min(end[1]).floor());
let (x1, y1) = (d.start[0].max(end[0]).ceil(), d.start[1].max(end[1]).ceil());
if x1 - x0 < 2.0 || y1 - y0 < 2.0 {
if app.session.is_enabled("select.deselect") {
let _ = app.run("select.deselect", json!({}));
}
return;
}
let bar = ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize];
let mode = if !d.modifiers.shift && !d.modifiers.alt {
bar
} else if d.modifiers.shift && d.modifiers.alt {
"intersect"
} else if d.modifiers.shift {
"add"
} else if d.modifiers.alt {
"subtract"
} else {
"replace"
};
let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee}));
}
Tool::Lasso => {
let pts: Vec<[f64; 2]> = d.points.iter().map(|p| [p[0], p[1]]).collect();
if pts.len() >= 3 {
let mode = selection_mode(app, d.modifiers);
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": app.ui.tool_options.anti_alias}));
} else if app.session.is_enabled("select.deselect") {
let _ = app.run("select.deselect", json!({}));
}
}
Tool::Gradient => {
if (end[0] - d.start[0]).abs() + (end[1] - d.start[1]).abs() >= 2.0 {
let o = app.ui.tool_options.clone();
let fg = app.session.tools.foreground;
let bg = app.session.tools.background;
let _ = app.run(
"paint.gradient",
json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "colors": [hex(fg), hex(bg)], "opacity": o.fill_opacity, "target": paint_target(app)}),
);
}
}
Tool::Crop => {
let r = [d.start[0].min(end[0]), d.start[1].min(end[1]), d.start[0].max(end[0]), d.start[1].max(end[1])];
if r[2] - r[0] >= 2.0 && r[3] - r[1] >= 2.0 {
app.ui.crop_rect = Some(r);
}
}
Tool::Move => {
let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round());
if dx != 0.0 || dy != 0.0 {
let _ = app.run("layer.translate", json!({"dx": dx, "dy": dy}));
}
}
_ => {}
}
}
/// Extra OS windows showing documents (multi-window / multi-monitor).
pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
let wins = app.ui.windows.clone();
for w in wins.into_iter().filter(|w| w.open) {
let Some(st) = app.session.documents().get(w.document) else { continue };
let title = format!("{} — window {}", st.doc.name, w.id);
let vid = egui::ViewportId::from_hash_of(("docwin", w.id));
let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
let mut view = w.view.clone();
let mut close = false;
ctx.show_viewport_immediate(vid, builder, |ui, _class| {
if ui.ctx().input(|i| i.viewport().close_requested()) {
close = true;
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(crate::theme::Tokens::get(ui.ctx()).canvas)).show(ui, |ui| {
let rect = ui.available_rect_before_wrap();
view = canvas_view(app, ui, w.document, rect, view.clone(), false);
});
});
if let Some(win) = app.ui.windows.iter_mut().find(|x| x.id == w.id) {
win.view = view;
if close {
win.open = false;
}
}
}
app.ui.windows.retain(|w| w.open);
}
/// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect).
fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str {
if m.shift && m.alt {
"intersect"
} else if m.shift {
"add"
} else if m.alt {
"subtract"
} else {
["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize]
}
}
/// Close the polygonal lasso and make the selection.
pub fn commit_polygon(app: &mut PhotocraftApp, mods: egui::Modifiers) {
let pts = std::mem::take(&mut app.ui.polygon);
if pts.len() >= 3 {
let mode = selection_mode(app, mods);
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": app.ui.tool_options.anti_alias}));
}
}
/// Apply the crop tool's rectangle.
pub fn commit_crop(app: &mut PhotocraftApp) {
let Some(r) = app.ui.crop_rect.take() else { return };
let (x, y) = (r[0].round(), r[1].round());
let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0));
if app.run("image.crop", json!({"x": x, "y": y, "width": w, "height": h})).is_ok()
&& let Some(i) = app.session.active_index()
{
app.ui.views[i].fit_pending = true;
}
}
/// "mask" when the Layers panel targets the active layer's mask, else "pixels".
pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value {
use photocraft_engine::channel_cmds::ChannelTarget;
let Some(st) = app.session.active() else { return json!("pixels") };
// A targeted alpha channel (Channels panel) or Quick Mask mode wins over the layer.
match st.channel_view.target {
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => return json!({ "channel": i }),
ChannelTarget::Composite if st.doc.quick_mask.is_some() => return json!("quickMask"),
_ => {}
}
let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some());
json!(if app.ui.mask_target && has_mask { "mask" } else { "pixels" })
}
/// `#rrggbb` for an sRGB colour (the engine's colour parameter notation).
fn hex(c: [f32; 4]) -> String {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn view_transform_roundtrip() {
for flip in [false, true] {
let xf = ViewXform { rect: Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)), zoom: 2.5, center: [320.0, 240.0], flip };
let s = xf.to_screen(10.0, 20.0);
let d = xf.to_doc(s);
assert!((d[0] - 10.0).abs() < 1e-3 && (d[1] - 20.0).abs() < 1e-3);
assert_eq!(xf.to_screen(320.0, 240.0), xf.rect.center());
// Flipped, document x grows to the left.
assert_eq!(xf.to_screen(330.0, 240.0).x > xf.rect.center().x, !flip);
let r = xf.doc_rect(DRect::new(0, 0, 10, 10));
assert!(r.width() > 0.0 && r.height() > 0.0);
}
}
#[test]
fn zoom_steps_monotone() {
assert_eq!(zoom_step(1.0, 1), 2.0);
assert_eq!(zoom_step(1.0, -1), 0.6667);
assert_eq!(zoom_step(0.4, 1), 0.5);
assert_eq!(zoom_step(32.0, 1), 32.0);
}
#[test]
fn downsample_averages_premultiplied() {
let mut b = photocraft_compose::Buffer::transparent(DRect::new(0, 0, 2, 2));
b.px[0] = [1.0, 0.0, 0.0, 1.0];
let d = downsample(&b, 2);
assert_eq!(d.px.len(), 1);
let p = d.px[0];
assert!((p[0] - 1.0).abs() < 1e-6 && (p[3] - 0.25).abs() < 1e-6, "{p:?}");
}
#[test]
fn damage_grows_by_nested_effect_reach() {
use photocraft_doc::{Effect, Layer};
let fmt = photocraft_color::PixelFormat::RGBA8;
assert_eq!(effect_reach(&[Layer::raster("plain", fmt)]), 0);
let mut inner = Layer::raster("inner", fmt);
inner.effects.items.push(Effect::default_drop_shadow());
let m = photocraft_compose::effects::margin(&inner);
let mut group = Layer::group("g", vec![inner.clone()]);
group.effects.items.push(Effect::default_drop_shadow());
assert_eq!(effect_reach(std::slice::from_ref(&inner)), m);
// A child's edit moves the group's shape, whose effects reach further.
assert_eq!(effect_reach(&[group.clone()]), 2 * m);
group.visible = false;
assert_eq!(effect_reach(&[group]), 0);
}
}