mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Integration: track t10
# Conflicts: # contributors/people.toml # crates/engine/src/prefs.rs # crates/ui-egui/src/layer_style/contour.rs # docs/ui-parity.md
This commit is contained in:
@@ -784,6 +784,9 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
app.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
// Every egui texture uploads through this adapter, whichever canvas draws:
|
||||
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
|
||||
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
|
||||
@@ -172,6 +172,9 @@ sync_github_name = true
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
||||
[people.theoabw]
|
||||
real_name = "Theo Wilenius"
|
||||
|
||||
[people.TaylorBurke]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -220,3 +223,6 @@ sync_github_name = true
|
||||
|
||||
[people.ksaxena149]
|
||||
sync_github_name = true
|
||||
|
||||
[people.giang17]
|
||||
real_name = "Giang Nguyen"
|
||||
|
||||
@@ -132,6 +132,13 @@ impl SnapTargets {
|
||||
if !l.visible || exclude.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
// An artboard is a group, but its edges and centre are the board's own.
|
||||
if let Some(a) = l.artboard() {
|
||||
if let Some(r) = to_rect(a.rect) {
|
||||
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = target_rect_of(doc, l) {
|
||||
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
|
||||
}
|
||||
@@ -389,4 +396,29 @@ mod tests {
|
||||
);
|
||||
assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0]));
|
||||
}
|
||||
|
||||
/// An artboard (a group) offers the board's own edges and centre, so other layers, boards and
|
||||
/// tools can snap to it; the board being moved doesn't snap to itself.
|
||||
#[test]
|
||||
fn an_artboard_offers_its_own_edges() {
|
||||
let mut doc = Document::with_background("s", Size::new(200, 100), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut board = Layer::group("Artboard 1", vec![]);
|
||||
if let photocraft_doc::LayerContent::Group(g) = &mut board.content {
|
||||
g.artboard =
|
||||
Some(photocraft_doc::Artboard { rect: photocraft_geom::Rect::new(20, 10, 120, 90), background: Default::default(), preset: String::new() });
|
||||
}
|
||||
let id = doc.insert_above(None, board);
|
||||
let opts = SnapOptions::default();
|
||||
let t = SnapTargets::from_document(&doc, &opts, &[]);
|
||||
let has = |ts: &[Target], kind: SnapKind, pos: f64| ts.iter().any(|t| t.kind == kind && t.pos == pos);
|
||||
for x in [20.0, 120.0] {
|
||||
assert!(has(&t.x, SnapKind::LayerEdge, x), "x = {x}");
|
||||
}
|
||||
for y in [10.0, 90.0] {
|
||||
assert!(has(&t.y, SnapKind::LayerEdge, y), "y = {y}");
|
||||
}
|
||||
assert!(has(&t.x, SnapKind::LayerCenter, 70.0) && has(&t.y, SnapKind::LayerCenter, 50.0));
|
||||
let t = SnapTargets::from_document(&doc, &opts, &[id]);
|
||||
assert!(!has(&t.x, SnapKind::LayerEdge, 120.0) && !has(&t.x, SnapKind::LayerCenter, 70.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Probe: do textures uploaded like egui-wgpu's (TEXTURE_BINDING | COPY_DST, one full
|
||||
//! `write_texture`) read back exactly what was written? Uploads mostly-transparent images with a
|
||||
//! few opaque texels, samples every texel in a render pass, reads the target back and counts
|
||||
//! texels that differ. Between uploads it frees render targets cleared to red, so stale memory is
|
||||
//! red if the driver hands it back without honouring the copy.
|
||||
//!
|
||||
//! cargo run --release -p photocraft-gpu --example zero_block_probe -- [dx12|vulkan|gl] [rounds] [guard] [adapter=<name>]
|
||||
//!
|
||||
//! `adapter=<name>` probes the first adapter whose name contains `<name>` (case-insensitive), e.g.
|
||||
//! `adapter=iris` on a machine that also has a discrete GPU; without it wgpu picks its default adapter.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const SHADER: &str = r"
|
||||
@group(0) @binding(0) var t: texture_2d<f32>;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = array(vec2(-1.0, -3.0), vec2(-1.0, 1.0), vec2(3.0, 1.0));
|
||||
return vec4(p[i], 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return textureLoad(t, vec2<i32>(p.xy), 0);
|
||||
}
|
||||
";
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let backends = match args.get(1).map(String::as_str) {
|
||||
Some("vulkan") => wgpu::Backends::VULKAN,
|
||||
Some("gl") => wgpu::Backends::GL,
|
||||
_ => wgpu::Backends::DX12,
|
||||
};
|
||||
let rounds: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300);
|
||||
// `guard`: transparent texels are uploaded as (0, 0, 0, 1), as `photocraft_ui_egui::texel_guard` does.
|
||||
let guard = args.get(3).is_some_and(|a| a == "guard");
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
|
||||
let adapter = match args.iter().find_map(|a| a.strip_prefix("adapter=")).map(str::to_lowercase) {
|
||||
Some(want) => {
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
|
||||
let names: Vec<String> = adapters.iter().map(|a| a.get_info().name).collect();
|
||||
adapters
|
||||
.into_iter()
|
||||
.find(|a| a.get_info().name.to_lowercase().contains(&want))
|
||||
.unwrap_or_else(|| panic!("no adapter matches {want:?}; found {names:?}"))
|
||||
}
|
||||
None => pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).expect("adapter"),
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
println!("adapter: {} ({:?}) driver {} {}", info.name, info.backend, info.driver, info.driver_info);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).expect("device");
|
||||
|
||||
let fmt = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: fmt, blend: None, write_mask: wgpu::ColorWrites::ALL })],
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let mut bad_textures = 0;
|
||||
let mut bad_texels = 0usize;
|
||||
for round in 0..rounds {
|
||||
// Vary the size like the brush previews and icons do.
|
||||
let (w, h) = (16 + (round as u32 * 37) % 300, 16 + (round as u32 * 13) % 60);
|
||||
// Free some red render targets first: their memory is what a not-zeroed heap hands back.
|
||||
for _ in 0..2 {
|
||||
let rt = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("red-rt"),
|
||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: fmt,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = rt.create_view(&Default::default());
|
||||
let mut enc = device.create_command_encoder(&Default::default());
|
||||
enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: 1.0, g: 0.03, b: 0.03, a: 1.0 }), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
queue.submit([enc.finish()]);
|
||||
}
|
||||
// The upload: transparent except a diagonal band of opaque grey, like a stroke strip.
|
||||
let mut px = if guard { [0u8, 0, 0, 1].repeat((w * h) as usize) } else { vec![0u8; (w * h * 4) as usize] };
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if (x as i64 * h as i64 / w as i64 - y as i64).abs() < 3 {
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
px[i..i + 4].copy_from_slice(&[200, 200, 200, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let tex = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("egui-like"),
|
||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: fmt,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&px,
|
||||
);
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("target"),
|
||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: fmt,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&tex.create_view(&Default::default())) }],
|
||||
});
|
||||
let tview = target.create_view(&Default::default());
|
||||
let row = (w * 4).div_ceil(256) * 256;
|
||||
let buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(row * h),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut enc = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &tview,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLUE), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
enc.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo { texture: &target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: None } },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
queue.submit([enc.finish()]);
|
||||
buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
|
||||
let data = buf.slice(..).get_mapped_range().expect("mapped");
|
||||
let mut bad = 0;
|
||||
let mut first = None;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let got = &data[(y * row + x * 4) as usize..(y * row + x * 4 + 4) as usize];
|
||||
let want = &px[((y * w + x) * 4) as usize..((y * w + x) * 4 + 4) as usize];
|
||||
if got != want {
|
||||
bad += 1;
|
||||
first.get_or_insert((x, y, got.to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
bad_textures += 1;
|
||||
bad_texels += bad;
|
||||
if bad_textures <= 10 {
|
||||
println!("round {round}: {w}x{h} texture has {bad} wrong texels, first {first:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{bad_textures}/{rounds} textures read back wrong ({bad_texels} texels)");
|
||||
}
|
||||
@@ -50,6 +50,47 @@ pub(crate) fn tip_axes(xf: &crate::canvas::ViewXform, size: f32) -> [Vec2; 2] {
|
||||
[xf.map_vec(Vec2::new(r, 0.0)), xf.map_vec(Vec2::new(0.0, r))]
|
||||
}
|
||||
|
||||
/// Apply pen orientation after the cached tip shape, in document space before the view.
|
||||
/// Keeping this outside `rings` avoids retracing a sampled tip on every tablet sample.
|
||||
pub(crate) fn pen_axes(xf: &crate::canvas::ViewXform, brush: &BrushSettings, pen: Option<crate::stylus::PenSample>) -> [Vec2; 2] {
|
||||
use photocraft_paint::brush::Control;
|
||||
use photocraft_paint::dynamics::{StepInput, apply_pose, control_angle};
|
||||
let axes = tip_axes(xf, brush.size);
|
||||
if !brush.shape_dynamics.enabled {
|
||||
return axes;
|
||||
}
|
||||
let Some(pen) = pen else { return axes };
|
||||
let pen = pen.sanitized();
|
||||
let point = apply_pose(
|
||||
brush,
|
||||
photocraft_paint::StrokePoint { pressure: pen.pressure, tilt_x: pen.tilt_x, tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() },
|
||||
);
|
||||
let sd = &brush.shape_dynamics;
|
||||
let step = StepInput { point, direction: 0.0, initial_direction: 0.0, step: 0 };
|
||||
let mut angle = match sd.angle.control {
|
||||
Control::PenTilt | Control::Rotation => control_angle(sd.angle.control, sd.angle.fade_steps, &step),
|
||||
_ => 0.0,
|
||||
};
|
||||
if sd.brush_projection {
|
||||
angle += point.rotation;
|
||||
}
|
||||
let (sn, cs) = angle.to_radians().sin_cos();
|
||||
let project = |v: Vec2| {
|
||||
if !sd.brush_projection {
|
||||
return v;
|
||||
}
|
||||
let tilt = point.tilt_x.hypot(point.tilt_y).clamp(0.0, 90.0);
|
||||
if tilt == 0.0 {
|
||||
return v;
|
||||
}
|
||||
let direction = Vec2::new(point.tilt_x, point.tilt_y).normalized();
|
||||
v + direction * v.dot(direction) * (tilt.to_radians().cos().max(0.05) - 1.0)
|
||||
};
|
||||
let x = project(Vec2::new(cs, -sn));
|
||||
let y = project(Vec2::new(sn, cs));
|
||||
[axes[0] * x.x + axes[1] * x.y, axes[0] * y.x + axes[1] * y.y]
|
||||
}
|
||||
|
||||
/// Rings in radius units (document orientation) on screen, through [`tip_axes`].
|
||||
fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec<Vec<Vec2>> {
|
||||
rings.iter().map(|r| r.iter().map(|p| axes[0] * p[0] + axes[1] * p[1]).collect()).collect()
|
||||
@@ -59,7 +100,12 @@ fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec<Vec<Vec2>> {
|
||||
/// for the plain circle; `axes` comes from [`tip_axes`], for a traced outline. `normal` is
|
||||
/// Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip).
|
||||
pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, axes: [Vec2; 2], normal: bool, centre: bool) -> CursorIcon {
|
||||
if let Some(rings) = rings(painter.ctx(), brush, !normal) {
|
||||
let anisotropic = (axes[0].length_sq() - axes[1].length_sq()).abs() > 0.001 || axes[0].dot(axes[1]).abs() > 0.001;
|
||||
let outline = rings(painter.ctx(), brush, !normal).or_else(|| {
|
||||
// A round tip becomes an ellipse under projection (or a non-square pixel aspect).
|
||||
anisotropic.then(|| photocraft_paint::outline::tip_outline(brush, !normal).map(Arc::new)).flatten()
|
||||
});
|
||||
if let Some(rings) = outline {
|
||||
let pts = on_screen(&rings, axes);
|
||||
let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length()));
|
||||
if extent < MIN_RADIUS {
|
||||
@@ -139,6 +185,76 @@ mod tests {
|
||||
(a - b).length() < 1e-4
|
||||
}
|
||||
|
||||
/// Component timing only: outline transformation and OS bitmap generation, not presentation
|
||||
/// or tablet latency. Run explicitly in release mode on the same runner for both cases.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pen_cursor_cost_probe() {
|
||||
let mut brush = BrushSettings { size: 60.0, roundness: 0.25, hardness: 1.0, ..Default::default() };
|
||||
brush.shape_dynamics.enabled = true;
|
||||
brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation;
|
||||
let xf = view(0.0, false, 1.0);
|
||||
for dynamic in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
for sample in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| {
|
||||
for i in 0..360 {
|
||||
let axes = if dynamic {
|
||||
pen_axes(&xf, &brush, Some(crate::stylus::PenSample { rotation: i as f32, ..Default::default() }))
|
||||
} else {
|
||||
tip_axes(&xf, brush.size)
|
||||
};
|
||||
std::hint::black_box(cursor(ui.painter(), pos2(100.0, 100.0), &brush, 60.0, axes, false, false));
|
||||
}
|
||||
});
|
||||
println!("PEN_CURSOR_COST dynamic={dynamic} sample={sample} us_per_update={:.3}", start.elapsed().as_secs_f64() * 1e6 / 360.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_orientation_preserves_mouse_and_disabled_dynamics() {
|
||||
let mut brush = BrushSettings::default();
|
||||
brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation;
|
||||
let pen = crate::stylus::PenSample { rotation: 90.0, ..Default::default() };
|
||||
let xf = view(30.0, true, 2.0);
|
||||
assert_eq!(pen_axes(&xf, &brush, Some(pen)), tip_axes(&xf, brush.size));
|
||||
brush.shape_dynamics.enabled = true;
|
||||
assert_eq!(pen_axes(&xf, &brush, None), tip_axes(&xf, brush.size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_pose_rotation_precedes_the_view_and_reuses_the_tip_cache() {
|
||||
let mut brush = BrushSettings { size: 20.0, roundness: 0.25, ..Default::default() };
|
||||
brush.shape_dynamics.enabled = true;
|
||||
brush.shape_dynamics.angle.control = photocraft_paint::brush::Control::Rotation;
|
||||
brush.pose.enabled = true;
|
||||
brush.pose.override_rotation = true;
|
||||
brush.pose.rotation = 90.0;
|
||||
let ctx = egui::Context::default();
|
||||
let cached = rings(&ctx, &brush, true).unwrap();
|
||||
for (rotation, flip, aspect) in [(0.0, false, 1.0), (30.0, true, 2.0)] {
|
||||
let xf = view(rotation, flip, aspect);
|
||||
let axes = pen_axes(&xf, &brush, Some(crate::stylus::PenSample::default()));
|
||||
assert!(close(axes[0], xf.map_vec(Vec2::new(0.0, -10.0))));
|
||||
assert!(close(axes[1], xf.map_vec(Vec2::new(10.0, 0.0))));
|
||||
assert!(Arc::ptr_eq(&cached, &rings(&ctx, &brush, true).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_projection_shortens_the_tilt_axis() {
|
||||
let mut brush = BrushSettings { size: 20.0, ..Default::default() };
|
||||
brush.shape_dynamics.enabled = true;
|
||||
brush.shape_dynamics.brush_projection = true;
|
||||
let xf = view(0.0, false, 1.0);
|
||||
let axes = pen_axes(&xf, &brush, Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() }));
|
||||
assert!(close(axes[0], Vec2::new(10.0, 0.0)));
|
||||
assert!(close(axes[1], Vec2::new(0.0, 20.0)));
|
||||
assert_eq!(pen_axes(&xf, &brush, Some(crate::stylus::PenSample::default())), tip_axes(&xf, brush.size));
|
||||
}
|
||||
|
||||
/// The axes are the screen offsets of the document points one radius right of and below the
|
||||
/// pointer, whatever the view does.
|
||||
#[test]
|
||||
|
||||
+410
-11
@@ -12,14 +12,74 @@ use serde_json::json;
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::{Tool, View};
|
||||
|
||||
/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands.
|
||||
/// Largest texture side we upload; bigger documents are uploaded downsampled, with the part in
|
||||
/// view added at the screen's resolution when zoomed in (`detail_plan`).
|
||||
pub const MAX_TEXTURE: u32 = 4096;
|
||||
|
||||
/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU
|
||||
/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit
|
||||
/// egui asserts and the backend fails to create the texture.
|
||||
fn texture_side(ctx: &egui::Context) -> u32 {
|
||||
u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE)
|
||||
clamp_texture_side(ctx.input(|i| i.max_texture_side))
|
||||
}
|
||||
|
||||
/// [`texture_side`] for a device whose largest texture side is `max_texture_side`.
|
||||
fn clamp_texture_side(max_texture_side: usize) -> u32 {
|
||||
u32::try_from(max_texture_side).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE)
|
||||
}
|
||||
|
||||
/// How many document pixels per side one texel of the CPU canvas texture of a `size` document
|
||||
/// holds when textures are at most `side` texels per side: 1 (full resolution) when it fits.
|
||||
fn texture_factor(size: [u32; 2], side: u32) -> u32 {
|
||||
size[0].max(size[1]).div_ceil(side.max(1)).max(1)
|
||||
}
|
||||
|
||||
/// What the CPU canvas draws over its reduced texture so a large document shows its own pixels
|
||||
/// at 100 % (#7568): the part of the document in view (`region`, document pixels) composited at
|
||||
/// one texel per `factor` pixels, a power of two no coarser than the screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DetailPlan {
|
||||
pub factor: u32,
|
||||
pub region: DRect,
|
||||
}
|
||||
|
||||
/// The detail region snaps outward to multiples of this many texels, so a small pan reuses it.
|
||||
const DETAIL_QUANTUM: i32 = 256;
|
||||
|
||||
/// The detail texture a CPU canvas view of a `size` document needs, at `zoom` physical pixels
|
||||
/// per document pixel over `visible` (document pixels), with textures at most `side` texels per
|
||||
/// side: `None` when the whole-document texture is already as fine as the screen (a document
|
||||
/// that fits, or a view zoomed out far enough), or nothing of the document is in view.
|
||||
///
|
||||
/// A document over `side` (4096 px, or the device's smaller limit) is uploaded reduced, so at
|
||||
/// 100 % it was drawn magnified and blurred. The detail costs what the window shows, not what the
|
||||
/// document holds: its side is capped by `side` like any texture, coarsening by powers of two.
|
||||
pub(crate) fn detail_plan(size: [u32; 2], side: u32, zoom: f32, visible: DRect) -> Option<DetailPlan> {
|
||||
if !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let base = texture_factor(size, side);
|
||||
let mut factor = crate::adjust_preview::proxy_factor(zoom);
|
||||
let bounds = DRect::new(0, 0, i32::try_from(size[0]).ok()?, i32::try_from(size[1]).ok()?);
|
||||
let v = visible.intersect(&bounds);
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
while factor < base {
|
||||
let q = DETAIL_QUANTUM.saturating_mul(i32::try_from(factor).unwrap_or(i32::MAX));
|
||||
let up = |x: i32| x.div_euclid(q).saturating_add(i32::from(x.rem_euclid(q) != 0)).saturating_mul(q);
|
||||
let region = DRect::new(v.x0.div_euclid(q).saturating_mul(q), v.y0.div_euclid(q).saturating_mul(q), up(v.x1), up(v.y1)).intersect(&bounds);
|
||||
// A reduced region's texel count can round one up (`render_reduced_damage`'s cells).
|
||||
let fits = |n: u32| n.div_ceil(factor).saturating_add(1) <= side;
|
||||
// Snapped when that fits, else just what is in view (a window near the device's limit).
|
||||
for region in [region, v] {
|
||||
if !region.is_empty() && fits(region.width()) && fits(region.height()) {
|
||||
return Some(DetailPlan { factor, region });
|
||||
}
|
||||
}
|
||||
factor = factor.saturating_mul(2);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) struct ClonePreviewCache {
|
||||
@@ -144,6 +204,40 @@ pub struct CanvasCache {
|
||||
pub tex_preview_key: u64,
|
||||
/// The CPU path's pixel grid for the part of the document in view (pixel_grid.rs).
|
||||
pub grid: Option<crate::pixel_grid::GridLines>,
|
||||
/// The CPU path's sharper textures of the part in view, over a reduced `texture` (#7568),
|
||||
/// by `InView::key` (two windows of a document can show different parts).
|
||||
pub detail: Vec<(u64, DetailTexture)>,
|
||||
}
|
||||
|
||||
/// [`CanvasCache::detail`]: the part of the document `DetailPlan` chose, uploaded at its factor.
|
||||
pub struct DetailTexture {
|
||||
texture: egui::TextureHandle,
|
||||
plan: DetailPlan,
|
||||
/// The texels it holds, in the plan's reduced pixels (`render_reduced_damage`'s output rect).
|
||||
out: DRect,
|
||||
/// Revision and preview key (display key folded in) it was rendered at.
|
||||
revision: u64,
|
||||
preview_key: u64,
|
||||
/// Pixels were composited for it this call (for the perf span).
|
||||
rendered: bool,
|
||||
}
|
||||
|
||||
/// The CPU canvas textures to draw: the whole document, and over it the sharper part in view.
|
||||
pub(crate) struct CpuTextures {
|
||||
pub texture: egui::TextureId,
|
||||
pub scale: f32,
|
||||
/// The detail texture and the document area it covers (x0, y0, x1, y1 in document pixels).
|
||||
pub detail: Option<(egui::TextureId, [f32; 4])>,
|
||||
}
|
||||
|
||||
/// The part of a CPU canvas view in sight: its document area and zoom (physical pixels per
|
||||
/// document pixel), for [`ensure_texture_in_view`]'s detail texture.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct InView {
|
||||
pub visible: DRect,
|
||||
pub zoom: f32,
|
||||
/// Which view this is, so each keeps its own detail texture.
|
||||
pub key: u64,
|
||||
}
|
||||
|
||||
/// An in-progress pointer gesture on the canvas.
|
||||
@@ -1517,6 +1611,18 @@ fn canvas_texture_options(factor: u32) -> TextureOptions {
|
||||
/// Make sure the canvas texture for document `idx` on `display` is current; returns (texture
|
||||
/// id, scale).
|
||||
pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display: Option<u32>) -> Option<(egui::TextureId, f32)> {
|
||||
ensure_texture_in_view(app, ctx, idx, display, None).map(|t| (t.texture, t.scale))
|
||||
}
|
||||
|
||||
/// [`ensure_texture`], and for a canvas showing `view` of a document too large for one
|
||||
/// full-resolution texture, the detail texture of the part in view ([`detail_plan`], #7568).
|
||||
pub(crate) fn ensure_texture_in_view(
|
||||
app: &mut PhotocraftApp,
|
||||
ctx: &egui::Context,
|
||||
idx: usize,
|
||||
display: Option<u32>,
|
||||
view: Option<InView>,
|
||||
) -> Option<CpuTextures> {
|
||||
let output = display;
|
||||
let (revision, last_damage, id) = {
|
||||
let st = app.session.documents().get(idx)?;
|
||||
@@ -1534,6 +1640,16 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
let (display, display_key) = canvas_display(app, &doc, output);
|
||||
let seen = app.canvases.get(&cache_key(id, output)).map(|c| (c.tex_revision, c.tex_preview_key));
|
||||
let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage));
|
||||
// The detail texture can lag the whole-document one (made or moved later): its own damage.
|
||||
let detail_seen = view.and_then(|v| {
|
||||
let c = app.canvases.get(&cache_key(id, output))?;
|
||||
c.detail.iter().find(|(k, _)| *k == v.key).map(|(_, d)| (d.revision, d.preview_key))
|
||||
});
|
||||
let detail_damage = match detail_seen {
|
||||
Some(s) if Some(s) == seen => damage,
|
||||
Some(s) => damage_since(app, idx, s, (revision, preview_key), display_key, last_damage),
|
||||
None => None,
|
||||
};
|
||||
let preview_key = preview_key ^ display_key;
|
||||
let cache = app.canvases.entry(cache_key(id, output)).or_insert(CanvasCache {
|
||||
revision: 0,
|
||||
@@ -1544,11 +1660,11 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
tex_revision: 0,
|
||||
tex_preview_key: 0,
|
||||
grid: None,
|
||||
detail: Vec::new(),
|
||||
});
|
||||
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let longest = doc.size.width.max(doc.size.height);
|
||||
let factor = longest.div_ceil(texture_side(ctx)).max(1);
|
||||
let factor = texture_factor([doc.size.width, doc.size.height], texture_side(ctx));
|
||||
let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1));
|
||||
let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]);
|
||||
if let (Some(d), Some(t)) = (damage, current) {
|
||||
@@ -1577,7 +1693,108 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
cache.tex_revision = revision;
|
||||
cache.tex_preview_key = preview_key;
|
||||
}
|
||||
Some((cache.texture.as_ref()?.id(), cache.scale))
|
||||
let texture = cache.texture.as_ref()?.id();
|
||||
let scale = cache.scale;
|
||||
// Without a view (the Navigator's call) the views' detail textures stay as they are.
|
||||
let Some(view) = view else { return Some(CpuTextures { texture, scale, detail: None }) };
|
||||
let old = cache.detail.iter().position(|(k, _)| *k == view.key).map(|i| cache.detail.remove(i).1);
|
||||
let Some(plan) = detail_plan([doc.size.width, doc.size.height], texture_side(ctx), view.zoom, view.visible) else {
|
||||
return Some(CpuTextures { texture, scale, detail: None });
|
||||
};
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let name = format!("canvas-detail-{}-{}", id.0, cache_key(id, output).1);
|
||||
let detail = update_detail(ctx, &doc, display.as_deref(), name, plan, old, detail_damage, (revision, preview_key));
|
||||
if detail.as_ref().is_some_and(|d| d.rendered) {
|
||||
app.perf.span("canvas detail", crate::gpu_canvas::now_ms() - t0);
|
||||
}
|
||||
let Some(mut detail) = detail else { return Some(CpuTextures { texture, scale, detail: None }) };
|
||||
detail.rendered = false;
|
||||
// Where its texels lie in the document: reduced texel t covers [t·W/w, (t+1)·W/w).
|
||||
let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1));
|
||||
let (sx, sy) = (doc.size.width as f32 / w as f32, doc.size.height as f32 / h as f32);
|
||||
let out = detail.out;
|
||||
let shown = (detail.texture.id(), [out.x0 as f32 * sx, out.y0 as f32 * sy, out.x1 as f32 * sx, out.y1 as f32 * sy]);
|
||||
cache.detail.push((view.key, detail));
|
||||
// Views that stopped asking (a closed window, a zoom out) give theirs up past a few.
|
||||
if cache.detail.len() > MAX_DETAIL_VIEWS {
|
||||
cache.detail.remove(0);
|
||||
}
|
||||
Some(CpuTextures { texture, scale, detail: Some(shown) })
|
||||
}
|
||||
|
||||
/// Most CPU canvas views of one document on one display that keep a detail texture.
|
||||
const MAX_DETAIL_VIEWS: usize = 4;
|
||||
|
||||
/// The detail texture for `plan` at document state `now` (revision, preview key with the display
|
||||
/// key folded in): `old` as it is when current, updated over `damage` when only that changed
|
||||
/// within the same region, or rendered again. `None` when the region holds no texels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_detail(
|
||||
ctx: &egui::Context,
|
||||
doc: &Document,
|
||||
display: Option<&photocraft_engine::display_color::CanvasDisplay>,
|
||||
name: String,
|
||||
plan: DetailPlan,
|
||||
old: Option<DetailTexture>,
|
||||
damage: Option<DRect>,
|
||||
now: (u64, u64),
|
||||
) -> Option<DetailTexture> {
|
||||
let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1));
|
||||
let options = canvas_texture_options(plan.factor);
|
||||
let (kept, spare) = match old {
|
||||
Some(d) if d.plan == plan => (Some(d), None),
|
||||
d => (None, d.map(|d| d.texture)),
|
||||
};
|
||||
if let Some(mut d) = kept {
|
||||
if (d.revision, d.preview_key) == now {
|
||||
return Some(d);
|
||||
}
|
||||
// An edit or a stroke step in view: only the texels it touched.
|
||||
if let Some(dmg) = damage {
|
||||
let dmg = dmg.intersect(&plan.region);
|
||||
let buf = (!dmg.is_empty()).then(|| photocraft_compose::render_reduced_damage(doc, w, h, dmg));
|
||||
let fits = buf.as_ref().is_none_or(|b| d.out.contains_rect(&b.rect));
|
||||
if fits {
|
||||
if let Some(b) = buf.filter(|b| !b.rect.is_empty()) {
|
||||
let at = [b.rect.x0.abs_diff(d.out.x0) as usize, b.rect.y0.abs_diff(d.out.y0) as usize];
|
||||
d.texture.set_partial(at, display_image(display, &b), options);
|
||||
d.rendered = true;
|
||||
}
|
||||
(d.revision, d.preview_key) = now;
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
return render_detail(ctx, doc, display, name, plan, Some(d.texture), now);
|
||||
}
|
||||
render_detail(ctx, doc, display, name, plan, spare, now)
|
||||
}
|
||||
|
||||
/// [`update_detail`]'s whole render of `plan`'s region, into `texture` when it is the same size.
|
||||
fn render_detail(
|
||||
ctx: &egui::Context,
|
||||
doc: &Document,
|
||||
display: Option<&photocraft_engine::display_color::CanvasDisplay>,
|
||||
name: String,
|
||||
plan: DetailPlan,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
now: (u64, u64),
|
||||
) -> Option<DetailTexture> {
|
||||
let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1));
|
||||
let options = canvas_texture_options(plan.factor);
|
||||
let buf = photocraft_compose::render_reduced_damage(doc, w, h, plan.region);
|
||||
let out = buf.rect;
|
||||
if out.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let img = display_image(display, &buf);
|
||||
let texture = match texture {
|
||||
Some(mut t) if t.size() == img.size => {
|
||||
t.set(img, options);
|
||||
t
|
||||
}
|
||||
_ => ctx.load_texture(name, img, options),
|
||||
};
|
||||
Some(DetailTexture { texture, plan, out, revision: now.0, preview_key: now.1, rendered: true })
|
||||
}
|
||||
|
||||
/// What changed since a canvas cache showed (`revision`, `preview key`) `seen`, when only a
|
||||
@@ -1760,6 +1977,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
tex_revision: 0,
|
||||
tex_preview_key: 0,
|
||||
grid: None,
|
||||
detail: Vec::new(),
|
||||
});
|
||||
let present = cache.on_gpu && gpu.has(id.0, size);
|
||||
let current = cache.revision == revision && cache.preview_key == preview_key;
|
||||
@@ -2472,7 +2690,8 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
}
|
||||
}
|
||||
if !f.overflow.is_empty() {
|
||||
let left = x.min((row.right() - crate::tab_strip::CHEVRON_W).max(row.left()));
|
||||
// Pinned to the strip's right end, as Photoshop's is, not after the last tab (#8290).
|
||||
let left = (row.right() - crate::tab_strip::CHEVRON_W).max(row.left());
|
||||
let r = Rect::from_min_size(egui::pos2(left, row.top()), egui::vec2(crate::tab_strip::CHEVRON_W, row.height()));
|
||||
let labels: Vec<&str> =
|
||||
app.session.documents().iter().map(|st| st.doc.name.as_str()).chain(opening.iter().map(|(_, name, _)| name.as_str())).collect();
|
||||
@@ -2805,7 +3024,8 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
}
|
||||
}
|
||||
if !f.overflow.is_empty() {
|
||||
let left = x.min((strip.right() - crate::tab_strip::CHEVRON_W).max(strip.left()));
|
||||
// Pinned to the strip's right end, as Photoshop's is, not after the last tab (#8290).
|
||||
let left = (strip.right() - crate::tab_strip::CHEVRON_W).max(strip.left());
|
||||
let r = Rect::from_min_size(egui::pos2(left, strip.top()), egui::vec2(crate::tab_strip::CHEVRON_W, strip.height()));
|
||||
let labels: Vec<&str> = titles.iter().map(String::as_str).collect();
|
||||
let mut picked = None;
|
||||
@@ -3364,8 +3584,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
let rotated = view.rotation.abs() > 0.05;
|
||||
let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square));
|
||||
let tex = ensure_texture(app, &ctx, idx, output).map(|(tex, _scale)| tex);
|
||||
// A document too large for one full-resolution texture also gets the part in view at
|
||||
// the screen's resolution, drawn over the reduced whole (#7568).
|
||||
let in_view =
|
||||
InView { visible: visible_doc_rect(&xf), zoom: view.zoom, key: egui::Id::new(("pc-canvas-detail", ctx.viewport_id(), idx, primary)).value() };
|
||||
let textures = ensure_texture_in_view(app, &ctx, idx, output, Some(in_view));
|
||||
let tex = textures.as_ref().map(|t| t.texture);
|
||||
paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex);
|
||||
if let Some((detail, area)) = textures.and_then(|t| t.detail) {
|
||||
paint_cpu_detail(&painter, &xf, doc.bounds(), rotated, checkerboard, detail, area);
|
||||
}
|
||||
for &c in &pattern {
|
||||
paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex);
|
||||
}
|
||||
@@ -3682,7 +3910,23 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
.map(|m| crate::workspace_ui::sticky_mods(app, m))
|
||||
.filter(|m| m.command);
|
||||
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none();
|
||||
if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
|
||||
// ⌘Space / ⌥Space zoom under the dialog as they do without one, before Space pans (#6460).
|
||||
let held_zoom = !middle && matches!(temporary, Some(crate::hold_keys::Temporary::ZoomIn | crate::hold_keys::Temporary::ZoomOut));
|
||||
if held_zoom {
|
||||
let alt = ctx.input(|i| i.modifiers.alt);
|
||||
if free_hover {
|
||||
ctx.set_cursor_icon(crate::tool_cursor::zoom(&ctx, zoom_out(alt)));
|
||||
}
|
||||
let b = crate::dialogs::free_buttons(&ctx, rect);
|
||||
let pointer = ctx.input(|i| i.pointer.latest_pos());
|
||||
crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &b, pointer);
|
||||
if b.clicked
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let nz = crate::zoom_levels::step(view.zoom, if zoom_out(alt) { -1 } else { 1 }, view.doc_size);
|
||||
zoom_about(&mut view, &xf, p, nz, app.session.prefs().tools.zoom_clicked_point_to_center, ppp);
|
||||
}
|
||||
} else if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
|
||||
let d = xf.unmap_vec(d) / (view.zoom / ppp);
|
||||
view.center[0] -= d.x;
|
||||
view.center[1] -= d.y;
|
||||
@@ -4020,7 +4264,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
if press_stroke {
|
||||
buttons.clicked = false;
|
||||
}
|
||||
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]])));
|
||||
let at = response.interact_pointer_pos();
|
||||
// #6286: a guide let go over a ruler is removed, whichever ruler it is. Tested here in
|
||||
// screen space, where the rulers are: on the guide's own axis the release can still be
|
||||
// inside the document, which is what `finish_drag` sees.
|
||||
if app.guide_drag.is_some() {
|
||||
let on_ruler = at.is_some_and(|p| crate::rulers::dropped_on_a_ruler(app, full, p));
|
||||
if let Some(g) = app.guide_drag.as_mut() {
|
||||
g.over_ruler = on_ruler;
|
||||
}
|
||||
}
|
||||
let p = at.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 {
|
||||
tap_at_peak_pressure(app);
|
||||
tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods);
|
||||
@@ -4377,7 +4631,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// tip keeps the circle.
|
||||
_ => {
|
||||
let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip);
|
||||
let axes = crate::brush_cursor::tip_axes(&xf, brush.size);
|
||||
let axes = crate::brush_cursor::pen_axes(&xf, brush, app.stylus.sample());
|
||||
crate::brush_cursor::cursor(&painter, p, brush, full, axes, cur.painting == PaintingCursor::NormalTip, centre)
|
||||
}
|
||||
}
|
||||
@@ -4649,6 +4903,48 @@ fn paint_cpu_document(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ensure_texture_in_view`]'s detail texture over document area `area` (x0, y0, x1, y1), on its
|
||||
/// own checkerboard (or white), so the reduced texture under it doesn't show through where the
|
||||
/// image is transparent. The checkerboard keeps [`paint_cpu_document`]'s phase.
|
||||
fn paint_cpu_detail(
|
||||
painter: &egui::Painter,
|
||||
xf: &ViewXform,
|
||||
bounds: DRect,
|
||||
rotated: bool,
|
||||
checker: Option<(egui::TextureId, f32)>,
|
||||
tex: egui::TextureId,
|
||||
area: [f32; 4],
|
||||
) {
|
||||
let [x0, y0, x1, y1] = area;
|
||||
let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
||||
let pts = corners.map(|[x, y]| xf.to_screen(x, y));
|
||||
let quad = |tex: egui::TextureId, uvs: [Pos2; 4]| {
|
||||
let mut mesh = Mesh::with_texture(tex);
|
||||
for (pos, uv) in pts.into_iter().zip(uvs) {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
};
|
||||
match checker {
|
||||
Some((checker_id, square)) => {
|
||||
let tile = 2.0 * square;
|
||||
let uvs = if rotated {
|
||||
corners.map(|[x, y]| pos2(x / tile, y / tile))
|
||||
} else {
|
||||
let min = xf.doc_rect(bounds).min;
|
||||
pts.map(|p| ((p - min) / tile).to_pos2())
|
||||
};
|
||||
quad(checker_id, uvs);
|
||||
}
|
||||
None => {
|
||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), Color32::WHITE, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
quad(tex, [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]);
|
||||
}
|
||||
|
||||
fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, size: [f32; 2], color: Color32) {
|
||||
let [w, h] = size;
|
||||
let pts = vec![xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)];
|
||||
@@ -7154,6 +7450,109 @@ mod tests {
|
||||
assert_eq!(uploads, vec![TextureOptions::LINEAR]);
|
||||
}
|
||||
|
||||
/// #7568: a document over the texture limit was drawn from one reduced texture, so at 100 %
|
||||
/// it was magnified and blurred. The view now gets the part in sight at one texel per
|
||||
/// document pixel, about the window's size, whatever the device's limit.
|
||||
#[test]
|
||||
fn cpu_canvas_detail_plan_shows_large_documents_at_full_resolution() {
|
||||
let size = [12000, 8000];
|
||||
// A 1920×1080 view in the middle (`visible_doc_rect` adds 2 px around it).
|
||||
let screen = DRect::new(4998, 2998, 6922, 4082);
|
||||
for limit in [16384, 8192, 4096, 2048] {
|
||||
let side = clamp_texture_side(limit);
|
||||
assert!(texture_factor(size, side) > 1, "the whole document is reduced on a {limit} px device");
|
||||
for zoom in [1.0, 2.0, 8.0] {
|
||||
let plan = detail_plan(size, side, zoom, screen).unwrap_or_else(|| panic!("{limit} px device at {zoom}"));
|
||||
assert_eq!(plan.factor, 1, "one texel per document pixel at {zoom} on a {limit} px device");
|
||||
assert!(plan.region.contains_rect(&screen), "{plan:?}");
|
||||
assert!(plan.region.width() <= 1924 + 2 * 256 && plan.region.height() <= 1084 + 2 * 256, "about the view's size: {plan:?}");
|
||||
assert!(plan.region.width() < side && plan.region.height() < side, "{plan:?}");
|
||||
}
|
||||
}
|
||||
// A small pan reuses the region (no new composite).
|
||||
assert_eq!(detail_plan(size, 4096, 1.0, screen), detail_plan(size, 4096, 1.0, screen.translate(8, -8)));
|
||||
// A window wider than a small device's limit coarsens rather than exceed it.
|
||||
let wide = DRect::new(0, 0, 3000, 2000);
|
||||
let plan = detail_plan(size, 2048, 1.0, wide).unwrap();
|
||||
assert_eq!(plan.factor, 2);
|
||||
assert!(plan.region.width() / 2 < 2048 && plan.region.contains_rect(&wide), "{plan:?}");
|
||||
// Zoomed out a little: finer than the reduced whole, as fine as the screen.
|
||||
let plan = detail_plan(size, 4096, 0.4, DRect::new(0, 0, 4800, 2700)).unwrap();
|
||||
assert_eq!(plan.factor, 2);
|
||||
// Zoomed out far enough, the reduced whole is as fine as the screen: nothing to add.
|
||||
let whole = DRect::new(0, 0, 12000, 8000);
|
||||
assert_eq!(detail_plan(size, 4096, 0.25, whole), None);
|
||||
assert_eq!(detail_plan(size, 4096, 1.0 / 3.0, whole), None, "a detail as large as the document would not fit");
|
||||
// A document that fits is uploaded whole at full resolution.
|
||||
assert_eq!(detail_plan([4000, 3000], 4096, 1.0, screen), None);
|
||||
// Nothing of the document in view, or a broken zoom.
|
||||
assert_eq!(detail_plan(size, 4096, 1.0, DRect::new(-500, -500, -10, -10)), None);
|
||||
for zoom in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(detail_plan(size, 4096, zoom, screen), None, "{zoom}");
|
||||
}
|
||||
assert_eq!(detail_plan([0, 0], 4096, 1.0, screen), None);
|
||||
assert_eq!(detail_plan([u32::MAX, 8], 4096, 1.0, screen), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_canvas_detail_shows_a_large_documents_own_pixels_at_100_percent() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 3 * MAX_TEXTURE, "height": 8})).unwrap();
|
||||
app.run("select.rect", json!({"x": 101, "y": 0, "width": 1, "height": 8})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.input_mut(|i| i.max_texture_side = 16384);
|
||||
let uploads = |ctx: &egui::Context, tex: egui::TextureId| {
|
||||
let mut delta = ctx.tex_manager().write().take_delta();
|
||||
let seen: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| (d.pos, d.options, d.image.clone())).collect();
|
||||
delta.clear();
|
||||
seen
|
||||
};
|
||||
let row = |image: &egui::ImageData| {
|
||||
let egui::ImageData::Color(image) = image;
|
||||
(image.size, image.pixels.clone())
|
||||
};
|
||||
let view = InView { visible: DRect::new(0, 0, 1000, 8), zoom: 1.0, key: 1 };
|
||||
let t = ensure_texture_in_view(&mut app, &ctx, 0, None, Some(view)).unwrap();
|
||||
assert!(t.scale < 1.0, "the whole document is reduced");
|
||||
let (detail, area) = t.detail.expect("a detail texture at 100 %");
|
||||
assert_eq!(area, [0.0, 0.0, 1024.0, 8.0]);
|
||||
let crisp = TextureOptions { magnification: egui::TextureFilter::Nearest, ..TextureOptions::LINEAR };
|
||||
let up = uploads(&ctx, detail);
|
||||
assert_eq!(up.len(), 1);
|
||||
let (pos, options, image) = &up[0];
|
||||
assert_eq!((*pos, *options), (None, crisp));
|
||||
let (size, px) = row(image);
|
||||
assert_eq!(size, [1024, 8]);
|
||||
assert_eq!((px[100], px[101], px[102]), (Color32::WHITE, Color32::BLACK, Color32::WHITE), "the 1 px line stays 1 px");
|
||||
// Unchanged: nothing is composited again, and a small pan keeps the region.
|
||||
let panned = InView { visible: DRect::new(4, 0, 1004, 8), ..view };
|
||||
assert_eq!(ensure_texture_in_view(&mut app, &ctx, 0, None, Some(panned)).unwrap().detail, Some((detail, area)));
|
||||
assert!(uploads(&ctx, detail).is_empty());
|
||||
// The Navigator's call (no view) leaves the view's detail alone.
|
||||
ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
assert_eq!(app.canvases.values().map(|c| c.detail.len()).sum::<usize>(), 1);
|
||||
// An edit in view updates just its texels.
|
||||
app.run("select.rect", json!({"x": 300, "y": 0, "width": 1, "height": 8})).unwrap();
|
||||
ensure_texture_in_view(&mut app, &ctx, 0, None, Some(view)).unwrap();
|
||||
uploads(&ctx, detail);
|
||||
app.run("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
assert_eq!(ensure_texture_in_view(&mut app, &ctx, 0, None, Some(view)).unwrap().detail.map(|d| d.0), Some(detail));
|
||||
let up = uploads(&ctx, detail);
|
||||
assert_eq!(up.len(), 1);
|
||||
let (pos, options, image) = &up[0];
|
||||
assert_eq!(*options, crisp);
|
||||
let [x, y] = pos.expect("a partial update");
|
||||
let (size, px) = row(image);
|
||||
assert!(y == 0 && x < 300 && x + size[0] > 300, "the edit's texels: {x} {size:?}");
|
||||
assert_eq!((px[299 - x], px[300 - x]), (Color32::WHITE, Color32::BLACK));
|
||||
// Zoomed out, the reduced texture is enough: the detail goes.
|
||||
let far = InView { zoom: 0.25, ..view };
|
||||
assert!(ensure_texture_in_view(&mut app, &ctx, 0, None, Some(far)).unwrap().detail.is_none());
|
||||
assert!(app.canvases.values().all(|c| c.detail.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_display_luts_are_per_display() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
|
||||
@@ -224,3 +224,25 @@ fn crowded_tabs_keep_their_whole_names_and_the_menu_lists_every_document() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #8290: the » button sits at the strip's right end, as Photoshop's does, not right after the
|
||||
/// last tab that fits (which left a gap of up to a tab's width after it), in both layouts: it is
|
||||
/// in the same place however many tabs fit before it.
|
||||
#[test]
|
||||
fn the_overflow_button_is_pinned_to_the_right_end() {
|
||||
for theme in [ThemeKind::Pro, ThemeKind::Studio] {
|
||||
let at = |docs: usize| {
|
||||
let h = harness(docs, theme);
|
||||
let s = strip(&h);
|
||||
let button = h.get_by_label("More documents").rect();
|
||||
let last = s.tabs.iter().map(|(_, r)| r.right()).fold(f32::MIN, f32::max);
|
||||
assert!(button.left() >= last - 0.5, "{theme:?}: » {button:?} overlaps the last tab (ends at {last})");
|
||||
// At the end of the strip (the Studio row keeps a small inset).
|
||||
assert!(button.right() <= s.rect.right() + 0.5 && button.right() >= s.rect.right() - 10.0, "{theme:?}: » at {button:?} in {:?}", s.rect);
|
||||
(button, last)
|
||||
};
|
||||
let ((a, last_a), (b, last_b)) = (at(14), at(23));
|
||||
assert!((last_a - last_b).abs() > 1.0, "{theme:?}: the fits must end at different places to tell");
|
||||
assert_eq!(a, b, "{theme:?}: the » button doesn't follow the last tab");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let current_user = users.iter().rev().find(|(_, f)| matches(o, f)).map(|(n, _)| n.clone());
|
||||
let can_new = savable(o).is_some();
|
||||
let mut chosen: Option<Choice> = None;
|
||||
let parent_layer = ui.layer_id();
|
||||
egui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
|
||||
crate::press_menu::above_parent_ui(parent_layer, ui);
|
||||
let (before, after): (Vec<_>, Vec<_>) = builtin.iter().partition(|(k, _)| k != crop_size::FRONT);
|
||||
for (k, l) in &before {
|
||||
if ui.selectable_label(current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() {
|
||||
|
||||
@@ -50,6 +50,26 @@ pub(crate) fn titlebar_drag_area_is_clear(ctx: &egui::Context, area: egui::Rect)
|
||||
rects(ctx).iter().all(|dialog| !dialog.intersects(area))
|
||||
}
|
||||
|
||||
/// Whether the press that is being held began on a dialog (#6810). The dialog follows the pointer
|
||||
/// while its title is dragged, so the press origin ends up outside the (moved) dialog and a canvas
|
||||
/// check against the dialog rects alone would take the drag for a canvas drag. Remembered from the
|
||||
/// frame of the press, which is the only frame that carries the press event.
|
||||
fn press_began_on_dialog(ctx: &egui::Context, rects: &[egui::Rect]) -> bool {
|
||||
let key = egui::Id::new("pc-dialog-press-grabbed");
|
||||
let pressed = ctx.input(|i| {
|
||||
i.events.iter().rev().find_map(|e| match e {
|
||||
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(p) = pressed {
|
||||
let on_dialog = rects.iter().any(|r| r.contains(p));
|
||||
ctx.data_mut(|m| m.insert_temp(key, on_dialog));
|
||||
return on_dialog;
|
||||
}
|
||||
ctx.data(|m| m.get_temp::<bool>(key)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// With a dialog open the rest of the window is inert (egui's modal layer), but like Photoshop the
|
||||
/// image can still be panned and zoomed under it. The pointer position when it is over `canvas`
|
||||
/// and not over a dialog or one of its popups.
|
||||
@@ -66,12 +86,31 @@ pub fn free_pointer_over(ctx: &egui::Context, canvas: egui::Rect) -> Option<egui
|
||||
/// the free canvas. Returns this frame's pointer movement.
|
||||
pub fn pan_delta(ctx: &egui::Context, canvas: egui::Rect, hand: bool) -> Option<egui::Vec2> {
|
||||
let rects = rects(ctx);
|
||||
let grabbed = press_began_on_dialog(ctx, &rects);
|
||||
let (origin, panning, delta) = ctx.input(|i| {
|
||||
let p = &i.pointer;
|
||||
(p.press_origin(), p.middle_down() || (p.primary_down() && (hand || i.key_down(egui::Key::Space))), p.delta())
|
||||
});
|
||||
let origin = origin?;
|
||||
(panning && canvas.contains(origin) && !rects.iter().any(|r| r.contains(origin))).then_some(delta)
|
||||
(panning && !grabbed && canvas.contains(origin) && !rects.iter().any(|r| r.contains(origin))).then_some(delta)
|
||||
}
|
||||
|
||||
/// The primary button's drag and click on the free canvas under an open dialog, as the canvas
|
||||
/// response would report them if it weren't inert: for the held Zoom (⌘Space / ⌥Space, #6460).
|
||||
/// A drag counts from egui's drag threshold, like `drag_started`.
|
||||
pub fn free_buttons(ctx: &egui::Context, canvas: egui::Rect) -> crate::paint_mouse::Buttons {
|
||||
let id = egui::Id::new("pc-dialog-free-drag");
|
||||
let was = ctx.data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
|
||||
let rects = rects(ctx);
|
||||
let open_popup = egui::Popup::is_any_open(ctx);
|
||||
let free = |p: egui::Pos2| !open_popup && canvas.contains(p) && !rects.iter().any(|r| r.contains(p));
|
||||
let (down, dragging, clicked) = ctx.input(|i| {
|
||||
let p = &i.pointer;
|
||||
let dragging = p.primary_down() && p.is_decidedly_dragging() && (was || p.press_origin().is_some_and(free));
|
||||
(p.primary_down(), dragging, p.primary_clicked() && p.interact_pos().is_some_and(free))
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(id, dragging));
|
||||
crate::paint_mouse::Buttons { started: dragging && !was, dragged: dragging, stopped: was && !down, clicked }
|
||||
}
|
||||
|
||||
/// A click or drag with the primary button started on the free canvas under an open dialog, Space
|
||||
@@ -83,6 +122,7 @@ pub fn free_press(ctx: &egui::Context, canvas: egui::Rect) -> Option<egui::Pos2>
|
||||
}
|
||||
let rects = rects(ctx);
|
||||
let free = |p: egui::Pos2| canvas.contains(p) && !rects.iter().any(|r| r.contains(p));
|
||||
let grabbed = press_began_on_dialog(ctx, &rects);
|
||||
ctx.input(|i| {
|
||||
if i.key_down(egui::Key::Space) {
|
||||
return None;
|
||||
@@ -94,7 +134,7 @@ pub fn free_press(ctx: &egui::Context, canvas: egui::Rect) -> Option<egui::Pos2>
|
||||
if let Some(p) = pressed {
|
||||
return free(p).then_some(p);
|
||||
}
|
||||
let held = i.pointer.primary_down() && i.pointer.press_origin().is_some_and(free);
|
||||
let held = i.pointer.primary_down() && !grabbed && i.pointer.press_origin().is_some_and(free);
|
||||
i.pointer.latest_pos().filter(|p| held && free(*p))
|
||||
})
|
||||
}
|
||||
@@ -919,6 +959,30 @@ mod tests {
|
||||
assert!((moved - egui::vec2(-120.0, 40.0)).length() < 2.0, "the band beside the × moved the dialog: {moved:?}");
|
||||
}
|
||||
|
||||
/// #6810: dragging a dialog's title moves the dialog under the pointer, so the press origin is
|
||||
/// no longer inside it; that drag must not pan the canvas or reach a canvas tool.
|
||||
#[test]
|
||||
fn dragging_a_dialog_title_does_not_pan_the_canvas() {
|
||||
let ctx = egui::Context::default();
|
||||
let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 800.0));
|
||||
let at_press = egui::Rect::from_min_max(egui::pos2(100.0, 100.0), egui::pos2(300.0, 200.0));
|
||||
let moved = egui::Rect::from_min_max(egui::pos2(550.0, 480.0), egui::pos2(750.0, 580.0));
|
||||
let run = |rect: egui::Rect, events: Vec<egui::Event>| {
|
||||
ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECTS), vec![rect]));
|
||||
ctx.begin_pass(egui::RawInput { events, ..Default::default() });
|
||||
let out = (pan_delta(&ctx, canvas, true), free_press(&ctx, canvas));
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
out
|
||||
};
|
||||
let press =
|
||||
egui::Event::PointerButton { pos: egui::pos2(150.0, 120.0), button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE };
|
||||
let (pan, click) = run(at_press, vec![egui::Event::PointerMoved(egui::pos2(150.0, 120.0)), press]);
|
||||
assert!(pan.is_none() && click.is_none(), "the press is on the dialog");
|
||||
let (pan, click) = run(moved, vec![egui::Event::PointerMoved(egui::pos2(600.0, 500.0))]);
|
||||
assert!(pan.is_none(), "the dialog moved away from the press origin: {pan:?}");
|
||||
assert!(click.is_none(), "and the drag is not a canvas tool drag: {click:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_picker_has_inset_content_and_actions_at_the_right_edge() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
@@ -120,9 +120,12 @@ impl Group {
|
||||
}
|
||||
|
||||
/// Tabs that lay out their own scrolling list and footer, or size themselves to the group
|
||||
/// (the Navigator's preview grows with it, as in Photoshop: #2726).
|
||||
/// (the Navigator's preview grows with it, as in Photoshop: #2726). Channels and Paths are
|
||||
/// Layers' siblings and do the same: wrapped in the dock's scroll area as well, the two nested
|
||||
/// areas shared the wheel, and where the outer content overflowed by one physical pixel (at
|
||||
/// 125 % scaling for some window heights) the footer and list moved a pixel as you scrolled (#6238).
|
||||
pub fn scrolls_itself(self, tab: usize) -> bool {
|
||||
matches!((self, tab), (Group::Layers, 0) | (Group::History, 0) | (Group::History, 1) | (Group::Navigator, 0))
|
||||
matches!((self, tab), (Group::Layers, 0..=2) | (Group::History, 0) | (Group::History, 1) | (Group::Navigator, 0))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_mut(self, tabs: &mut DockTabs) -> &mut usize {
|
||||
|
||||
@@ -74,3 +74,56 @@ fn a_click_still_opens_the_list_for_a_second_click() {
|
||||
h.run_steps(3);
|
||||
assert_eq!(blend(&h), BlendMode::Screen, "a second click picks");
|
||||
}
|
||||
|
||||
/// #7113: on macOS a trackpad tap-to-click delivers `Pressed` and `Released` in the same frame,
|
||||
/// which raises both the modal dialog (`pointer_pressed_on_area`) and the reopened ComboBox popup
|
||||
/// (`!visible_last_frame`) in the same pass. When the popup's static LayerId already sits before
|
||||
/// the dialog's in `Areas::order` (from an earlier open or `ui.ron`), egui's stable sort left the
|
||||
/// dropdown menu behind the dialog window.
|
||||
#[test]
|
||||
fn single_frame_trackpad_tap_keeps_dialog_dropdown_above_modal_and_selects_option() {
|
||||
let mut h = app_with_a_layer();
|
||||
let tap = |h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2| {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(at));
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
};
|
||||
|
||||
// First dialog: open the Palette dropdown once so its static popup LayerId enters `Areas::order`,
|
||||
// then close the dialog.
|
||||
let d1 = crate::filter_dialog::open(h.state_mut(), "image.mode.indexedColor").unwrap();
|
||||
h.run_steps(3);
|
||||
let palette_box = blend_box(&h, "Selective").center();
|
||||
tap(&mut h, palette_box);
|
||||
assert!(egui::Popup::is_any_open(&h.ctx));
|
||||
crate::dialogs::cancel(h.state_mut(), d1).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
// Second dialog gets a newer `("dialog", d2)` LayerId placed after the popup's static LayerId
|
||||
// in `Areas::order` (the same ordering `ui.ron` restores across launches).
|
||||
let d2 = crate::filter_dialog::open(h.state_mut(), "image.mode.indexedColor").unwrap();
|
||||
h.run_steps(3);
|
||||
let palette_box = blend_box(&h, "Selective").center();
|
||||
let dialog_layer = h.ctx.layer_id_at(palette_box).expect("dialog layer at ComboBox");
|
||||
|
||||
// 1-frame trackpad tap on the Palette dropdown: the list must open IN FRONT OF the dialog.
|
||||
tap(&mut h, palette_box);
|
||||
assert!(egui::Popup::is_any_open(&h.ctx), "tap opens the dropdown");
|
||||
let perceptual_pos = h.get_by_label("Perceptual").rect().center();
|
||||
let top_at_item = h.ctx.layer_id_at(perceptual_pos);
|
||||
assert!(
|
||||
top_at_item.is_some() && top_at_item != Some(dialog_layer),
|
||||
"#7113: dropdown popup layer ({top_at_item:?}) must be above the modal dialog ({dialog_layer:?})"
|
||||
);
|
||||
|
||||
// Second 1-frame trackpad tap on "Perceptual" selects it and closes the list.
|
||||
tap(&mut h, perceptual_pos);
|
||||
assert!(!egui::Popup::is_any_open(&h.ctx), "tap on option closes the dropdown");
|
||||
assert_eq!(
|
||||
h.state().ui.dialogs.iter().find(|d| d.id == d2).and_then(|d| d.fields.get("palette")).and_then(|v| v.as_str()),
|
||||
Some("perceptual"),
|
||||
"tap on Perceptual selected it"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,56 @@ fn clicking_outside_a_dialog_keeps_it_open_and_the_canvas_pans_and_zooms() {
|
||||
assert!(h.state().ui.dialogs.is_empty());
|
||||
}
|
||||
|
||||
/// ⌘Space (Ctrl+Space off the Mac) and ⌥Space are the temporary Zoom under an open dialog too, as
|
||||
/// in Photoshop: a drag on the free canvas is a scrubby zoom about the press point, not a pan, and
|
||||
/// a click steps the zoom (#6460).
|
||||
#[test]
|
||||
fn temporary_zoom_works_under_a_dialog() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.extras.rulers = false;
|
||||
open_dialog(&mut h);
|
||||
let p = free_canvas(&h);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
let (z0, doc0) = (zoom(&h), doc_at(&h, p));
|
||||
let held = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool, m: Modifiers| {
|
||||
h.event(egui::Event::ModifiersChanged(m));
|
||||
h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: m });
|
||||
h.run_steps(1);
|
||||
};
|
||||
let button = |h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool, m: Modifiers| {
|
||||
h.event_modifiers(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: m }, m);
|
||||
h.run_steps(1);
|
||||
};
|
||||
held(&mut h, true, Modifiers::COMMAND);
|
||||
button(&mut h, p, true, Modifiers::COMMAND);
|
||||
for i in 1..=6 {
|
||||
h.hover_at(p + vec2(10.0 * i as f32, 0.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
button(&mut h, p + vec2(60.0, 0.0), false, Modifiers::COMMAND);
|
||||
held(&mut h, false, Modifiers::COMMAND);
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
let z1 = zoom(&h);
|
||||
let size = h.state().ui.views[0].doc_size;
|
||||
assert!((z1 - crate::zoom_tool::scrub_zoom(z0, 60.0, size)).abs() < 1e-4, "⌘Space-drag zooms in: {z0} -> {z1}");
|
||||
let doc1 = doc_at(&h, p);
|
||||
assert!((doc1[0] - doc0[0]).abs() < 0.5 && (doc1[1] - doc0[1]).abs() < 0.5, "the pressed point stays put: {doc0:?} -> {doc1:?}");
|
||||
|
||||
// ⌥Space-click steps out.
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
held(&mut h, true, Modifiers::ALT);
|
||||
button(&mut h, p, true, Modifiers::ALT);
|
||||
button(&mut h, p, false, Modifiers::ALT);
|
||||
held(&mut h, false, Modifiers::ALT);
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert_eq!(zoom(&h), crate::zoom_levels::step(z1, -1, size));
|
||||
assert_eq!(h.state().ui.dialogs.len(), 1);
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
@@ -503,6 +553,49 @@ fn alt_scroll_zooms_while_a_temporary_tool_is_held() {
|
||||
assert_eq!(h.state().ui.tool, crate::state::Tool::Brush);
|
||||
}
|
||||
|
||||
/// #8049 (Windows 11): the Alt key's press arrives, but the modifier state stays without Alt, so
|
||||
/// every wheel event while it is held reports no modifiers; tapping Ctrl brought Alt back. Until
|
||||
/// then ⌥ + scroll panned (or, with Zoom with Scroll Wheel on, zoomed) like a plain scroll.
|
||||
#[test]
|
||||
fn alt_scroll_zooms_when_the_modifier_state_misses_the_held_alt() {
|
||||
let alt_key =
|
||||
|pressed: bool, repeat: bool| egui::Event::Key { key: Key::AltLeft, physical_key: Some(Key::AltLeft), pressed, repeat, modifiers: Modifiers::NONE };
|
||||
for zoom_with_wheel in [false, true] {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel);
|
||||
let p = h.state().last_canvas_rect.center();
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let (z0, c0) = (zoom(&h), h.state().ui.views[0].center);
|
||||
// Alt goes down (and repeats while held); the wheel events carry no modifiers.
|
||||
h.event(alt_key(true, false));
|
||||
h.run_steps(1);
|
||||
h.event(alt_key(true, true));
|
||||
wheel(&h, 1.0, Modifiers::NONE);
|
||||
h.run_steps(2);
|
||||
wheel(&h, 1.0, Modifiers::NONE);
|
||||
h.run_steps(40);
|
||||
if zoom_with_wheel {
|
||||
// ⌥ + scroll scrolls instead of zooming.
|
||||
assert_eq!(zoom(&h), z0, "pref on: ⌥ + scroll never zooms");
|
||||
assert!(h.state().ui.views[0].center[1] < c0[1], "pref on: ⌥ + scroll pans: {c0:?} -> {:?}", h.state().ui.views[0].center);
|
||||
} else {
|
||||
assert!((zoom(&h) / z0 - 1.1f32.powi(2)).abs() < 1e-3, "two ⌥ notches are ×1.21: {z0} -> {}", zoom(&h));
|
||||
}
|
||||
// Alt released: the wheel does its plain thing again.
|
||||
h.event(alt_key(false, false));
|
||||
h.run_steps(2);
|
||||
let z1 = zoom(&h);
|
||||
wheel(&h, -1.0, Modifiers::NONE);
|
||||
h.run_steps(40);
|
||||
if zoom_with_wheel {
|
||||
assert!(zoom(&h) < z1, "pref on: a plain notch zooms out: {z1} -> {}", zoom(&h));
|
||||
} else {
|
||||
assert_eq!(zoom(&h), z1, "a plain scroll after Alt's release doesn't zoom");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_alt_arrows_kern_the_pair() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
|
||||
@@ -132,7 +132,9 @@ pub(super) fn row(
|
||||
let mut delete: Option<String> = None;
|
||||
let mut act: Option<Act> = None;
|
||||
let user = &app.session.presets.contours;
|
||||
let parent_layer = arrow.layer_id;
|
||||
egui::Popup::from_response(&arrow).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
crate::press_menu::above_parent_ui(parent_layer, ui);
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
egui::Grid::new(("contour-presets", effect, key)).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
for (i, (name, _)) in CONTOURS.iter().enumerate() {
|
||||
|
||||
@@ -160,6 +160,7 @@ pub mod object_finder;
|
||||
mod opacity_keys;
|
||||
pub mod open_prompt;
|
||||
pub mod outline;
|
||||
mod page_keys;
|
||||
pub mod paint_mouse;
|
||||
pub mod palette;
|
||||
mod panel_docking;
|
||||
@@ -217,6 +218,7 @@ pub mod swatches_ui;
|
||||
pub mod symmetry_ui;
|
||||
mod tab_strip;
|
||||
pub mod targa_options_ui;
|
||||
pub mod texel_guard;
|
||||
pub mod text_field_menu;
|
||||
pub mod theme;
|
||||
pub mod tiff_options_ui;
|
||||
@@ -3240,6 +3242,8 @@ mod warp_text_dialog_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_dropdown_wheel_tests;
|
||||
#[cfg(test)]
|
||||
mod paths_scroll_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod options_bar_mode_wheel_tests;
|
||||
|
||||
@@ -1047,6 +1047,22 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
|
||||
items.insert(after + 1 + k, it);
|
||||
}
|
||||
}
|
||||
// Window › Workspace: saved workspaces follow the presets, checked while current.
|
||||
let saved = crate::workspace_ui::saved_names(app);
|
||||
if !saved.is_empty()
|
||||
&& let Some(after) = items.iter().position(|i| i.id == "window.workspace.motion")
|
||||
{
|
||||
let wp: Vec<String> = vec!["Window".into(), "Workspace".into()];
|
||||
let separator = MenuItem { id: "---".into(), label: "---".into(), path: wp.clone(), shortcut: None, enabled: false, checked: None, color: None };
|
||||
let entries = saved.into_iter().enumerate().map(|(i, name)| {
|
||||
let id = format!("{}{i}", crate::workspace_ui::SAVED);
|
||||
let checked = checked(app, &id);
|
||||
MenuItem { id, label: name, path: wp.clone(), shortcut: None, enabled: true, checked, color: None }
|
||||
});
|
||||
for (k, it) in std::iter::once(separator).chain(entries).enumerate() {
|
||||
items.insert(after + 1 + k, it);
|
||||
}
|
||||
}
|
||||
// Help: the link items, a separator, then System Info and About.
|
||||
if let Some(at) = items.iter().position(|i| i.id == "help.systemInfo" || i.id == "help.about") {
|
||||
items.insert(
|
||||
|
||||
@@ -185,7 +185,7 @@ mod tests {
|
||||
assert_eq!(active_outline(&mut app, None, false, false), Some(a));
|
||||
|
||||
// When a drag or transform is in progress, no outline:
|
||||
app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0 });
|
||||
app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0, over_ruler: false });
|
||||
assert_eq!(active_outline(&mut app, None, false, false), None);
|
||||
app.guide_drag = None;
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Photoshop's page keys scroll the canvas (#8057): Page Up and Page Down a screen up or down,
|
||||
//! ⌘/Ctrl turns them sideways (⌘ Page Up left, ⌘ Page Down right), and ⇧ makes the step ten
|
||||
//! screen points. Home and End show the document's top-left and bottom-right corners. The zoom
|
||||
//! stays as it is. A binding of the user's on the same keys has already run and taken them.
|
||||
|
||||
use egui::{Event, Key, Modifiers, Vec2, vec2};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Screen points a ⇧ page key scrolls.
|
||||
pub const SMALL_STEP: f32 = 10.0;
|
||||
|
||||
/// What a page key does to the view.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum PageKey {
|
||||
/// Scroll by this many screen points (positive: show what is below or to the right).
|
||||
Scroll(Vec2),
|
||||
/// Show the document's top-left corner (Home) or its bottom-right one (End).
|
||||
Corner { end: bool },
|
||||
}
|
||||
|
||||
/// The page key `key` with `mods` held, in a canvas of `size` points. `None` for other keys, ⌥,
|
||||
/// or ⇧ on Home and End.
|
||||
pub fn page_key(key: Key, mods: Modifiers, size: Vec2) -> Option<PageKey> {
|
||||
if mods.alt {
|
||||
return None;
|
||||
}
|
||||
let sign = match key {
|
||||
Key::PageUp => -1.0,
|
||||
Key::PageDown => 1.0,
|
||||
Key::Home | Key::End if !mods.shift && !mods.command => return Some(PageKey::Corner { end: key == Key::End }),
|
||||
_ => return None,
|
||||
};
|
||||
let sideways = mods.command;
|
||||
let step = match (mods.shift, sideways) {
|
||||
(true, _) => SMALL_STEP,
|
||||
(false, true) => size.x,
|
||||
(false, false) => size.y,
|
||||
};
|
||||
Some(PageKey::Scroll(if sideways { vec2(sign * step, 0.0) } else { vec2(0.0, sign * step) }))
|
||||
}
|
||||
|
||||
/// Consumes this frame's page keys and moves the active document's view. True when one ran.
|
||||
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
let Some(i) = app.session.active_index() else { return false };
|
||||
let rect = crate::rulers::content_rect(app, app.last_canvas_rect);
|
||||
let size = rect.size();
|
||||
let presses: Vec<PageKey> = ctx.input_mut(|input| {
|
||||
let mut out = Vec::new();
|
||||
input.events.retain(|e| match e {
|
||||
Event::Key { key, pressed: true, modifiers, .. } => match page_key(*key, *modifiers, size) {
|
||||
Some(p) => {
|
||||
out.push(p);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
},
|
||||
_ => true,
|
||||
});
|
||||
out
|
||||
});
|
||||
if presses.is_empty() {
|
||||
return false;
|
||||
}
|
||||
app.sync_views();
|
||||
let Some(doc_size) = app.session.active().map(|st| [st.doc.size.width as f32, st.doc.size.height as f32]) else { return false };
|
||||
let (ppp, aspect, flip, overscroll) = (app.ppp, app.ui.view.display_aspect(), app.ui.view.flip_horizontal, app.session.prefs().tools.overscroll);
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let Some(view) = app.ui.views.get_mut(i) else { return false };
|
||||
let zoom = view.zoom / ppp;
|
||||
if !zoom.is_finite() || zoom <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
view.fit_pending = false;
|
||||
view.fill_pending = false;
|
||||
for p in presses {
|
||||
match p {
|
||||
PageKey::Scroll(d) => {
|
||||
let xf = crate::canvas::ViewXform { rect, zoom, center: view.center, flip, rotation: view.rotation, aspect };
|
||||
let d = xf.unmap_vec(d) / zoom;
|
||||
if d.is_finite() {
|
||||
view.center[0] += d.x;
|
||||
view.center[1] += d.y;
|
||||
}
|
||||
}
|
||||
PageKey::Corner { end } => {
|
||||
// The document corner goes to the canvas corner the document runs on from: the
|
||||
// top-left one in an upright view, another when it is rotated or flipped.
|
||||
let xf = crate::canvas::ViewXform { rect, zoom, center: view.center, flip, rotation: view.rotation, aspect };
|
||||
let [w, h] = doc_size;
|
||||
let (corner, inward) = if end { ([w, h], vec2(-1.0, -1.0)) } else { ([0.0, 0.0], vec2(1.0, 1.0)) };
|
||||
let dir = xf.map_vec(inward);
|
||||
let at = vec2(-dir.x.signum() * size.x / 2.0, -dir.y.signum() * size.y / 2.0);
|
||||
let off = xf.unmap_vec(at) / zoom;
|
||||
if off.is_finite() {
|
||||
view.center = [corner[0] - off.x, corner[1] - off.y];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Overscroll off: the view stops at the edge, as the scrollbars do.
|
||||
if !overscroll {
|
||||
crate::scrollbars::clamp_view_with_aspect(view, size, ppp, aspect);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_keys_map_to_photoshops_scrolls() {
|
||||
let size = vec2(800.0, 600.0);
|
||||
let (none, shift, cmd) = (Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND);
|
||||
assert_eq!(page_key(Key::PageDown, none, size), Some(PageKey::Scroll(vec2(0.0, 600.0))));
|
||||
assert_eq!(page_key(Key::PageUp, none, size), Some(PageKey::Scroll(vec2(0.0, -600.0))));
|
||||
assert_eq!(page_key(Key::PageDown, shift, size), Some(PageKey::Scroll(vec2(0.0, SMALL_STEP))));
|
||||
assert_eq!(page_key(Key::PageUp, cmd, size), Some(PageKey::Scroll(vec2(-800.0, 0.0))));
|
||||
assert_eq!(page_key(Key::PageDown, cmd | shift, size), Some(PageKey::Scroll(vec2(SMALL_STEP, 0.0))));
|
||||
assert_eq!(page_key(Key::Home, none, size), Some(PageKey::Corner { end: false }));
|
||||
assert_eq!(page_key(Key::End, none, size), Some(PageKey::Corner { end: true }));
|
||||
assert_eq!(page_key(Key::PageDown, Modifiers::ALT, size), None);
|
||||
assert_eq!(page_key(Key::Home, shift, size), None);
|
||||
assert_eq!(page_key(Key::ArrowDown, none, size), None);
|
||||
}
|
||||
|
||||
/// #8057: through the app's key handling, Page Down scrolls a zoomed-in document a screen
|
||||
/// down at the same zoom, ⇧ a small step, ⌘ sideways; Home and End jump to the corners.
|
||||
#[test]
|
||||
fn page_keys_scroll_the_zoomed_canvas() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 2000, "height": 2000})).unwrap();
|
||||
app.sync_views();
|
||||
app.ppp = 1.0;
|
||||
let v = &mut app.ui.views[0];
|
||||
(v.zoom, v.center, v.fit_pending, v.fill_pending) = (4.0, [1000.0, 1000.0], false, false);
|
||||
let size = crate::rulers::content_rect(&app, app.last_canvas_rect).size();
|
||||
let press = |app: &mut PhotocraftApp, key: Key, modifiers: Modifiers| {
|
||||
let raw = egui::RawInput {
|
||||
events: vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.begin_pass(raw);
|
||||
crate::shortcuts::handle(app, &ctx);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
app.ui.views[0].center
|
||||
};
|
||||
let near = |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3;
|
||||
let c = press(&mut app, Key::PageDown, Modifiers::NONE);
|
||||
assert!(near(c, [1000.0, 1000.0 + size.y / 4.0]), "a screen down: {c:?}");
|
||||
assert_eq!(app.ui.views[0].zoom, 4.0, "the zoom stays");
|
||||
let c = press(&mut app, Key::PageUp, Modifiers::NONE);
|
||||
assert!(near(c, [1000.0, 1000.0]), "and back: {c:?}");
|
||||
let c = press(&mut app, Key::PageDown, Modifiers::SHIFT);
|
||||
assert!(near(c, [1000.0, 1000.0 + SMALL_STEP / 4.0]), "a small step: {c:?}");
|
||||
let c = press(&mut app, Key::PageUp, Modifiers::COMMAND);
|
||||
assert!(near(c, [1000.0 - size.x / 4.0, 1000.0 + SMALL_STEP / 4.0]), "a screen left: {c:?}");
|
||||
let c = press(&mut app, Key::Home, Modifiers::NONE);
|
||||
assert!(near(c, [size.x / 8.0, size.y / 8.0]), "the top-left corner: {c:?}");
|
||||
let c = press(&mut app, Key::End, Modifiers::NONE);
|
||||
assert!(near(c, [2000.0 - size.x / 8.0, 2000.0 - size.y / 8.0]), "the bottom-right corner: {c:?}");
|
||||
|
||||
// Rotated a quarter turn or flipped, the corner still lands on a canvas corner with the
|
||||
// document running into the canvas from it.
|
||||
let rect = crate::rulers::content_rect(&app, app.last_canvas_rect);
|
||||
for (rotation, flip) in [(90.0, false), (0.0, true), (-90.0, true)] {
|
||||
app.ui.views[0].rotation = rotation;
|
||||
app.ui.view.flip_horizontal = flip;
|
||||
for (key, corner, inward) in [(Key::Home, [0.0, 0.0], 1.0), (Key::End, [2000.0, 2000.0], -1.0)] {
|
||||
let center = press(&mut app, key, Modifiers::NONE);
|
||||
let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center, flip, rotation, aspect: 1.0 };
|
||||
let at = xf.to_screen(corner[0], corner[1]);
|
||||
let on_corner = [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()].iter().any(|c| c.distance(at) < 1e-2);
|
||||
assert!(on_corner, "{key:?} at {rotation}° flip {flip}: {at:?} in {rect:?}");
|
||||
assert!(
|
||||
rect.contains(xf.to_screen(corner[0] + inward, corner[1] + inward)),
|
||||
"{key:?} at {rotation}° flip {flip}: the document runs into the canvas"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +447,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool {
|
||||
let output = if layout.root.is_some() {
|
||||
let mut panel = egui::Panel::right("shared-panel-dock")
|
||||
.default_size(if t.pro { 290.0 } else { 300.0 })
|
||||
.size_range(crate::panels::DOCK_WIDTH)
|
||||
.size_range(crate::panels::dock_width_range(ui.ctx().content_rect().width()))
|
||||
.resizable(!locked)
|
||||
.frame(egui::Frame::NONE.fill(t.card));
|
||||
if let Some(width) = requested_width {
|
||||
|
||||
@@ -561,7 +561,9 @@ fn legacy_dock_width_is_consumed_by_custom_renderer_and_clamped_to_existing_rang
|
||||
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 800.0));
|
||||
let ctx = h.ctx.clone();
|
||||
for (requested, expected) in [(410.0, 410.0), (900.0, 520.0), (20.0, 250.0)] {
|
||||
// A 1280-point window: the dock may take all but 400 points (#7896), so 700 is no longer
|
||||
// cut to 520.
|
||||
for (requested, expected) in [(410.0, 410.0), (700.0, 700.0), (900.0, 880.0), (20.0, 250.0)] {
|
||||
assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true);
|
||||
h.run_steps(2);
|
||||
let state = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap();
|
||||
@@ -787,3 +789,13 @@ fn native_tab_reorder_stays_native_until_drag_crosses_the_strip() {
|
||||
let channels = tabs.iter().position(|(_, label)| *label == "Channels").unwrap();
|
||||
assert!(channels < layers, "upstream native reorder remains effective");
|
||||
}
|
||||
|
||||
/// #7896: the dock's widest is the window less what the toolbar and the canvas keep, never
|
||||
/// below the old 520 points, so a wide window allows a two-column dock.
|
||||
#[test]
|
||||
fn the_dock_can_be_as_wide_as_the_window_allows() {
|
||||
for (window, max) in [(2560.0, 2160.0), (1280.0, 880.0), (800.0, 520.0), (f32::NAN, 520.0)] {
|
||||
let range = crate::panels::dock_width_range(window);
|
||||
assert_eq!((*range.start(), *range.end()), (crate::panels::DOCK_MIN_WIDTH, max), "{window}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,8 +616,8 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range());
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap));
|
||||
let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
if drag.drag_started_by(egui::PointerButton::Primary) {
|
||||
crate::titlebar::start_os_move(ui.ctx());
|
||||
}
|
||||
if drag.double_clicked() {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
@@ -670,16 +670,8 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let ui = &mut group_ui;
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let mut ws = app.ui.workspace.clone();
|
||||
let opts = [
|
||||
// Keys stay English (`apply_workspace` matches them); the dropdown
|
||||
// shows the labels in the UI language.
|
||||
("Essentials".to_string(), tl!("Essentials")),
|
||||
("Photography".to_string(), tl!("Photography")),
|
||||
("Painting".to_string(), tl!("Painting")),
|
||||
("Graphic and Web".to_string(), tl!("Graphic and Web")),
|
||||
("Pixel Art".to_string(), tl!("Pixel Art")),
|
||||
("Motion".to_string(), tl!("Motion")),
|
||||
];
|
||||
let options = crate::workspace_ui::switcher_options(app);
|
||||
let opts: Vec<(String, &str)> = options.iter().map(|(k, l)| (k.clone(), l.as_str())).collect();
|
||||
// A narrow bar drops what is also in a menu, the theme toggle first
|
||||
// (Preferences), then search (Edit › Search), and narrows the switcher
|
||||
// (Window › Workspace) before anything runs over.
|
||||
@@ -1847,8 +1839,18 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::dock::persist(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// The dock's width range (points). It is the right dock, on the left in a right-to-left UI.
|
||||
pub(crate) const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
|
||||
/// The dock's narrowest width (points). It is the right dock, on the left in a right-to-left UI.
|
||||
pub(crate) const DOCK_MIN_WIDTH: f32 = 250.0;
|
||||
/// What the dock leaves the toolbar and the canvas when it is widest (points).
|
||||
const DOCK_LEAVES: f32 = 400.0;
|
||||
|
||||
/// The dock's width range (points) in a window `window_width` wide: from [`DOCK_MIN_WIDTH`] to
|
||||
/// everything but [`DOCK_LEAVES`], and never capped below the old 520, so two-column layouts can
|
||||
/// be as wide as the window allows (#7896).
|
||||
pub(crate) fn dock_width_range(window_width: f32) -> std::ops::RangeInclusive<f32> {
|
||||
let max = if window_width.is_finite() { (window_width - DOCK_LEAVES).max(520.0) } else { 520.0 };
|
||||
DOCK_MIN_WIDTH..=max
|
||||
}
|
||||
|
||||
pub(crate) fn dock_width_id() -> egui::Id {
|
||||
egui::Id::new("dock-width-request")
|
||||
@@ -1856,7 +1858,8 @@ pub(crate) fn dock_width_id() -> egui::Id {
|
||||
|
||||
/// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range.
|
||||
pub fn request_dock_width(ctx: &egui::Context, w: f32) {
|
||||
let w = if w.is_finite() { w.clamp(*DOCK_WIDTH.start(), *DOCK_WIDTH.end()) } else { *DOCK_WIDTH.start() };
|
||||
let range = dock_width_range(ctx.content_rect().width());
|
||||
let w = if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { *range.start() };
|
||||
ctx.data_mut(|d| d.insert_temp(dock_width_id(), w));
|
||||
}
|
||||
|
||||
@@ -1879,7 +1882,10 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
|
||||
return;
|
||||
}
|
||||
let margin = if t.pro { 2 } else { 8 };
|
||||
let mut panel = crate::direction::end_panel("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH);
|
||||
let mut panel = crate::direction::end_panel("dock")
|
||||
.resizable(true)
|
||||
.default_size(if t.pro { 290.0 } else { 300.0 })
|
||||
.size_range(dock_width_range(ui.ctx().content_rect().width()));
|
||||
if let Some(w) = ui.ctx().data_mut(|d| d.remove_temp::<f32>(dock_width_id())) {
|
||||
panel = panel.exact_size(w);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Scrolling the Paths panel moves only its list (#6238).
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
fn harness(paths: usize, size: egui::Vec2, ppp: f32) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(size).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
for i in 0..paths {
|
||||
let x = (i % 8) as i32 * 40;
|
||||
app.run("select.rect", json!({"x": x, "y": 20, "width": 30, "height": 30})).unwrap();
|
||||
app.run("select.toWorkPath", json!({"tolerance": 2})).unwrap();
|
||||
app.run("path.rename", json!({"name": "work", "to": format!("Path {}", i + 1)})).unwrap();
|
||||
}
|
||||
// Layers | Channels | Paths: the Paths tab.
|
||||
app.ui.dock_tabs.layers = 2;
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// Scrolling the path list moves the rows and nothing else: the footer's buttons stay where they
|
||||
/// are. The Paths panel lays out its own list and footer, but the dock also wrapped it in a
|
||||
/// scroll area; at 125 % scaling and some window heights that outer area overflowed by one
|
||||
/// physical pixel, took part of the wheel and moved the footer about a pixel down and back up.
|
||||
#[test]
|
||||
fn scrolling_the_paths_list_does_not_move_the_footer() {
|
||||
// Heights that moved it at 125 % (two bands of window heights), and ones that never did.
|
||||
for (ppp, height) in [(1.25, 520.0), (1.25, 900.0), (1.25, 960.0), (1.0, 900.0), (1.5, 900.0)] {
|
||||
let mut h = harness(30, vec2(1440.0, height), ppp);
|
||||
let footer = |h: &Harness<'_, PhotocraftApp>| h.query_by_label("Delete path").map(|r| r.rect().min.y);
|
||||
let rest = footer(&h).unwrap_or_else(|| panic!("the footer shows at {ppp}x, {height} high"));
|
||||
let first_row = h.query_by_label("Path 1").unwrap().rect();
|
||||
h.hover_at(first_row.center());
|
||||
h.run_steps(2);
|
||||
for i in 0..40 {
|
||||
// Down to the end of the list and past it, then back up.
|
||||
let dir = if i < 30 { -1.0 } else { 1.0 };
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, dir),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
h.run_steps(1);
|
||||
assert_eq!(footer(&h), Some(rest), "{ppp}x, {height} high, notch {i}: the footer stayed put");
|
||||
}
|
||||
// The list itself did scroll (the first row is out of view or has moved).
|
||||
let moved = h.query_by_label("Path 1").map(|r| r.rect().min.y) != Some(first_row.min.y);
|
||||
assert!(moved || h.query_by_label("Path 1").is_none(), "{ppp}x, {height} high: the list scrolled");
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels, Paths and Layers are siblings with the same own-scroll layout, so none is wrapped in
|
||||
/// the dock's scroll area.
|
||||
#[test]
|
||||
fn the_layers_group_tabs_all_scroll_themselves() {
|
||||
use crate::dock::Group;
|
||||
for tab in 0..=2 {
|
||||
assert!(Group::Layers.scrolls_itself(tab), "Layers group tab {tab}");
|
||||
}
|
||||
assert!(!Group::Layers.scrolls_itself(3), "a tab that does not exist is not special");
|
||||
}
|
||||
@@ -3898,6 +3898,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_snap_off_survives_a_restart() {
|
||||
let (mut app, store) = app_with_store();
|
||||
let ctx = egui::Context::default();
|
||||
tick(&mut app, &ctx);
|
||||
assert!(app.ui.extras.snap, "on by default");
|
||||
crate::menus::invoke(&mut app, &ctx, "view.snap", json!({})).unwrap();
|
||||
assert!(!app.ui.extras.snap);
|
||||
crate::view_cmds::persist(&mut app, &ctx);
|
||||
tick(&mut app, &ctx);
|
||||
assert_eq!(stored(&store)["viewExtras"], json!({"snap": false}));
|
||||
let saved = store.lock().unwrap().clone().unwrap();
|
||||
let (mut app2, _) = app_with_saved(Some(saved));
|
||||
tick(&mut app2, &ctx);
|
||||
assert!(!app2.ui.extras.snap, "still off after a restart");
|
||||
crate::menus::invoke(&mut app2, &ctx, "view.snap", json!({})).unwrap();
|
||||
crate::view_cmds::persist(&mut app2, &ctx);
|
||||
assert!(app2.session.prefs().view_extras.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_tooltips_preferences_turn_tooltips_off() {
|
||||
let (mut app, _) = app_with_store();
|
||||
|
||||
@@ -79,6 +79,22 @@ impl PressMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep an open popup or dropdown layer above `parent` when `parent` is also in
|
||||
/// [`egui::Order::Foreground`] (a modal dialog or popover, #7113): a 1-frame trackpad tap raises
|
||||
/// both the parent modal (`pointer_pressed_on_area`) and the reopened popup (`!visible_last_frame`)
|
||||
/// in the same pass, and egui's stable `wants_to_be_on_top` sort then leaves a previously used
|
||||
/// popup behind the dialog.
|
||||
pub fn above_parent(ctx: &egui::Context, parent: egui::LayerId, child: egui::LayerId) {
|
||||
if parent.order == child.order && parent != child {
|
||||
ctx.set_sublayer(parent, child);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`above_parent`] for an open popup's `Ui`.
|
||||
pub fn above_parent_ui(parent: egui::LayerId, popup_ui: &egui::Ui) {
|
||||
above_parent(popup_ui.ctx(), parent, popup_ui.layer_id());
|
||||
}
|
||||
|
||||
/// Shows `button`'s menu (an `egui::Popup::menu`) with press-drag-release; `content` chooses its
|
||||
/// items with [`PressMenu::item`].
|
||||
pub fn show<R>(button: &Response, content: impl FnOnce(&mut Ui, &PressMenu) -> R) -> Option<R> {
|
||||
@@ -89,6 +105,9 @@ pub fn show<R>(button: &Response, content: impl FnOnce(&mut Ui, &PressMenu) -> R
|
||||
// close it again.
|
||||
let close = if button.clicked() && menu.owns_gesture(&ctx) { PopupCloseBehavior::IgnoreClicks } else { PopupCloseBehavior::CloseOnClick };
|
||||
let shown = Popup::menu(button).open_memory(open).close_behavior(close).show(|ui| content(ui, &menu)).map(|r| r.inner);
|
||||
if shown.is_some() || Popup::is_id_open(&ctx, menu.popup) {
|
||||
above_parent(&ctx, button.layer_id, egui::LayerId::new(egui::Order::Foreground, menu.popup));
|
||||
}
|
||||
// This menu's gesture ends with the button (its release was handled by the items above).
|
||||
if menu.owns_gesture(&ctx) && ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| d.remove::<egui::Id>(gesture_id()));
|
||||
@@ -150,6 +169,9 @@ impl PressCombo {
|
||||
} else if self.owns_gesture(ctx) && ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| d.remove::<egui::Id>(gesture_id()));
|
||||
}
|
||||
if Popup::is_id_open(ctx, self.popup) {
|
||||
above_parent(ctx, button.layer_id, egui::LayerId::new(egui::Order::Foreground, self.popup));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+201
-15
@@ -1,6 +1,6 @@
|
||||
//! View extras: rulers (⌘R), grid (⌘'), guides (⌘;). Guides are dragged out of the rulers and
|
||||
//! moved with the Move tool (drag one off the canvas to delete it), all through undoable engine
|
||||
//! commands (`view.newGuide` / `view.moveGuide` / `view.deleteGuide`).
|
||||
//! moved with the Move tool (drag one off the canvas or onto a ruler to delete it), all through
|
||||
//! undoable engine commands (`view.newGuide` / `view.moveGuide` / `view.deleteGuide`).
|
||||
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use photocraft_doc::Document;
|
||||
@@ -96,13 +96,16 @@ pub struct GuideDrag {
|
||||
/// Where the pointer went down, on the guide's axis: released there, an existing guide is
|
||||
/// clicked (selected), not moved.
|
||||
pub press: f64,
|
||||
/// Whether the pointer was over a ruler when the guide was let go (#6286): a guide released
|
||||
/// there is removed, whatever its own axis reads.
|
||||
pub over_ruler: bool,
|
||||
}
|
||||
|
||||
impl GuideDrag {
|
||||
/// An existing guide taken at document point `p`.
|
||||
pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self {
|
||||
let at = if vertical { p[0] } else { p[1] };
|
||||
Self { vertical, index: Some(index), pos: at, press: at }
|
||||
Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +114,20 @@ pub fn content_rect(app: &PhotocraftApp, rect: Rect) -> Rect {
|
||||
if app.ui.extras.rulers { Rect::from_min_max(rect.min + vec2(RULER, RULER), rect.max) } else { rect }
|
||||
}
|
||||
|
||||
/// Whether the screen point `p` is over a ruler, inside the full canvas rect `full`. A guide let go
|
||||
/// there is removed, as in Photoshop, and either ruler removes it (#6286): the top ruler measures
|
||||
/// document x and the left one y, so the ruler the guide was not dragged out of can lie over a
|
||||
/// coordinate that is still inside the document on the guide's own axis.
|
||||
pub(crate) fn dropped_on_a_ruler(app: &PhotocraftApp, full: Rect, p: Pos2) -> bool {
|
||||
if !app.ui.extras.rulers {
|
||||
return false;
|
||||
}
|
||||
// The rulers take the strips [`content_rect`] trims off the canvas rect, and nothing else: a
|
||||
// canvas point on the boundary is canvas.
|
||||
let content = content_rect(app, full);
|
||||
full.contains(p) && (p.x < content.min.x || p.y < content.min.y)
|
||||
}
|
||||
|
||||
/// Tick step (document px) so major ticks are at least ~60 screen px apart: 1, 2, 5, 10, 20, 50…
|
||||
#[cfg(test)]
|
||||
fn tick_step(zoom: f32) -> f64 {
|
||||
@@ -259,12 +276,15 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Finish a guide drag: create, move, or delete (dropped outside the canvas). An existing guide
|
||||
/// released where it was pressed is a click: it's selected and stays put, as in Photoshop; a
|
||||
/// moved one stays selected.
|
||||
/// Finish a guide drag: create, move, or delete (dropped outside the canvas or on a ruler). An
|
||||
/// existing guide released where it was pressed is a click: it's selected and stays put, as in
|
||||
/// Photoshop; a moved one stays selected.
|
||||
pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) {
|
||||
let Some(size) = app.session.active().map(|s| s.doc.size) else { return };
|
||||
// A drop on a ruler removes the guide even when the drag held its own axis, which otherwise
|
||||
// reads as a click: dragged sideways onto the left ruler, a horizontal guide never moves in y.
|
||||
if let Some(i) = d.index
|
||||
&& !d.over_ruler
|
||||
&& (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5
|
||||
{
|
||||
crate::guide_select::select(app, d.vertical, i);
|
||||
@@ -272,7 +292,7 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) {
|
||||
}
|
||||
let extent = if d.vertical { size.width } else { size.height } as f64;
|
||||
let orientation = if d.vertical { "vertical" } else { "horizontal" };
|
||||
let inside = d.pos >= 0.0 && d.pos <= extent;
|
||||
let inside = !d.over_ruler && d.pos >= 0.0 && d.pos <= extent;
|
||||
let pos = (d.pos * 1000.0).round() / 1000.0;
|
||||
let _ = match (d.index, inside) {
|
||||
(None, true) => app.run("view.newGuide", json!({"orientation": orientation, "position": pos})),
|
||||
@@ -490,11 +510,13 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
|
||||
let d = xf.to_doc(pos);
|
||||
let along = if vertical { d[0] } else { d[1] };
|
||||
let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) };
|
||||
app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos });
|
||||
app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false });
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(d) = app.guide_drag.take()
|
||||
&& let Some(mut d) = app.guide_drag.take()
|
||||
{
|
||||
// Let go over a ruler (the other one, say) the drag is discarded, not placed (#6286).
|
||||
d.over_ruler = resp.interact_pointer_pos().is_some_and(|p| dropped_on_a_ruler(app, full, p));
|
||||
finish_drag(app, d);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
@@ -750,13 +772,13 @@ mod tests {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.hover_doc = None;
|
||||
app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0 });
|
||||
app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0, over_ruler: false });
|
||||
let mut h = egui_kittest::Harness::builder()
|
||||
.with_size(vec2(260.0, 240.0))
|
||||
.build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("42").is_some(), "vertical guide: X readout");
|
||||
h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0 });
|
||||
h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0, over_ruler: false });
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout");
|
||||
}
|
||||
@@ -1002,14 +1024,14 @@ mod tests {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0 });
|
||||
finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0 }); // off canvas: ignored
|
||||
finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0, over_ruler: false });
|
||||
finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0, over_ruler: false }); // off canvas: ignored
|
||||
assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![50.0]);
|
||||
assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty());
|
||||
assert_eq!(guide_at(&app, 51.0, 10.0), Some((true, 0)));
|
||||
finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0 });
|
||||
finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0, over_ruler: false });
|
||||
assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![120.0]);
|
||||
finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0 });
|
||||
finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0, over_ruler: false });
|
||||
assert!(app.session.active().unwrap().doc.guides.vertical.is_empty());
|
||||
app.ui.extras.lock_guides = true;
|
||||
assert_eq!(guide_at(&app, 0.0, 0.0), None);
|
||||
@@ -1203,6 +1225,170 @@ mod tests {
|
||||
assert_eq!(drag(left, egui::Modifiers::ALT), (vec![], vec![90.0]), "Alt from the left ruler: horizontal");
|
||||
}
|
||||
|
||||
/// #6286: a guide let go over a ruler is removed, whichever ruler it is. The top ruler measures
|
||||
/// document x and the left one y, so a horizontal guide released on the left ruler is still
|
||||
/// inside the document on its own axis, and used to be placed instead of discarded.
|
||||
#[test]
|
||||
fn a_guide_dropped_on_either_ruler_is_removed() {
|
||||
let new_app = || {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 256, "height": 128})).unwrap();
|
||||
app.ui.extras.rulers = true;
|
||||
// No snapping, so a guide that is kept lands exactly where it is let go.
|
||||
app.ui.extras.snap = false;
|
||||
app
|
||||
};
|
||||
let drag = |from: Pos2, to: Pos2| {
|
||||
let (mut h, _, _) = ruler_harness(new_app());
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
press(&mut h, from, true, egui::Modifiers::NONE);
|
||||
h.event(egui::Event::PointerMoved(to));
|
||||
h.run_steps(1);
|
||||
press(&mut h, to, false, egui::Modifiers::NONE);
|
||||
let g = &h.state().session.active().unwrap().doc.guides;
|
||||
(g.vertical.clone(), g.horizontal.clone())
|
||||
};
|
||||
// #5698: out of the top ruler is a horizontal guide, out of the left one a vertical guide.
|
||||
let (top, left) = (pos2(200.0, RULER / 2.0), pos2(RULER / 2.0, 150.0));
|
||||
assert_eq!(drag(top, left), (vec![], vec![]), "out of the top ruler, released on the left ruler");
|
||||
assert_eq!(drag(left, top), (vec![], vec![]), "out of the left ruler, released on the top ruler");
|
||||
// Released on the canvas the guide lands where it was let go, as before.
|
||||
assert_eq!(drag(top, pos2(200.0, 150.0)), (vec![], vec![56.0]), "released on the canvas");
|
||||
assert_eq!(drag(left, pos2(120.0, 150.0)), (vec![40.0], vec![]), "released on the canvas");
|
||||
}
|
||||
|
||||
/// #6286: the guide is removed when it is let go over a ruler, and moved when it is not. The
|
||||
/// release can be inside the document on the guide's own axis and still be a ruler drop, so the
|
||||
/// two are told apart by where the pointer is, not by the position it reads.
|
||||
#[test]
|
||||
fn a_guide_let_go_on_a_ruler_is_removed_not_moved() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
let horizontal = |app: &PhotocraftApp| app.session.active().unwrap().doc.guides.horizontal.clone();
|
||||
for drag in [
|
||||
GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 10.0, over_ruler: true },
|
||||
// Dragged sideways onto the left ruler, a horizontal guide holds its y, which on its own
|
||||
// reads as a click: the ruler still removes it.
|
||||
GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 50.0, over_ruler: true },
|
||||
] {
|
||||
app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap();
|
||||
finish_drag(&mut app, drag);
|
||||
assert!(horizontal(&app).is_empty(), "let go on a ruler: removed, {drag:?}");
|
||||
}
|
||||
app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap();
|
||||
finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 10.0, over_ruler: false });
|
||||
assert_eq!(horizontal(&app), vec![80.0], "let go on the canvas: moved");
|
||||
// Clicked where it was pressed, on the canvas: selected and kept, as before.
|
||||
finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 80.0, over_ruler: false });
|
||||
assert_eq!(horizontal(&app), vec![80.0], "clicked on the canvas: kept");
|
||||
assert!(app.ui.selected_guide.is_some(), "clicked on the canvas: selected");
|
||||
}
|
||||
|
||||
/// The ruler bands are the strips the rulers take off the canvas rect, and only while the rulers
|
||||
/// are shown: with them hidden the whole canvas rect is canvas (#6286).
|
||||
#[test]
|
||||
fn only_the_ruler_bands_count_as_a_ruler() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let full = Rect::from_min_size(pos2(16.0, 42.0), vec2(968.0, 642.0));
|
||||
let content = Rect::from_min_size(pos2(32.0, 58.0), vec2(952.0, 626.0));
|
||||
app.ui.extras.rulers = true;
|
||||
assert!(dropped_on_a_ruler(&app, full, pos2(full.min.x + RULER / 2.0, 371.0)), "the left ruler");
|
||||
assert!(dropped_on_a_ruler(&app, full, pos2(508.0, full.min.y + RULER / 2.0)), "the top ruler");
|
||||
assert!(dropped_on_a_ruler(&app, full, full.min), "the ruler corner");
|
||||
assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, 371.0)), "the canvas");
|
||||
assert!(!dropped_on_a_ruler(&app, full, content.min), "the canvas' own top-left corner");
|
||||
assert!(!dropped_on_a_ruler(&app, full, pos2(content.min.x + 0.5, 371.0)), "the first canvas column");
|
||||
assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, content.min.y + 0.5)), "the first canvas row");
|
||||
assert!(!dropped_on_a_ruler(&app, full, pos2(full.min.x - 1.0, 371.0)), "outside the canvas rect");
|
||||
app.ui.extras.rulers = false;
|
||||
for p in [pos2(full.min.x + RULER / 2.0, 371.0), pos2(508.0, full.min.y + RULER / 2.0), full.min] {
|
||||
assert!(!dropped_on_a_ruler(&app, full, p), "rulers hidden: no band at {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas the guides live on, rulers on, at 100 %.
|
||||
fn canvas_harness(tool: crate::state::Tool) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.extras.rulers = true;
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.tool = tool;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::shortcuts::handle(app, &ctx);
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
let v = &mut h.state_mut().ui.views[0];
|
||||
v.zoom = 1.0;
|
||||
v.center = [200.0, 150.0];
|
||||
v.fit_pending = false;
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
/// #6286: an existing guide dragged onto a ruler with the Move tool is deleted, though the
|
||||
/// release is inside the document on the guide's own axis: the left ruler measures y, so a
|
||||
/// horizontal guide let go there still reads a y inside the canvas and used to be moved. Held
|
||||
/// level, that release reads as a click on the guide, which a ruler drop also outranks.
|
||||
#[test]
|
||||
fn a_guide_dragged_onto_a_ruler_is_deleted_through_the_canvas() {
|
||||
for below in [50.0f32, 0.0] {
|
||||
let mut h = canvas_harness(crate::state::Tool::Move);
|
||||
h.state_mut().run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap();
|
||||
h.run_steps(1);
|
||||
let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| {
|
||||
let app = h.state();
|
||||
let v = &app.ui.views[0];
|
||||
let xf = ViewXform {
|
||||
rect: content_rect(app, app.last_canvas_rect),
|
||||
zoom: v.zoom,
|
||||
center: v.center,
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
rotation: v.rotation,
|
||||
aspect: app.ui.view.display_aspect(),
|
||||
};
|
||||
xf.to_screen(x, y)
|
||||
};
|
||||
let on_the_guide = at(&h, 200.0, 150.0);
|
||||
// In the left ruler's band, `below` document px under the press: inside the canvas on the
|
||||
// guide's own axis, which is where the old code moved the guide instead of removing it.
|
||||
let canvas = h.state().last_canvas_rect;
|
||||
let onto_the_left_ruler = pos2(canvas.min.x + RULER / 2.0, on_the_guide.y + below);
|
||||
// Along the guide, so a held-level release keeps its y, the way a sideways drag does.
|
||||
let dragged = pos2(on_the_guide.x - 12.0, on_the_guide.y);
|
||||
h.event(egui::Event::PointerMoved(on_the_guide));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: on_the_guide, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
// Past egui's click distance, so the drag starts at the press, on the guide.
|
||||
h.event(egui::Event::PointerMoved(dragged));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().guide_drag.is_some(), "the Move tool takes the guide");
|
||||
h.event(egui::Event::PointerMoved(onto_the_left_ruler));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton {
|
||||
pos: onto_the_left_ruler,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(2);
|
||||
let g = &h.state().session.active().unwrap().doc.guides;
|
||||
assert!(g.horizontal.is_empty(), "{below} px below the press, released on the left ruler: got {:?}", g.horizontal);
|
||||
assert!(h.state().guide_drag.is_none(), "the drag is over");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws
|
||||
/// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped.
|
||||
#[test]
|
||||
|
||||
@@ -764,6 +764,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Page Up / Page Down (⌘ sideways, ⇧ a small step), Home and End scroll the canvas (#8057).
|
||||
if crate::page_keys::handle(app, ctx) {
|
||||
return;
|
||||
}
|
||||
// 1–0 set opacity, ⇧1–0 flow or fill (`opacity_keys`).
|
||||
crate::opacity_keys::handle(app, ctx);
|
||||
// ⇧+ / ⇧− step the blend mode (`blend_keys`).
|
||||
|
||||
@@ -304,7 +304,7 @@ fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
|
||||
fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
|
||||
let tol = tolerance(app);
|
||||
let Some(snap) = app.prefs_rt.snap.as_ref() else { return p };
|
||||
if snap.disabled {
|
||||
if !snap_on(app) || snap.disabled {
|
||||
app.prefs_rt.snap_lines.clear();
|
||||
return p;
|
||||
}
|
||||
@@ -758,6 +758,20 @@ mod tests {
|
||||
assert!(!override_held(cmd) && override_held(ctrl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_off_disables_all_snapping_even_with_smart_guides() {
|
||||
let mut app = app_with_box();
|
||||
app.ui.tool = Tool::Move;
|
||||
assert!(app.ui.view.show.smart_guides);
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.tool_options.move_auto_select = false;
|
||||
let m = egui::Modifiers::NONE;
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m);
|
||||
crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m);
|
||||
assert_eq!(mover_bounds(&app).x0, 303);
|
||||
assert!(app.prefs_rt.snap_lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_corner_snaps_to_guides_and_document_edges() {
|
||||
let mut app = app_with_box();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Intel Iris Xe on DirectX 12: no egui texture upload carries an all-zero texel, because the
|
||||
//! driver shows stale memory where it does (the red blocks in Brush Preset picker strokes and
|
||||
//! around tool icons).
|
||||
//!
|
||||
//! wgpu creates every texture on Iris Xe as a committed resource (its workaround for
|
||||
//! gfx-rs/wgpu#3552, where suballocated textures had the same symptom) and, since wgpu added it,
|
||||
//! with `D3D12_HEAP_FLAG_CREATE_NOT_ZEROED`. egui-wgpu fills each new texture with one full copy,
|
||||
//! so wgpu never clears it first. On this driver the copy leaves 4×4 blocks whose texels are all
|
||||
//! (0, 0, 0, 0) showing whatever the memory last held, typically a freed texture's pixels. Blocks
|
||||
//! with any other value upload correctly, which is why the garbage sits only in the empty corners
|
||||
//! of a thumbnail, never next to what is drawn. It shows up after textures have been freed and
|
||||
//! made again: scrolling the picker (stroke previews come and go), switching brush tools (the
|
||||
//! options bar and toolbar icons are rasterized again).
|
||||
//!
|
||||
//! [`TexelGuard`] rewrites fully transparent texels in every upload as premultiplied
|
||||
//! (0, 0, 0, 1) before eframe hands the upload to the renderer: alpha 1/255 black, which darkens
|
||||
//! what is underneath by less than one 8-bit level. It is installed only on the affected adapter
|
||||
//! ([`affected`]); `PHOTOCRAFT_TEXEL_GUARD=1` forces it on (to check it on any GPU) and `=0` off.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use eframe::wgpu;
|
||||
use egui::{Color32, FullOutput, ImageData, TexturesDelta};
|
||||
|
||||
/// What a fully transparent texel is uploaded as.
|
||||
pub const NEAR_CLEAR: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 1);
|
||||
|
||||
/// Whether texture uploads on this adapter need the guard (`env`: `PHOTOCRAFT_TEXEL_GUARD`).
|
||||
pub fn affected(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
|
||||
match env {
|
||||
Some("1") => true,
|
||||
Some("0") => false,
|
||||
// The same adapter test wgpu uses for its #3552 workaround.
|
||||
_ => info.backend == wgpu::Backend::Dx12 && info.name.contains("Iris(R) Xe"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite the fully transparent texels of every upload in `delta` as [`NEAR_CLEAR`]; returns
|
||||
/// how many changed. Images without one are not copied.
|
||||
pub fn guard(delta: &mut TexturesDelta) -> usize {
|
||||
let mut changed = 0;
|
||||
for d in delta.set.values_mut().flatten() {
|
||||
let ImageData::Color(image) = &mut d.image;
|
||||
if !image.pixels.contains(&Color32::TRANSPARENT) {
|
||||
continue;
|
||||
}
|
||||
for p in &mut Arc::make_mut(image).pixels {
|
||||
if *p == Color32::TRANSPARENT {
|
||||
*p = NEAR_CLEAR;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The egui plugin that runs [`guard`] on each frame's texture uploads.
|
||||
pub struct TexelGuard;
|
||||
|
||||
impl egui::Plugin for TexelGuard {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"TexelGuard"
|
||||
}
|
||||
|
||||
fn output_hook(&mut self, _ctx: &egui::Context, output: &mut FullOutput) {
|
||||
guard(&mut output.textures_delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install [`TexelGuard`] on `ctx` when the window's adapter needs it; returns whether it did.
|
||||
/// Call once, with the adapter eframe renders the window with.
|
||||
pub fn install(ctx: &egui::Context, info: &wgpu::AdapterInfo) -> bool {
|
||||
let on = affected(info, std::env::var("PHOTOCRAFT_TEXEL_GUARD").ok().as_deref());
|
||||
if on && ctx.plugin_opt::<TexelGuard>().is_none() {
|
||||
ctx.add_plugin(TexelGuard);
|
||||
log::info!("texture uploads: transparent texels written as alpha 1/255 ({}, {:?}; gfx-rs/wgpu#3552)", info.name, info.backend);
|
||||
}
|
||||
on
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::epaint::ImageDelta;
|
||||
use egui::{ColorImage, TextureId, TextureOptions};
|
||||
|
||||
fn adapter(name: &str, backend: wgpu::Backend) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo { name: name.into(), ..wgpu::AdapterInfo::new(wgpu::DeviceType::IntegratedGpu, backend) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_iris_xe_on_dx12_is_affected_unless_forced() {
|
||||
assert!(affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), None));
|
||||
assert!(!affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Vulkan), None));
|
||||
assert!(!affected(&adapter("Intel(R) UHD Graphics 620", wgpu::Backend::Dx12), None));
|
||||
assert!(!affected(&adapter("NVIDIA GeForce RTX 4070", wgpu::Backend::Dx12), None));
|
||||
assert!(affected(&adapter("NVIDIA GeForce RTX 4070", wgpu::Backend::Dx12), Some("1")));
|
||||
assert!(!affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), Some("0")));
|
||||
assert!(affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), Some("nonsense")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_texels_upload_as_near_clear_and_nothing_else_changes() {
|
||||
// A stroke-strip-like image: mostly transparent, a grey texel and a faint one.
|
||||
let grey = Color32::from_gray(200);
|
||||
let faint = Color32::from_rgba_premultiplied(1, 1, 1, 2);
|
||||
let mut px = vec![Color32::TRANSPARENT; 16];
|
||||
px[5] = grey;
|
||||
px[6] = faint;
|
||||
let strip = Arc::new(ColorImage::new([4, 4], px));
|
||||
let opaque = Arc::new(ColorImage::filled([2, 2], grey));
|
||||
let mut delta = TexturesDelta::default();
|
||||
delta.set.insert(TextureId::Managed(1), [ImageDelta::full(ImageData::Color(strip.clone()), TextureOptions::LINEAR)].into_iter().collect());
|
||||
delta.set.insert(TextureId::Managed(2), [ImageDelta::full(ImageData::Color(opaque.clone()), TextureOptions::LINEAR)].into_iter().collect());
|
||||
// A partial update (the font atlas grows this way) is guarded too.
|
||||
let patch = Arc::new(ColorImage::new([2, 1], vec![Color32::TRANSPARENT, grey]));
|
||||
delta.set.insert(TextureId::Managed(0), [ImageDelta::partial([3, 4], ImageData::Color(patch), TextureOptions::LINEAR)].into_iter().collect());
|
||||
|
||||
assert_eq!(guard(&mut delta), 14 + 1);
|
||||
let image = |id| {
|
||||
let ImageData::Color(i) = &delta.set[&TextureId::Managed(id)][0].image;
|
||||
i.clone()
|
||||
};
|
||||
let s = image(1);
|
||||
assert!(!s.pixels.contains(&Color32::TRANSPARENT));
|
||||
assert_eq!((s.pixels[5], s.pixels[6], s.pixels[0]), (grey, faint, NEAR_CLEAR));
|
||||
// The caller's image is untouched (copy on write), and an image with nothing to change is
|
||||
// not copied at all.
|
||||
assert!(strip.pixels.contains(&Color32::TRANSPARENT));
|
||||
assert!(Arc::ptr_eq(&image(2), &opaque));
|
||||
assert_eq!(image(0).pixels, vec![NEAR_CLEAR, grey]);
|
||||
// Idempotent: a second pass finds nothing.
|
||||
assert_eq!(guard(&mut delta), 0);
|
||||
delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_clear_is_premultiplied_and_invisible_over_any_background() {
|
||||
// Premultiplied blend: out = src + dst × (1 − a). Over every 8-bit level the result moves
|
||||
// by at most one level.
|
||||
let a = f32::from(NEAR_CLEAR.a()) / 255.0;
|
||||
assert!(NEAR_CLEAR.r() <= NEAR_CLEAR.a() && NEAR_CLEAR.g() <= NEAR_CLEAR.a() && NEAR_CLEAR.b() <= NEAR_CLEAR.a());
|
||||
for dst in 0..=255u8 {
|
||||
let out = (f32::from(NEAR_CLEAR.r()) + f32::from(dst) * (1.0 - a)).round();
|
||||
assert!((f32::from(dst) - out).abs() <= 1.0, "{dst} -> {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plugin_guards_a_frames_uploads() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(TexelGuard);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let img = ColorImage::new([8, 8], vec![Color32::TRANSPARENT; 64]);
|
||||
let tex = ui.ctx().load_texture("texel-guard-test", img, TextureOptions::LINEAR);
|
||||
ui.image((tex.id(), egui::vec2(8.0, 8.0)));
|
||||
});
|
||||
let uploaded: Vec<_> = out
|
||||
.textures_delta
|
||||
.set
|
||||
.values()
|
||||
.flatten()
|
||||
.filter_map(|d| {
|
||||
let ImageData::Color(i) = &d.image;
|
||||
(i.size == [8, 8]).then(|| i.clone())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(uploaded.len(), 1);
|
||||
assert!(uploaded[0].pixels.iter().all(|p| *p == NEAR_CLEAR));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_adds_the_plugin_once_and_only_where_needed() {
|
||||
if std::env::var_os("PHOTOCRAFT_TEXEL_GUARD").is_some() {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!install(&ctx, &adapter("Apple M2", wgpu::Backend::Metal)));
|
||||
assert!(ctx.plugin_opt::<TexelGuard>().is_none());
|
||||
let iris = adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12);
|
||||
assert!(install(&ctx, &iris) && install(&ctx, &iris));
|
||||
assert!(ctx.plugin_opt::<TexelGuard>().is_some());
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,8 @@ pub(crate) fn modal_drag_overlay(ctx: &egui::Context) {
|
||||
}
|
||||
egui::Area::new(Id::new("titlebar-modal-drag-overlay")).order(Order::Tooltip).fixed_pos(area.min).movable(false).sense(Sense::hover()).show(ctx, |ui| {
|
||||
let response = ui.allocate_rect(area, Sense::click_and_drag());
|
||||
if response.drag_started() {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
if response.drag_started_by(PointerButton::Primary) {
|
||||
start_os_move(ctx);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
@@ -266,7 +266,7 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) {
|
||||
for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) {
|
||||
let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() });
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
start_os_move(ui.ctx());
|
||||
}
|
||||
let pressed = resp.is_pointer_button_down_on() && resp.hovered();
|
||||
if windows {
|
||||
@@ -468,7 +468,7 @@ fn resize_handed_off_id() -> Id {
|
||||
Id::new("titlebar-resize-handed-off")
|
||||
}
|
||||
|
||||
/// An OS resize started at `pass`; `released` is the pass whose input ended the press
|
||||
/// An OS resize or move started at `pass`; `released` is the pass whose input ended the press
|
||||
/// ([`release_after_os_resize`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct HandOff {
|
||||
@@ -476,11 +476,26 @@ struct HandOff {
|
||||
released: Option<u64>,
|
||||
}
|
||||
|
||||
/// The compositor takes the button release of an OS resize (KDE Wayland), so egui kept the edge
|
||||
/// pressed: the zones stopped hovering until the next click, every other resize (#1581). End the
|
||||
/// press ourselves on the input after the hand-off, but only while egui still has it down and this
|
||||
/// input brings no release of its own: where the OS does send it (Windows, X11), nothing is added,
|
||||
/// so no second release can turn into a click. Call from `raw_input_hook`.
|
||||
/// The OS took the press at this pass: [`release_after_os_resize`] ends it on the next input.
|
||||
fn hand_off(ctx: &egui::Context) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(resize_handed_off_id(), HandOff { pass, released: None }));
|
||||
}
|
||||
|
||||
/// Move the window from this press. The window manager takes the press of an OS move as it does
|
||||
/// that of a resize and keeps its release, so egui kept the drag: every other move failed, and a
|
||||
/// resize right after one (#5725). The same hand-off ends it.
|
||||
pub(crate) fn start_os_move(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
hand_off(ctx);
|
||||
}
|
||||
|
||||
/// The compositor takes the button release of an OS resize or move (KDE Wayland, KWin on X11), so
|
||||
/// egui kept the edge pressed: the zones stopped hovering until the next click, every other resize
|
||||
/// (#1581, and #5725 for moves). End the press ourselves on the input after the hand-off, but only
|
||||
/// while egui still has it down and this input brings no release of its own: where the OS does send
|
||||
/// it (Windows), nothing is added, so no second release can turn into a click. Call from
|
||||
/// `raw_input_hook`.
|
||||
pub fn release_after_os_resize(ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let Some(h) = ctx.data(|d| d.get_temp::<HandOff>(resize_handed_off_id())) else { return };
|
||||
if h.released.is_some() || h.pass >= ctx.cumulative_pass_nr() {
|
||||
@@ -524,8 +539,7 @@ pub fn resize_zones(ui: &mut Ui) {
|
||||
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(resize_handed_off_id(), HandOff { pass, released: None }));
|
||||
hand_off(&ctx);
|
||||
}
|
||||
}
|
||||
// After the zones, so its one-frame cursor wins over theirs.
|
||||
@@ -608,9 +622,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn gesture_with_modal(&mut self, path: &[Pos2], modal: bool) -> Vec<ViewportCommand> {
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
|
||||
self.gesture_by(path, modal, egui::PointerButton::Primary)
|
||||
}
|
||||
|
||||
fn gesture_by(&mut self, path: &[Pos2], modal: bool, btn: egui::PointerButton) -> Vec<ViewportCommand> {
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true, btn)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false, btn)]);
|
||||
events
|
||||
.into_iter()
|
||||
.flat_map(|e| self.frame_with_modal(e, modal).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default())
|
||||
@@ -654,8 +672,8 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
fn button(p: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
fn button(p: Pos2, pressed: bool, button: egui::PointerButton) -> Event {
|
||||
Event::PointerButton { pos: p, button, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -758,6 +776,23 @@ mod tests {
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_button_drag_in_the_gap_does_not_start_a_window_drag() {
|
||||
let mut w = Win::new(1440.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(980.0, 16.0);
|
||||
let path = [empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)];
|
||||
let cmds = w.gesture_by(&path, false, egui::PointerButton::Middle);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
|
||||
// An ordinary left drag afterwards still moves the window.
|
||||
let cmds = w.gesture(&path);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
// Same with a modal open (the overlay over the gap).
|
||||
w.frame_with_modal(vec![], true);
|
||||
let cmds = w.gesture_by(&path, true, egui::PointerButton::Middle);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_drag_overlay_does_not_cover_a_dialog_moved_into_the_gap() {
|
||||
let mut w = Win::new(1440.0);
|
||||
@@ -780,7 +815,7 @@ mod tests {
|
||||
let p = pos2(3.0, 300.0);
|
||||
// Press and drag; the compositor takes over, so no release ever arrives.
|
||||
let mut cmds = vec![];
|
||||
for e in [vec![Event::PointerMoved(p)], vec![button(p, true)], vec![Event::PointerMoved(p + vec2(6.0, 0.0))]] {
|
||||
for e in [vec![Event::PointerMoved(p)], vec![button(p, true, egui::PointerButton::Primary)], vec![Event::PointerMoved(p + vec2(6.0, 0.0))]] {
|
||||
cmds.extend(w.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default());
|
||||
}
|
||||
assert!(cmds.contains(&ViewportCommand::BeginResize(ResizeDirection::West)), "{cmds:?}");
|
||||
@@ -795,7 +830,7 @@ mod tests {
|
||||
assert!(w.gesture(&[p, p + vec2(6.0, 0.0)]).contains(&ViewportCommand::BeginResize(ResizeDirection::West)));
|
||||
}
|
||||
|
||||
/// Where the OS does send the release of a resize (Windows, X11), no second one is added: a
|
||||
/// Where the OS does send the release of a resize (Windows), no second one is added: a
|
||||
/// release right after it could count as a click on what lies under the edge.
|
||||
#[test]
|
||||
fn a_release_the_os_sends_is_not_doubled() {
|
||||
@@ -803,10 +838,10 @@ mod tests {
|
||||
w.frame(vec![]);
|
||||
let p = pos2(3.0, 300.0);
|
||||
w.frame(vec![Event::PointerMoved(p)]);
|
||||
w.frame(vec![button(p, true)]);
|
||||
w.frame(vec![button(p, true, egui::PointerButton::Primary)]);
|
||||
w.frame(vec![Event::PointerMoved(p + vec2(6.0, 0.0))]);
|
||||
// The release arrives with the next input.
|
||||
let mut raw = RawInput { events: vec![button(p + vec2(6.0, 0.0), false)], ..Default::default() };
|
||||
let mut raw = RawInput { events: vec![button(p + vec2(6.0, 0.0), false, egui::PointerButton::Primary)], ..Default::default() };
|
||||
release_after_os_resize(&w.ctx, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1, "{:?}", raw.events);
|
||||
// Or it already arrived in an earlier input: egui has the button up, nothing to end.
|
||||
@@ -818,6 +853,31 @@ mod tests {
|
||||
assert!(raw.events.is_empty(), "{:?}", raw.events);
|
||||
}
|
||||
|
||||
/// #5725: the window manager keeps the button release of an OS move just as of a resize, so
|
||||
/// egui kept the drag: every other move of the window failed, and so did a resize right after one.
|
||||
#[test]
|
||||
fn after_an_os_move_the_bar_and_the_edges_take_the_next_press() {
|
||||
/// Press and drag along `path`; the window manager takes over, so no release ever arrives.
|
||||
fn handed_off(w: &mut Win, path: &[Pos2]) -> Vec<ViewportCommand> {
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true, egui::PointerButton::Primary)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![]);
|
||||
events.into_iter().flat_map(|e| w.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
|
||||
}
|
||||
let moves = |cmds: &[ViewportCommand]| cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count();
|
||||
let mut w = Win::new(1440.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(980.0, 16.0);
|
||||
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
|
||||
let drag = [empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)];
|
||||
assert_eq!(moves(&handed_off(&mut w, &drag)), 1);
|
||||
assert!(!w.ctx.input(|i| i.pointer.primary_down()), "the press ends with the hand-off");
|
||||
assert_eq!(moves(&w.gesture(&drag)), 1, "the next move");
|
||||
assert_eq!(moves(&handed_off(&mut w, &drag)), 1);
|
||||
let edge = pos2(3.0, 300.0);
|
||||
assert!(w.gesture(&[edge, edge + vec2(6.0, 0.0)]).contains(&ViewportCommand::BeginResize(ResizeDirection::West)), "a resize after a move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_zones_resize_unless_maximized() {
|
||||
use ResizeDirection::*;
|
||||
|
||||
@@ -380,6 +380,80 @@ fn harness() -> Harness<'static, crate::PhotocraftApp> {
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn brush_cursor_follows_pen_orientation_6350() {
|
||||
for control in ["penTilt", "rotation"] {
|
||||
let mut h = harness();
|
||||
h.state_mut()
|
||||
.run(
|
||||
"tools.setBrush",
|
||||
json!({"brush": {
|
||||
"size": 60, "hardness": 1.0, "roundness": 0.25,
|
||||
"shapeDynamics": {"enabled": true, "angle": {"control": control}}
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 45.0, ..Default::default() }));
|
||||
h.run_steps(3);
|
||||
let initial = h.output().platform_output.cursor_image.clone().expect("initial brush cursor");
|
||||
h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_y: -45.0, rotation: 90.0, ..Default::default() }));
|
||||
h.run_steps(3);
|
||||
let turned = h.output().platform_output.cursor_image.clone().expect("turned brush cursor");
|
||||
save_pen_cursor_image(&format!("{control}-initial"), &initial);
|
||||
save_pen_cursor_image(&format!("{control}-turned"), &turned);
|
||||
let pen = h.state().stylus.sample().expect("simulated pen reaches the canvas");
|
||||
assert_eq!((pen.tilt_y, pen.rotation), (-45.0, 90.0));
|
||||
let point = photocraft_paint::StrokePoint { tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() };
|
||||
let step = photocraft_paint::dynamics::StepInput { point, direction: 0.0, initial_direction: 0.0, step: 0 };
|
||||
let mut dabs = Vec::new();
|
||||
photocraft_paint::dynamics::DabBuilder::new(&h.state().session.tools.brush).build(&step, &mut dabs);
|
||||
assert_eq!(dabs.len(), 1);
|
||||
assert!((dabs[0].angle.to_degrees() - 90.0).abs() < 0.001, "painted dab turns: {control}");
|
||||
assert_ne!(initial.rgba, turned.rgba, "cursor must follow pen orientation: {control}");
|
||||
// The dynamically rotated cursor must match the same tip set to that angle directly.
|
||||
h.state_mut().session.tools.brush.shape_dynamics.enabled = false;
|
||||
h.state_mut().session.tools.brush.angle = 90.0;
|
||||
h.run_steps(3);
|
||||
let reference = h.output().platform_output.cursor_image.clone().expect("static rotated reference");
|
||||
assert_eq!(turned.size, reference.size);
|
||||
assert_eq!(turned.hotspot, reference.hotspot);
|
||||
assert_eq!(turned.rgba, reference.rgba, "dynamic cursor matches the dab angle: {control}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn round_cursor_shows_brush_projection_6350() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.tools.brush.shape_dynamics.enabled = true;
|
||||
h.state_mut().session.tools.brush.shape_dynamics.brush_projection = true;
|
||||
h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample::default()));
|
||||
h.run_steps(3);
|
||||
let upright = h.output().platform_output.cursor_image.clone().unwrap();
|
||||
h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() }));
|
||||
h.run_steps(3);
|
||||
let tilted = h.output().platform_output.cursor_image.clone().unwrap();
|
||||
save_pen_cursor_image("projection-upright", &upright);
|
||||
save_pen_cursor_image("projection-tilted", &tilted);
|
||||
assert_ne!(upright.rgba, tilted.rgba, "a projected round brush must not keep a circular cursor");
|
||||
h.state_mut().stylus.feed.set(None);
|
||||
h.run_steps(3);
|
||||
assert_eq!(upright.rgba, h.output().platform_output.cursor_image.clone().unwrap().rgba, "mouse restores the unprojected tip");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_pen_cursor_image(name: &str, cursor: &egui::CustomCursorImage) {
|
||||
let Some(dir) = std::env::var_os("PHOTOCRAFT_CURSOR_OUTPUT") else { return };
|
||||
let dir = std::path::PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let image =
|
||||
photocraft_codecs::Image::from_u8(u32::from(cursor.size[0]), u32::from(cursor.size[1]), photocraft_codecs::ChannelLayout::Rgba, cursor.rgba.to_vec())
|
||||
.unwrap();
|
||||
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes() {
|
||||
|
||||
@@ -302,6 +302,9 @@ fn remembered(app: &PhotocraftApp) -> Value {
|
||||
if e.guides != def.guides {
|
||||
m.insert("guides".into(), Value::Bool(e.guides));
|
||||
}
|
||||
if e.snap != def.snap {
|
||||
m.insert("snap".into(), Value::Bool(e.snap));
|
||||
}
|
||||
if let Some(show) = changed_flags(v.show, Show::default(), &SHOW_KEYS, show_slot) {
|
||||
m.insert("show".into(), show);
|
||||
}
|
||||
@@ -342,6 +345,9 @@ pub fn restore(app: &mut PhotocraftApp) {
|
||||
if let Some(on) = flag("guides") {
|
||||
app.ui.extras.guides = on;
|
||||
}
|
||||
if let Some(on) = flag("snap") {
|
||||
app.ui.extras.snap = on;
|
||||
}
|
||||
for (k, v) in saved.get("show").and_then(Value::as_object).into_iter().flatten() {
|
||||
if let (Some(slot), Some(on)) = (show_slot(&mut app.ui.view.show, k), v.as_bool()) {
|
||||
*slot = on;
|
||||
|
||||
@@ -23,10 +23,11 @@
|
||||
//! The target behaviour is exact ×1.1 steps, one per notch, with no easing in between
|
||||
//! (measured frame by frame on a screen recording). egui smooths a wheel notch over several
|
||||
//! frames, so zooming reads the wheel events themselves and ignores that smoothed tail; panning
|
||||
//! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next
|
||||
//! wheel event: releasing Alt while the tail is still settling must not turn it into a pan.
|
||||
//! keeps the smoothed delta. The Alt state is taken from the wheel events (and the Alt key's own
|
||||
//! press and release, `wheel_events`) and kept until the next wheel event: releasing Alt while
|
||||
//! the tail is still settling must not turn it into a pan.
|
||||
|
||||
use egui::{Context, Event, Id, InputState, Modifiers, MouseWheelUnit, RawInput, Vec2};
|
||||
use egui::{Context, Event, Id, InputState, Key, Modifiers, MouseWheelUnit, RawInput, Vec2};
|
||||
|
||||
/// Zoom factor of one wheel notch: every step is exactly ×1.1.
|
||||
pub const NOTCH: f32 = 1.1;
|
||||
@@ -214,22 +215,9 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner:
|
||||
|
||||
// --- Modifier tracking (always runs for all callers) ---
|
||||
let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| {
|
||||
let latest_alt = i.events.iter().rev().find_map(|e| match e {
|
||||
Event::MouseWheel { modifiers, .. } => Some(modifiers.alt && !modifiers.command && !modifiers.ctrl),
|
||||
_ => None,
|
||||
});
|
||||
let latest_shift = i.events.iter().rev().find_map(|e| match e {
|
||||
Event::MouseWheel { modifiers, .. } => Some(modifiers.shift && !modifiers.command && !modifiers.ctrl),
|
||||
_ => None,
|
||||
});
|
||||
let wheel: Vec<(MouseWheelUnit, Vec2, Modifiers)> = i
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::MouseWheel { unit, delta, modifiers, .. } => Some((*unit, *delta, *modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let wheel = wheel_events(i);
|
||||
let latest_alt = wheel.last().map(|(_, _, m)| m.alt && !m.command && !m.ctrl);
|
||||
let latest_shift = wheel.last().map(|(_, _, m)| m.shift && !m.command && !m.ctrl);
|
||||
(i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height())
|
||||
});
|
||||
let alt = match latest_alt {
|
||||
@@ -334,6 +322,37 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner:
|
||||
}
|
||||
}
|
||||
|
||||
fn is_alt_key(key: Key) -> bool {
|
||||
matches!(key, Key::AltLeft | Key::AltRight)
|
||||
}
|
||||
|
||||
/// This frame's wheel events, in order, with ⌥/Alt added to their modifiers while an Alt key is
|
||||
/// down (#8049). The modifiers a wheel event carries are the windowing layer's modifier state,
|
||||
/// which on Windows can miss a held Alt (the wheel then scrolled until another modifier was
|
||||
/// tapped), while the Alt key's own press and release still arrive; the menu bar follows those
|
||||
/// too (`menu_keys`). Alt is down from its press until its release, starting from whether it
|
||||
/// was down before this frame's first Alt key event (egui's `keys_down` when there is none).
|
||||
fn wheel_events(i: &InputState) -> Vec<(MouseWheelUnit, Vec2, Modifiers)> {
|
||||
let first_alt_key = i.events.iter().find_map(|e| match e {
|
||||
// A repeat means the key was already down; a release means it was down until then.
|
||||
Event::Key { key, pressed, repeat, .. } if is_alt_key(*key) => Some(!*pressed || *repeat),
|
||||
_ => None,
|
||||
});
|
||||
let mut alt_key = first_alt_key.unwrap_or_else(|| i.key_down(Key::AltLeft) || i.key_down(Key::AltRight));
|
||||
let mut wheel = Vec::new();
|
||||
for e in &i.events {
|
||||
match e {
|
||||
Event::Key { key, pressed, .. } if is_alt_key(*key) => alt_key = *pressed,
|
||||
Event::MouseWheel { unit, delta, modifiers, .. } => {
|
||||
let modifiers = Modifiers { alt: modifiers.alt || alt_key, ..*modifiers };
|
||||
wheel.push((*unit, *delta, modifiers));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
wheel
|
||||
}
|
||||
|
||||
/// This frame's pinch zoom factor (1 = none). While two fingers are down on a touch screen, egui's
|
||||
/// `zoom_delta` reports their pinch in place of the trackpad's `Event::Zoom`. With Preferences ›
|
||||
/// Enhanced Controls › Enable Gestures off (`touch_gestures` false) the fingers are ignored and only
|
||||
|
||||
@@ -183,6 +183,9 @@ pub fn panel_footer<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
ui.painter().line_segment([pos2(r.left() - m.left as f32, y), pos2(r.right() + m.right as f32, y)], Stroke::new(1.0, t.separator));
|
||||
let bar = Rect::from_min_size(pos2(r.left(), r.top() + 1.0), vec2(r.width(), FOOTER_BAR));
|
||||
let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
// The bar runs on over the body's bottom margin, past where the body's own clip ends: without
|
||||
// this its buttons were cut off there, a 26 px hover highlight drawn about 21 px tall.
|
||||
bar_ui.set_clip_rect(bar_ui.clip_rect().union(bar));
|
||||
bar_ui.spacing_mut().item_spacing.x = 2.0;
|
||||
add(&mut bar_ui)
|
||||
}
|
||||
@@ -793,7 +796,9 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o
|
||||
ui.painter().rect_filled(arrow, t.radius_sm, t.field_border.gamma_multiply(0.6));
|
||||
}
|
||||
chevron_icon(ui, arrow, ui.style().interact(&resp), open);
|
||||
let parent_layer = resp.layer_id;
|
||||
popup.show(|ui| {
|
||||
crate::press_menu::above_parent_ui(parent_layer, ui);
|
||||
ui.set_width(POPUP_SLIDER_W);
|
||||
let s = slider(ui, value, range, None);
|
||||
s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), &name));
|
||||
@@ -1750,6 +1755,7 @@ pub fn swatch_popup(swatch: &Response) -> egui::Popup<'static> {
|
||||
ctx.data_mut(|d| d.remove::<f32>(key));
|
||||
return popup;
|
||||
}
|
||||
crate::press_menu::above_parent(ctx, swatch.layer_id, egui::LayerId::new(egui::Order::Foreground, popup.get_id()));
|
||||
// Its width is known from the frame after it opened (egui sizes it unseen first).
|
||||
let left = ctx.data(|d| d.get_temp::<f32>(key)).or_else(|| {
|
||||
let width = popup.get_expected_size()?.x;
|
||||
@@ -2155,6 +2161,32 @@ mod tests {
|
||||
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// A panel footer's buttons are drawn whole. The bar runs on over the body's bottom margin, so a
|
||||
/// body that clips where its content ends (as the panels do) cut the bottom off every button:
|
||||
/// the hover highlight of a 26 px button was about 21 px tall in the Paths, Layers and Channels
|
||||
/// panels (#6238).
|
||||
#[test]
|
||||
fn panel_footer_buttons_are_not_clipped_by_the_body() {
|
||||
let mut h = Harness::new_ui_state(
|
||||
|ui, s: &mut Option<(egui::Rect, egui::Rect, egui::Rect)>| {
|
||||
// A body exactly as tall as the footer takes, clipped there.
|
||||
let body = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(220.0, super::footer_height(ui)));
|
||||
let mut body_ui = ui.new_child(egui::UiBuilder::new().max_rect(body));
|
||||
body_ui.set_clip_rect(body);
|
||||
super::panel_footer(&mut body_ui, |bar| {
|
||||
let b = crate::icons::button(bar, "square", 26.0, false, "Button");
|
||||
*s = Some((b.rect, bar.clip_rect(), body));
|
||||
});
|
||||
},
|
||||
None,
|
||||
);
|
||||
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run();
|
||||
let (button, clip, body) = h.state().expect("the footer drew its button");
|
||||
assert!(button.bottom() > body.bottom(), "the bar really overruns the body, or this test proves nothing: {button:?} vs {body:?}");
|
||||
assert!(clip.contains_rect(button), "the whole button is inside the clip: {button:?} not in {clip:?}");
|
||||
}
|
||||
|
||||
/// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays
|
||||
/// local, so it isn't recoloured for the rest of the frame (selected rows, slider trails).
|
||||
#[test]
|
||||
|
||||
@@ -45,13 +45,45 @@ const IDS: [&str; 7] = [
|
||||
"view.show.showExtrasOptions",
|
||||
];
|
||||
|
||||
/// Window › Workspace items of saved workspaces: `window.workspace.saved.<index>`, an index into
|
||||
/// [`saved_names`].
|
||||
pub const SAVED: &str = "window.workspace.saved.";
|
||||
|
||||
/// Menu ids handled here (live menu items).
|
||||
pub fn handles(id: &str) -> bool {
|
||||
IDS.contains(&id)
|
||||
IDS.contains(&id) || id.starts_with(SAVED)
|
||||
}
|
||||
|
||||
/// Saved workspace names, in menu order.
|
||||
pub fn saved_names(app: &PhotocraftApp) -> Vec<String> {
|
||||
app.session.prefs().workspaces.keys().cloned().collect()
|
||||
}
|
||||
|
||||
fn saved_name(app: &PhotocraftApp, id: &str) -> Option<String> {
|
||||
let i = id.strip_prefix(SAVED)?.parse::<usize>().ok()?;
|
||||
app.session.prefs().workspaces.keys().nth(i).cloned()
|
||||
}
|
||||
|
||||
/// The top-bar workspace switcher's options as (key, label): the presets, then saved workspaces.
|
||||
pub fn switcher_options(app: &PhotocraftApp) -> Vec<(String, String)> {
|
||||
// Keys stay English (`apply_workspace` matches them); the dropdown shows the preset labels
|
||||
// in the UI language.
|
||||
let presets = [
|
||||
("Essentials", tl!("Essentials")),
|
||||
("Photography", tl!("Photography")),
|
||||
("Painting", tl!("Painting")),
|
||||
("Graphic and Web", tl!("Graphic and Web")),
|
||||
("Pixel Art", tl!("Pixel Art")),
|
||||
("Motion", tl!("Motion")),
|
||||
];
|
||||
let mut opts: Vec<(String, String)> = presets.iter().map(|(k, l)| ((*k).to_string(), (*l).to_string())).collect();
|
||||
opts.extend(saved_names(app).into_iter().map(|n| (n.clone(), n)));
|
||||
opts
|
||||
}
|
||||
|
||||
pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||||
match id {
|
||||
i if i.starts_with(SAVED) => Some(saved_name(app, i).is_some_and(|n| n == app.ui.workspace)),
|
||||
"window.panel.modifierKeys" => Some(app.ui.shell.modifier_keys),
|
||||
"window.workspace.lockWorkspace" => Some(app.session.prefs().workspace_locked),
|
||||
"view.pixelAspectRatio.custom" => Some(app.ui.view.pixel_aspect.starts_with("custom:")),
|
||||
@@ -140,6 +172,10 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<
|
||||
}
|
||||
"window.workspace.deleteWorkspace" => delete_workspace(app, params),
|
||||
"window.workspace.select" => select_workspace(app, params),
|
||||
i if i.starts_with(SAVED) => match saved_name(app, i) {
|
||||
Some(name) => select_workspace(app, &json!({"name": name})),
|
||||
None => Err(format!("no saved workspace for `{i}`")),
|
||||
},
|
||||
"view.pixelAspectRatio.custom" if no_params(params) => open(app, "customPar", json!({"name": "", "ratio": 1.0})),
|
||||
"view.pixelAspectRatio.custom" => custom_par(app, params),
|
||||
"view.show.showExtrasOptions" if no_params(params) => {
|
||||
@@ -609,6 +645,40 @@ mod tests {
|
||||
assert!(s2.prefs().workspaces.contains_key("Mine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_workspaces_are_listed_checked_and_selectable() {
|
||||
let (mut app, ctx) = app();
|
||||
let ws_items =
|
||||
|app: &PhotocraftApp| crate::menus::menu_items(app).into_iter().filter(|i| i.path == ["Window", "Workspace"] && i.id != "---").collect::<Vec<_>>();
|
||||
assert!(!ws_items(&app).iter().any(|i| i.id.starts_with(SAVED)));
|
||||
app.ui.panels.history = true;
|
||||
crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Retouch"})).unwrap();
|
||||
crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Comp"})).unwrap();
|
||||
// Listed right after the presets, in name order; only the current one is checked.
|
||||
let items = ws_items(&app);
|
||||
let motion = items.iter().position(|i| i.id == "window.workspace.motion").unwrap();
|
||||
let saved: Vec<(&str, Option<bool>, bool)> = items[motion + 1..].iter().take(2).map(|i| (i.label.as_str(), i.checked, i.enabled)).collect();
|
||||
assert_eq!(saved, [("Comp", Some(true), true), ("Retouch", Some(false), true)]);
|
||||
assert!(items.iter().filter(|i| !i.id.starts_with(SAVED)).all(|i| i.checked != Some(true) || i.id == "window.workspace.lockWorkspace"));
|
||||
assert!(crate::menus::is_live(&format!("{SAVED}1")));
|
||||
// Switch to a preset, then back through the menu item.
|
||||
crate::menus::invoke(&mut app, &ctx, "window.workspace.painting", json!({})).unwrap();
|
||||
assert!(!app.ui.panels.history);
|
||||
let retouch = ws_items(&app).into_iter().find(|i| i.label == "Retouch").unwrap();
|
||||
crate::menus::invoke(&mut app, &ctx, &retouch.id, json!({})).unwrap();
|
||||
assert_eq!(app.ui.workspace, "Retouch");
|
||||
assert!(app.ui.panels.history);
|
||||
assert_eq!(checked(&app, &retouch.id), Some(true));
|
||||
// Stale or malformed ids fail instead of panicking.
|
||||
for bad in ["9", "-1", "x", ""] {
|
||||
assert!(crate::menus::invoke(&mut app, &ctx, &format!("{SAVED}{bad}"), json!({})).is_err(), "{bad}");
|
||||
}
|
||||
// The top-bar switcher lists them after the presets.
|
||||
let keys: Vec<String> = switcher_options(&app).into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(keys[6..], ["Comp", "Retouch"]);
|
||||
assert!(keys.contains(&app.ui.workspace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_workspace_toggles_and_persists() {
|
||||
let (mut app, ctx) = app();
|
||||
|
||||
@@ -124,6 +124,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
|
||||
| `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback |
|
||||
| `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path |
|
||||
| `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) |
|
||||
| `PHOTOCRAFT_TEXEL_GUARD=1` (or `=0`) | Force on (off) uploading egui's fully transparent texels as alpha 1/255. On by default only for Intel Iris Xe on DX12, whose driver shows stale memory in all-zero texture blocks: red squares in brush previews and tool icons (`ui-egui/src/texel_guard.rs`; check a GPU with `cargo run --release -p photocraft-gpu --example zero_block_probe -- dx12 400`) |
|
||||
| `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the CPU layer-effect map cache (`compose::effect_maps`) |
|
||||
| `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) |
|
||||
| `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
||||
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
||||
@@ -204,7 +204,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|
||||
| Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | |
|
||||
| Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | |
|
||||
| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | |
|
||||
| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | |
|
||||
| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) |
|
||||
|
||||
## Panels and workspace
|
||||
|
||||
@@ -245,6 +245,7 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) |
|
||||
| 2026-10-16 | minor | Shape Fill/Stroke compact pickers accept six-digit hex RGB, including pasted values with `#`, retaining alpha and high-depth paint until an edit; typed/pasted colour and history regressions (#6176). Scores unchanged. |
|
||||
| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) |
|
||||
| 2026-10-15 | minor | A guide let go over either ruler is removed, not only off the canvas: the top ruler measures document x and the left one y, so the ruler a guide was not dragged out of can lie over a coordinate inside the document (#6286) |
|
||||
| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) |
|
||||
| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |
|
||||
| 2026-10-14 | minor | Character and Paragraph panels edit persistent defaults without a type target (#5556); no percentage or estimate changed |
|
||||
|
||||
Reference in New Issue
Block a user