mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Merge storytold/main into AF.3 native Affinity import
Conflicts: ROADMAP (main's guides and file-format rows, with the Affinity items kept); fileio/load.rs (main's physical-size raster documents, kept visible to the Affinity preview fallback). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
This commit is contained in:
+8
-8
File diff suppressed because one or more lines are too long
@@ -37,7 +37,7 @@ const HELP: &str =
|
||||
/// Frames the UI ran before the failure, below which it was still starting up. The failure in
|
||||
/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
|
||||
/// window is something else (such as the compositor restarting).
|
||||
const STARTUP_FRAMES: u64 = 10;
|
||||
pub const STARTUP_FRAMES: u64 = 10;
|
||||
|
||||
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
|
||||
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
|
||||
|
||||
@@ -43,10 +43,13 @@ struct App {
|
||||
graphics_loss: GraphicsLoss,
|
||||
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
|
||||
graphics_lost: bool,
|
||||
/// Frames the UI has run (see [`end_before_teardown`]).
|
||||
frames: u64,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.frames = ctx.cumulative_frame_nr();
|
||||
if let Some(why) = self.graphics_loss.take() {
|
||||
// eframe can't give a window a new device: the user saves and starts again.
|
||||
self.graphics_lost = self.app.graphics_lost(&why);
|
||||
@@ -87,6 +90,7 @@ impl eframe::App for App {
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.app);
|
||||
end_before_teardown(self.frames);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
@@ -94,6 +98,22 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
|
||||
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
|
||||
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
|
||||
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
|
||||
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
|
||||
/// failure then starts the app again on another adapter (#651).
|
||||
#[allow(unused_variables)]
|
||||
fn end_before_teardown(frames: u64) {
|
||||
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
|
||||
if frames >= gpu::STARTUP_FRAMES {
|
||||
log::info!("quitting");
|
||||
log::logger().flush();
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
|
||||
/// can't be opened is reported in the status bar and on stderr; the others still open.
|
||||
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
@@ -495,7 +515,7 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
open_files(&mut app, files);
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false }))
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
|
||||
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use moxcms::{
|
||||
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
|
||||
@@ -27,6 +27,9 @@ fn intent_index(i: Intent) -> usize {
|
||||
|
||||
type Xf = Option<Arc<TransformF32Executor>>;
|
||||
|
||||
/// A direct transform's cache key: destination name, intent index, black-point compensation.
|
||||
type DirectKey = (String, usize, bool);
|
||||
|
||||
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
|
||||
pub struct IccProfile {
|
||||
pub name: String,
|
||||
@@ -37,6 +40,21 @@ pub struct IccProfile {
|
||||
srgb: ColorProfile,
|
||||
to_srgb: [OnceLock<Xf>; 4],
|
||||
from_srgb: [OnceLock<Xf>; 4],
|
||||
/// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent
|
||||
/// and black-point compensation.
|
||||
direct: Mutex<Vec<(DirectKey, Option<Arc<Direct>>)>>,
|
||||
}
|
||||
|
||||
/// A transform from one profile into an RGB profile without passing through sRGB: into the
|
||||
/// destination's primaries with linear tone curves, where black-point compensation is a linear
|
||||
/// remap, then onto its own curves.
|
||||
struct Direct {
|
||||
to_linear: Arc<TransformF32Executor>,
|
||||
/// Linear → the destination's curves; `None` when the destination isn't a matrix profile
|
||||
/// (the first transform goes straight into it, without compensation).
|
||||
to_dest: Option<Arc<TransformF32Executor>>,
|
||||
/// The source black's luminance in the linear destination (0: no compensation).
|
||||
black: f32,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IccProfile {
|
||||
@@ -72,6 +90,7 @@ impl IccProfile {
|
||||
srgb: ColorProfile::new_srgb(),
|
||||
to_srgb: Default::default(),
|
||||
from_srgb: Default::default(),
|
||||
direct: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +154,72 @@ impl IccProfile {
|
||||
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
|
||||
}
|
||||
|
||||
/// Device values (RGB / CMYK / Gray, 0..1) → `dest`'s RGB, converted directly with `intent`:
|
||||
/// not through sRGB, so colours outside sRGB keep their place in a wider space. With `bpc`
|
||||
/// (and an intent other than absolute), this profile's black maps onto the destination's
|
||||
/// black (black-point compensation, the darkest ink the profile prints becoming RGB 0 rather
|
||||
/// than a dark grey). `None` when `dest` isn't RGB or no transform can be built.
|
||||
pub fn to_rgb_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option<[f32; 3]> {
|
||||
let t = self.direct_xf(dest, intent, bpc && intent != Intent::AbsoluteColorimetric)?;
|
||||
let mut src = v.to_vec();
|
||||
src.resize(self.channels(), 0.0);
|
||||
let mut lin = [0.0f32; 3];
|
||||
t.to_linear.transform(&src, &mut lin).ok()?;
|
||||
let Some(to_dest) = &t.to_dest else { return Some(lin.map(|x| x.clamp(0.0, 1.0))) };
|
||||
if t.black > 0.0 {
|
||||
lin = lin.map(|x| (x - t.black) / (1.0 - t.black));
|
||||
}
|
||||
let lin = lin.map(|x| x.clamp(0.0, 1.0));
|
||||
let mut out = [0.0f32; 3];
|
||||
to_dest.transform(&lin, &mut out).ok()?;
|
||||
Some(out.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
fn direct_xf(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Arc<Direct>> {
|
||||
if dest.kind != ProfileKind::Rgb {
|
||||
return None;
|
||||
}
|
||||
let key = (dest.name.clone(), intent_index(intent), bpc);
|
||||
let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return t.clone();
|
||||
}
|
||||
let t = self.build_direct(dest, intent, bpc).map(Arc::new);
|
||||
cache.push((key, t.clone()));
|
||||
t
|
||||
}
|
||||
|
||||
fn build_direct(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Direct> {
|
||||
let d = &dest.profile;
|
||||
let matrix = d.red_trc.is_some() && d.green_trc.is_some() && d.blue_trc.is_some();
|
||||
if !matrix {
|
||||
let xf = self.profile.create_transform_f32(self.layout(), d, Layout::Rgb, Self::opts(intent)).ok()?;
|
||||
return Some(Direct { to_linear: xf, to_dest: None, black: 0.0 });
|
||||
}
|
||||
let mut linear = d.clone();
|
||||
for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] {
|
||||
*trc = Some(ToneReprCurve::Parametric(vec![1.0]));
|
||||
}
|
||||
// A coding-independent code point names the transfer curve outright (moxcms's sRGB has
|
||||
// one) and would override the linear curves.
|
||||
linear.cicp = None;
|
||||
let to_linear = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?;
|
||||
let to_dest = linear.create_transform_f32(Layout::Rgb, d, Layout::Rgb, Self::opts(Intent::RelativeColorimetric)).ok()?;
|
||||
// The source black: the darkest colour the profile reproduces, found as lcms does for an
|
||||
// output profile (black sent into the profile and read back), made neutral.
|
||||
let black = if bpc && self.kind == ProfileKind::Cmyk {
|
||||
let inks = self.from_srgb([0.0; 3], Intent::RelativeColorimetric)?;
|
||||
let mut lin = [0.0f32; 3];
|
||||
to_linear.transform(&inks, &mut lin).ok()?;
|
||||
let y = |c: &moxcms::Xyzd| c.y as f32;
|
||||
let lum = lin[0] * y(&d.red_colorant) + lin[1] * y(&d.green_colorant) + lin[2] * y(&d.blue_colorant);
|
||||
lum.clamp(0.0, 0.5)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Some(Direct { to_linear, to_dest: Some(to_dest), black })
|
||||
}
|
||||
|
||||
/// Whether transforms can be built for this profile (checked at registration).
|
||||
pub fn usable(&self) -> bool {
|
||||
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
|
||||
|
||||
@@ -356,10 +356,37 @@ impl Cms {
|
||||
}
|
||||
}
|
||||
|
||||
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
|
||||
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour). An ICC
|
||||
/// CMYK profile converts as [`Self::cmyk_to_rgb`] does into sRGB (with the settings'
|
||||
/// black-point compensation), so what shows is what an RGB export writes.
|
||||
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
if let (false, CmykSpace::Icc(p)) = (absolute, &self.cmyk)
|
||||
&& let Some(srgb) = builtin_rgb_cache(SRGB)
|
||||
&& let Some(v) = p.to_rgb_of(&srgb, &cmyk.map(|v| v.clamp(0.0, 1.0)), Intent::RelativeColorimetric, self.settings.bpc)
|
||||
{
|
||||
return v;
|
||||
}
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
}
|
||||
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
|
||||
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
|
||||
/// RGB space holds (Wide Gamut RGB keeps CMYK cyan that sRGB can't show), and with the
|
||||
/// settings' black-point compensation. The built-in CMYK spaces go through sRGB as before
|
||||
/// (Generic CMYK compensates in its own model).
|
||||
pub fn cmyk_to_rgb(&self, cmyk: [f32; 4], intent: Intent) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
let src = match &self.cmyk {
|
||||
CmykSpace::Icc(p) => Some(p.clone()),
|
||||
CmykSpace::Generic(_) | CmykSpace::Device => None,
|
||||
};
|
||||
let dest = match &self.rgb {
|
||||
RgbSpace::Icc(p) => Some(p.clone()),
|
||||
RgbSpace::Srgb => builtin_rgb_cache(SRGB),
|
||||
};
|
||||
src.zip(dest)
|
||||
.and_then(|(s, d)| s.to_rgb_of(&d, &cmyk, intent, self.settings.bpc))
|
||||
.unwrap_or_else(|| self.srgb_to_rgb(self.cmyk_to_srgb(cmyk, false)))
|
||||
}
|
||||
/// Display sRGB → working CMYK with `intent`.
|
||||
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
|
||||
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
|
||||
@@ -424,6 +451,10 @@ impl Cms {
|
||||
let [c, m, y, k] = self.to_cmyk(c, intent);
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
(Color::Cmyk { c, m, y, k }, Model::Rgb) => {
|
||||
let [r, g, b] = self.cmyk_to_rgb([*c, *m, *y, *k], intent);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
(_, Model::Rgb) => {
|
||||
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
|
||||
Color::Rgb { r, g, b }
|
||||
|
||||
@@ -60,3 +60,54 @@ fn a_loaded_profile_is_embedded_as_loaded() {
|
||||
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
|
||||
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
|
||||
}
|
||||
|
||||
/// Settings with an ICC CMYK profile (Generic CMYK's, loaded as a file is) and Wide Gamut RGB.
|
||||
fn icc_cmyk(bpc: bool) -> Cms {
|
||||
let name = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Test ICC CMYK".into())).unwrap().name;
|
||||
Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: name, bpc, ..ColorSettings::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icc_cmyk_converts_into_a_wide_rgb_space_directly_without_clipping_to_srgb() {
|
||||
let wide = icc_cmyk(true);
|
||||
let cyan = [1.0, 0.0, 0.0, 0.0];
|
||||
let direct = wide.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
|
||||
// Through sRGB, cyan clips to sRGB's gamut first: in the wider space that reads as a red
|
||||
// channel well above zero.
|
||||
let hub = wide.srgb_to_rgb(wide.cmyk_to_srgb(cyan, false));
|
||||
assert!(direct[0] + 0.15 < hub[0], "direct {direct:?}, through sRGB {hub:?}");
|
||||
// Same colour either way where sRGB holds it: a mid grey and paper white.
|
||||
for inks in [[0.0, 0.0, 0.0, 0.5], [0.0; 4]] {
|
||||
let (d, h) = (wide.cmyk_to_rgb(inks, Intent::RelativeColorimetric), wide.srgb_to_rgb(wide.cmyk_to_srgb(inks, false)));
|
||||
assert!(d.iter().zip(h).all(|(a, b)| (a - b).abs() < 0.02), "{inks:?}: direct {d:?}, through sRGB {h:?}");
|
||||
}
|
||||
// Cms::convert takes the direct route for CMYK.
|
||||
let Color::Rgb { r, .. } = wide.convert(&Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 }, Model::Rgb, Intent::RelativeColorimetric) else {
|
||||
panic!("RGB")
|
||||
};
|
||||
assert!((r - direct[0]).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
|
||||
let rich = [0.75, 0.68, 0.67, 0.9];
|
||||
let (on, off) = (icc_cmyk(true).cmyk_to_rgb(rich, Intent::RelativeColorimetric), icc_cmyk(false).cmyk_to_rgb(rich, Intent::RelativeColorimetric));
|
||||
// Without compensation the profile's black is a dark grey; with it, rich black goes most of
|
||||
// the way to RGB black (not all: it isn't quite the darkest ink the profile prints).
|
||||
assert!(off.iter().all(|v| *v > 0.1), "without: {off:?}");
|
||||
assert!(on.iter().zip(off).all(|(a, b)| *a < 0.08 && *a < b * 0.5), "with: {on:?}, without: {off:?}");
|
||||
// The screen shows what an sRGB conversion writes, compensated alike.
|
||||
let srgb = |bpc| {
|
||||
let c = icc_cmyk(bpc);
|
||||
Cms::new(&ColorSettings { rgb: SRGB.into(), ..c.settings().clone() }).unwrap()
|
||||
};
|
||||
for bpc in [true, false] {
|
||||
let c = srgb(bpc);
|
||||
assert_eq!(c.cmyk_to_srgb(rich, false), c.cmyk_to_rgb(rich, Intent::RelativeColorimetric), "bpc {bpc}");
|
||||
}
|
||||
// White is untouched, and absolute colorimetric never compensates.
|
||||
let white = icc_cmyk(true).cmyk_to_rgb([0.0; 4], Intent::RelativeColorimetric);
|
||||
assert!(white.iter().all(|v| *v > 0.99), "{white:?}");
|
||||
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
|
||||
assert_eq!(abs(true), abs(false));
|
||||
}
|
||||
|
||||
@@ -136,10 +136,65 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
|
||||
Inks::new(w, h, data)
|
||||
}
|
||||
|
||||
/// A CMYK TIFF with an alpha channel (5 samples) as RGBA pixels, each colour converted by `rgb`
|
||||
/// (ink amounts 0..1 → sRGB 0..1) and associated (premultiplied) alpha divided back out; `None`
|
||||
/// for other images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. The image decoder
|
||||
/// doesn't read these at all; the inks can't be kept with the transparency, so they show as RGB.
|
||||
pub fn cmyka_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
|
||||
if !matches!(kind, tiff::ColorType::CMYKA(8 | 16)) || planar {
|
||||
return None;
|
||||
}
|
||||
let n = usize::try_from(pixels(w, h)?).ok()?;
|
||||
// ExtraSamples 1: associated alpha (premultiplied colour); 2 (or none stated): unassociated.
|
||||
let associated = d.find_tag_unsigned_vec::<u16>(tiff::tags::Tag::ExtraSamples).ok().flatten().is_some_and(|v| v.first() == Some(&1));
|
||||
let data: Vec<u8> = match d.read_image().ok()? {
|
||||
tiff::decoder::DecodingResult::U8(v) => v,
|
||||
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if data.len() != n * 5 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for p in data.as_chunks::<5>().0 {
|
||||
let a = f32::from(p[4]) / 255.0;
|
||||
let ink = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if associated && a > 0.0 { (v / a).min(1.0) } else { v }
|
||||
};
|
||||
let [r, g, b] = rgb([ink(p[0]), ink(p[1]), ink(p[2]), ink(p[3])]);
|
||||
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
out.push(p[4]);
|
||||
}
|
||||
image::RgbaImage::from_raw(w, h, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_tiff_with_alpha_reads_as_rgba() {
|
||||
use tiff::encoder::{TiffEncoder, colortype::CMYKA8};
|
||||
// 2 × 1: full cyan opaque, then 100% K at half alpha (premultiplied: K 128, alpha 128).
|
||||
let mut file = Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut file).unwrap();
|
||||
let mut img = enc.new_image::<CMYKA8>(2, 1).unwrap();
|
||||
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
|
||||
img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap();
|
||||
let bytes = file.into_inner();
|
||||
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
|
||||
let px = cmyka_tiff_rgba(&bytes, naive).expect("CMYKA reads");
|
||||
assert_eq!(px.dimensions(), (2, 1));
|
||||
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque");
|
||||
assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha");
|
||||
// Not CMYKA: not this reader's.
|
||||
assert!(cmyka_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
|
||||
}
|
||||
|
||||
fn inks() -> Inks {
|
||||
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
|
||||
}
|
||||
|
||||
@@ -300,10 +300,19 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
open_template(s, src.name, &src.bytes, src.path)
|
||||
}
|
||||
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]).
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
/// the decoder can't read, become RGBA in the active colour settings' CMYK.
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let ppi = super::ppi::resolution(bytes);
|
||||
@@ -311,7 +320,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
|
||||
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
|
||||
} else {
|
||||
let img = reader.decode().map_err(err)?.to_rgba8();
|
||||
let cmyka = || {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
|
||||
};
|
||||
let img = match (f.id == "tiff").then(cmyka).flatten() {
|
||||
Some(img) => img,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
};
|
||||
let size = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
|
||||
@@ -329,16 +345,16 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi })
|
||||
}
|
||||
|
||||
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
|
||||
/// An image as a document of its physical size at the resolution it declares (as Place sizes it;
|
||||
/// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file.
|
||||
pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let RasterImage { key, blob, width, height, .. } = raster_image(bytes)?;
|
||||
let mut d = Document::new(width as f64, height as f64);
|
||||
let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?;
|
||||
let (sx, sy) = crate::cmd::place::pt_per_px(ppi);
|
||||
let mut d = Document::new(width as f64 * sx, height as f64 * sy);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
|
||||
);
|
||||
let xf = Affine::scale_non_uniform(sx, sy);
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }));
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
||||
@@ -147,3 +147,19 @@ fn the_document_background_is_the_default_export_background() {
|
||||
assert_eq!(png(&mut s, json!({"format": "png", "background": "transparent"})).get_pixel(0, 0)[3], 0, "the option wins");
|
||||
assert_eq!(png(&mut s, json!({"format": "webp", "background": "black"})).get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
let mut s = session(72.0, 36.0, 1);
|
||||
let hi = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap());
|
||||
let lo = b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap());
|
||||
let lo = super::ppi::with_png_resolution(&lo, (72.0, 72.0));
|
||||
for (png, w, h, what) in [(hi, 72.0, 36.0, "300 ppi: 300 x 150 px is 1 x 0.5 in"), (lo, 72.0, 36.0, "72 ppi: 1 px = 1 pt")] {
|
||||
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let ab = doc.artboards[0].rect;
|
||||
assert!((ab.width() - w).abs() < 1e-6 && (ab.height() - h).abs() < 1e-6, "{what}: artboard {ab:?}");
|
||||
let art = doc.art_bounds().expect("the image");
|
||||
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
@@ -283,6 +283,21 @@ fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Type › Options › Additional Fonts Folder (#683) adds its folder to the system font scan,
|
||||
/// trimmed; clearing it takes the folder out again.
|
||||
#[test]
|
||||
fn the_additional_fonts_folder_joins_the_font_scan() {
|
||||
let mut s = Session::new();
|
||||
let dir = std::env::temp_dir().join("vectorcraft-fonts-folder-pref");
|
||||
s.execute("prefs.set", &json!({"key": "fontsFolder", "value": format!(" {} ", dir.display())})).unwrap();
|
||||
assert_eq!(vectorcraft_text::user_font_dirs(), std::slice::from_ref(&dir));
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
assert!(vectorcraft_text::system_font_dirs().contains(&dir));
|
||||
}
|
||||
s.execute("prefs.set", &json!({"key": "fontsFolder", "value": ""})).unwrap();
|
||||
assert!(vectorcraft_text::user_font_dirs().is_empty() && !vectorcraft_text::system_font_dirs().contains(&dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_flag_missing_replace_and_select() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Crop Image",
|
||||
["Object"],
|
||||
None,
|
||||
"{rect?: [x, y, width, height]} crop the selected image (default: to the artboard it sits on) → {id, width, height}",
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
|
||||
has_image,
|
||||
crop_image
|
||||
),
|
||||
@@ -541,6 +541,9 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(C, "select an image"))?;
|
||||
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||||
if p.get("trim").and_then(Value::as_bool) == Some(true) {
|
||||
return trim_image(s, id, im.clone());
|
||||
}
|
||||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
@@ -594,6 +597,49 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
}
|
||||
|
||||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||||
/// pixels copied as they are (no resampling) and placed where they were.
|
||||
fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
if p[3] > 0 {
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
}
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Err(bad(C, "the image is fully transparent"));
|
||||
}
|
||||
if (x0, y0, x1, y1) == (0, 0, w, h) {
|
||||
// Already trimmed: not an error, so a script can trim every image it opens.
|
||||
return Ok(json!({ "id": id.0, "width": w, "height": h, "trimmed": false }));
|
||||
}
|
||||
let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image();
|
||||
let mut png = Vec::new();
|
||||
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (cw, ch) = (x1 - x0, y1 - y0);
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Image(ImageObject {
|
||||
key,
|
||||
width: cw,
|
||||
height: ch,
|
||||
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
|
||||
}
|
||||
|
||||
/// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
|
||||
/// outline, with that path selected so dragging its handles crops the image.
|
||||
fn mask_image(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -187,6 +187,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
|
||||
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
|
||||
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
|
||||
p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text),
|
||||
// Units
|
||||
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
|
||||
p!("unitsStroke", "Units", "", "Stroke", choice(UNITS)),
|
||||
@@ -460,7 +461,18 @@ impl Session {
|
||||
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
|
||||
let history_changed = p.history_states != self.prefs.history_states;
|
||||
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
|
||||
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
|
||||
self.prefs = p;
|
||||
if fonts_folder_changed {
|
||||
let folder = self.prefs.fonts_folder.trim();
|
||||
vectorcraft_text::set_user_font_dirs(if folder.is_empty() { vec![] } else { vec![folder.into()] });
|
||||
// Once the fonts were scanned, scan again now: the folder's fonts appear (or go) in the
|
||||
// font menus, and type in them lays out again. The first scan reads it anyway.
|
||||
if vectorcraft_text::FontDb::global().installed_fonts_changed() {
|
||||
// Its result only counts the faces cataloged.
|
||||
let _ = super::fonts::rescan(self, &serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
|
||||
for st in &mut self.docs {
|
||||
if history_changed {
|
||||
|
||||
@@ -480,6 +480,9 @@ pub struct Prefs {
|
||||
// Hyphenation
|
||||
pub hyphenation_language: String,
|
||||
pub hyphenation_exceptions: String,
|
||||
/// Type › Options › Additional Fonts Folder: a folder (read with its subfolders) whose fonts
|
||||
/// are listed and used as if installed (#683); empty for none.
|
||||
pub fonts_folder: String,
|
||||
// Performance & Storage (Plug-ins & Scratch Disks)
|
||||
pub plugins_folder: String,
|
||||
pub scratch_primary: String,
|
||||
@@ -696,6 +699,7 @@ impl Default for Prefs {
|
||||
slice_line_color: s("#ff3f3f"),
|
||||
hyphenation_language: s("English: USA"),
|
||||
hyphenation_exceptions: String::new(),
|
||||
fonts_folder: String::new(),
|
||||
plugins_folder: String::new(),
|
||||
scratch_primary: s("Startup"),
|
||||
scratch_secondary: s("None"),
|
||||
|
||||
@@ -1159,3 +1159,35 @@ fn saved_selection_names_are_trimmed_and_capped() {
|
||||
let list = s.execute("select.savedList", &json!({})).unwrap();
|
||||
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||||
// 100 × 80 px, transparent but for a block at (20, 10)–(50, 40) px; placed at 2 pt a pixel.
|
||||
let mut px = image::RgbaImage::new(100, 80);
|
||||
for y in 10..40 {
|
||||
for x in 20..50 {
|
||||
px.put_pixel(x, y, image::Rgba([x as u8, y as u8, 7, if x == 20 { 1 } else { 255 }]));
|
||||
}
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let img = selected(&s)[0];
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap();
|
||||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||||
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 30), "{r}");
|
||||
// Placed where those pixels were: (40, 20) pt, 60 × 60 pt at 2 pt a pixel.
|
||||
let b = bounds(&s, img);
|
||||
assert!(close(b.x0, 40.0) && close(b.y0, 20.0) && close(b.width(), 60.0) && close(b.height(), 60.0), "{b:?}");
|
||||
// Pixel for pixel, a barely visible (alpha 1) edge included.
|
||||
let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") };
|
||||
let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(out, image::imageops::crop_imm(&px, 20, 10, 30, 30).to_image());
|
||||
assert_eq!(r["trimmed"], true);
|
||||
// Nothing transparent left to cut: left as it is, and not an error (scripts trim every image).
|
||||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
|
||||
assert_eq!(bounds(&s, img), b);
|
||||
}
|
||||
|
||||
@@ -282,6 +282,33 @@ fn snap_to_point_lands_a_dragged_selection_on_anchors() {
|
||||
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
|
||||
}
|
||||
|
||||
/// Smart Guides › Spacing Guides (#394): a square dragged 21 pt to the right of a row of two
|
||||
/// squares 20 pt apart lands 20 pt from it, with the gaps marked; off, it stays at 21.
|
||||
#[test]
|
||||
fn spacing_guides_space_a_dragged_square_like_its_row() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let mut s = new_doc();
|
||||
square(&mut s, 20.0, 300.0);
|
||||
square(&mut s, 90.0, 300.0);
|
||||
let m = square(&mut s, 200.0, 305.0);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
// `m` grabbed by its centre and dragged so its left edge is 21 pt from the second square.
|
||||
let drag = |s: &mut Session| {
|
||||
gesture(s, v, &[(Down, 225.0, 330.0), (Drag, 200.0, 330.0), (Drag, 186.0, 330.0)]);
|
||||
let marks = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { a, b, .. } if a.y == b.y)).count();
|
||||
gesture(s, v, &[(Up, 186.0, 330.0)]);
|
||||
let at = top_left(s, m).0;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
(at, marks)
|
||||
};
|
||||
let (at, marks) = drag(&mut s);
|
||||
assert_eq!(at, 160.0, "20 pt from it");
|
||||
assert!(marks >= 2, "both gaps marked");
|
||||
set_pref(&mut s, "spacingGuides", json!(false));
|
||||
assert_eq!(drag(&mut s), (161.0, 0), "off: where it was dragged");
|
||||
}
|
||||
|
||||
/// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement
|
||||
/// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a
|
||||
/// square is dragged into line with another, and how far the pull reaches; a hidden guide still
|
||||
|
||||
@@ -223,6 +223,7 @@ impl Session {
|
||||
anchor_path_labels: self.prefs.anchor_path_labels,
|
||||
measurement_labels: self.prefs.measurement_labels,
|
||||
transform_tools_guides: self.prefs.transform_tools_guides,
|
||||
spacing_guides: self.prefs.spacing_guides,
|
||||
snapping_tolerance: self.prefs.snapping_tolerance,
|
||||
construction_angles: if self.prefs.construction_guides {
|
||||
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)
|
||||
|
||||
@@ -592,6 +592,8 @@ struct Builder<'p> {
|
||||
image_keys: HashMap<u128, (String, u32, u32)>,
|
||||
/// The images kept with their CMYK samples ([`crate::import_image`]).
|
||||
cmyk_keys: HashSet<u128>,
|
||||
/// Mask keys of CMYK images whose mask turned out to hide nothing.
|
||||
opaque: HashSet<u128>,
|
||||
warnings: Vec<String>,
|
||||
/// Fonts by cache key → base font name (from [`scan_page`]).
|
||||
fonts: HashMap<u128, String>,
|
||||
@@ -743,6 +745,7 @@ impl<'p> Builder<'p> {
|
||||
images: HashMap::new(),
|
||||
image_keys: HashMap::new(),
|
||||
cmyk_keys: HashSet::new(),
|
||||
opaque: HashSet::new(),
|
||||
warnings: vec![],
|
||||
fonts: HashMap::new(),
|
||||
font_names: HashMap::new(),
|
||||
@@ -1146,6 +1149,40 @@ impl<'p> Builder<'p> {
|
||||
}
|
||||
|
||||
fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) {
|
||||
self.add_image_masked(key, make, xf, None);
|
||||
}
|
||||
|
||||
/// An image's alpha (its soft mask, stencil mask or colour key, at its own resolution) as a
|
||||
/// greyscale image over the image's `w` × `h` pixels (`transform`): its opacity mask. `None`
|
||||
/// when it masks nothing.
|
||||
fn alpha_mask(&mut self, key: u128, r: &hayro_interpret::RasterImage<'_>, w: u32, h: u32, transform: Affine) -> Option<OpacityMask> {
|
||||
let mkey = key ^ 0x5_3a5c;
|
||||
if self.opaque.contains(&mkey) {
|
||||
return None;
|
||||
}
|
||||
let (k, mw, mh) = if let Some(v) = self.image_keys.get(&mkey).cloned() {
|
||||
v
|
||||
} else {
|
||||
let mut alpha = None;
|
||||
r.with_rgba(|_, a| alpha = a, None);
|
||||
let Some(a) = alpha.filter(|a| a.data.iter().any(|v| *v < 255)) else {
|
||||
self.opaque.insert(mkey);
|
||||
return None;
|
||||
};
|
||||
let mut png = Vec::new();
|
||||
image::GrayImage::from_raw(a.width, a.height, a.data)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let k = blob.content_key();
|
||||
self.images.insert(k.clone(), blob);
|
||||
self.image_keys.insert(mkey, (k.clone(), a.width, a.height));
|
||||
(k, a.width, a.height)
|
||||
};
|
||||
let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1)));
|
||||
let art = Node::new(self.id(), NodeKind::Image(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() }));
|
||||
Some(OpacityMask::new(art, true))
|
||||
}
|
||||
|
||||
fn add_image_masked(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine, mask: Option<OpacityMask>) {
|
||||
let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() {
|
||||
v
|
||||
} else {
|
||||
@@ -1160,7 +1197,9 @@ impl<'p> Builder<'p> {
|
||||
(k, w, h)
|
||||
};
|
||||
let id = self.id();
|
||||
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })));
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }));
|
||||
n.mask = mask.map(Box::new);
|
||||
self.push_node(n);
|
||||
}
|
||||
|
||||
/// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region
|
||||
@@ -1542,7 +1581,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
};
|
||||
if known || cmyk.is_some() {
|
||||
self.cmyk_keys.insert(key);
|
||||
return self.add_image(key, || cmyk.map(|blob| (blob, w, h)), transform);
|
||||
// Its mask (read by the interpreter, whatever its kind) becomes an opacity
|
||||
// mask, so the inks stay as they are.
|
||||
let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None };
|
||||
return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask);
|
||||
}
|
||||
// JPEG passthrough for plain DeviceRGB/DeviceGray DCT images.
|
||||
let dict = st.dict();
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! four components. A JPEG whose Decode array inverts its samples (Adobe's CMYK JPEGs, the
|
||||
//! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any
|
||||
//! other is decoded, its Decode array applied, and stored as a CMYK TIFF
|
||||
//! ([`ImageBlob::cmyk_tiff`]). Every other image is read as RGB by the interpreter.
|
||||
//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer
|
||||
//! carries it as the image's opacity mask ([`has_mask`]). Every other image is read as RGB by the
|
||||
//! interpreter.
|
||||
|
||||
use hayro_syntax::Filter;
|
||||
use hayro_syntax::object::stream::ImageDecodeParams;
|
||||
@@ -11,7 +13,6 @@ use vectorcraft_doc::ImageBlob;
|
||||
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
|
||||
|
||||
/// A CMYK image read as RGB: why.
|
||||
pub(crate) const MASKED: &str = "CMYK images with a mask were imported in RGB";
|
||||
pub(crate) const UNREAD: &str = "CMYK images whose samples couldn't be read in CMYK were imported in RGB";
|
||||
|
||||
/// The Decode array of a JPEG kept as it is.
|
||||
@@ -57,9 +58,6 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
|
||||
if !cmyk_space(d) {
|
||||
return Ok(None);
|
||||
}
|
||||
if d.contains_key(b"SMask") || d.contains_key(b"Mask") {
|
||||
return Err(MASKED);
|
||||
}
|
||||
let ranges = decode_ranges(d);
|
||||
let filters = st.filters();
|
||||
if ranges == INVERTED && matches!(filters.as_slice(), [Filter::DctDecode]) {
|
||||
@@ -91,3 +89,9 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
|
||||
.collect();
|
||||
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD)
|
||||
}
|
||||
|
||||
/// Does the image carry its own transparency (a soft mask, a stencil mask or a colour key)?
|
||||
pub(crate) fn has_mask(st: &Stream<'_>) -> bool {
|
||||
let d = st.dict();
|
||||
d.contains_key(b"SMask") || d.contains_key(b"Mask") || d.get::<u8>(b"SMaskInData").is_some_and(|v| v != 0)
|
||||
}
|
||||
|
||||
@@ -375,6 +375,11 @@ impl Dest {
|
||||
};
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
// CMYK straight into the destination RGB (the destination shares the source's CMYK).
|
||||
(_, Color::Cmyk { c, m, y, k }) => {
|
||||
let [r, g, b] = self.cms.cmyk_to_rgb([*c, *m, *y, *k], intent);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
_ => {
|
||||
let [r, g, b] = self.cms.srgb_to_rgb(source.display_rgb(c));
|
||||
Color::Rgb { r, g, b }
|
||||
|
||||
@@ -212,19 +212,96 @@ fn other_cmyk_images_keep_their_ink_amounts() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_images_with_a_mask_or_odd_samples_open_in_rgb_with_a_warning() {
|
||||
let inks = flat([0, 102, 255, 0], 4);
|
||||
let mask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 128, 255, 128]);
|
||||
let source = pdf_with_images(&[(cmyk_dict(2, 2, "/SMask 6 0 R /Filter /FlateDecode"), crate::output::deflate(&inks).unwrap())], &[mask]);
|
||||
let r = import_with_report(&source, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(blobs(&r.document)[0].mime, "image/png");
|
||||
assert!(r.warnings.iter().any(|w| w == crate::import_image::MASKED), "{:?}", r.warnings);
|
||||
fn cmyk_images_with_odd_samples_open_in_rgb_with_a_warning() {
|
||||
let four_bits = cmyk_dict(2, 2, "").replace("/BitsPerComponent 8", "/BitsPerComponent 4");
|
||||
let r = import_with_report(&pdf_with_images(&[(four_bits, vec![0x0F; 8])], &[]), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(blobs(&r.document)[0].mime, "image/png");
|
||||
assert!(r.warnings.iter().any(|w| w == crate::import_image::UNREAD), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An opacity mask's image: its blob and pixel size.
|
||||
type MaskImage<'d> = (&'d ImageBlob, u32, u32);
|
||||
|
||||
/// The image nodes on the first layer: each one's blob and its opacity mask's image, if it has one.
|
||||
fn masked(d: &Document) -> Vec<(&ImageBlob, Option<MaskImage<'_>>)> {
|
||||
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() };
|
||||
children
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
let NodeKind::Image(im) = &n.kind else { return None };
|
||||
let mask = n.mask.as_ref().map(|m| match &m.art.kind {
|
||||
NodeKind::Image(a) => (&d.images[&a.key], a.width, a.height),
|
||||
other => panic!("mask art is an image: {other:?}"),
|
||||
});
|
||||
Some((&d.images[&im.key], mask))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_cmyk_images_keep_their_inks_and_carry_the_mask_as_an_opacity_mask() {
|
||||
let inks = flat([0, 102, 255, 0], 4);
|
||||
let z = |data: &[u8]| crate::output::deflate(data).unwrap();
|
||||
// Object 6 (the first after the two images): a soft mask; image 2 a colour key masking its
|
||||
// first two pixels.
|
||||
let smask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 128, 255, 128]);
|
||||
let keyed: Vec<u8> = [[10, 20, 30, 40], [10, 20, 30, 40], [0, 102, 255, 0], [0, 102, 255, 0]].concat();
|
||||
let images = [
|
||||
(cmyk_dict(2, 2, "/SMask 7 0 R /Filter /FlateDecode"), z(&inks)),
|
||||
(cmyk_dict(2, 2, "/Mask [10 10 20 20 30 30 40 40] /Filter /FlateDecode"), z(&keyed)),
|
||||
];
|
||||
let source = pdf_with_images(&images, &[smask]);
|
||||
let r = import_with_report(&source, &ImportOptions::default()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let m = masked(&r.document);
|
||||
assert_eq!(m.len(), 2);
|
||||
for ((blob, mask), (want, alpha)) in m.iter().zip([(&inks, [255, 128, 255, 128]), (&keyed, [0, 0, 255, 255])]) {
|
||||
assert_eq!(blob.mime, "image/tiff");
|
||||
assert!(close(&blob.cmyk().unwrap().data, want, 0), "the inks as they were");
|
||||
let (mblob, mw, mh) = mask.expect("an opacity mask");
|
||||
assert_eq!((mw, mh), (2, 2));
|
||||
let grey = image::load_from_memory(&mblob.bytes).unwrap().to_luma8();
|
||||
assert_eq!(grey.into_raw(), alpha, "the mask's values");
|
||||
}
|
||||
// The mask art covers the image exactly.
|
||||
let NodeKind::Layer { children, .. } = &r.document.layers[0].kind else { panic!() };
|
||||
let img = children.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).unwrap();
|
||||
assert_eq!(img.geometric_bounds(), img.mask.as_ref().unwrap().art.geometric_bounds());
|
||||
// Written as CMYK with the mask in grey; it opens with the same inks.
|
||||
let out = export_with_report(&r.document, &opts(json!({}))).unwrap();
|
||||
let w = written(&out.bytes);
|
||||
assert!(w.iter().filter(|w| w.space == "DeviceCMYK").count() == 2 && w.iter().any(|w| w.space == "DeviceGray"), "{w:?}");
|
||||
let back = import_with_report(&out.bytes, &ImportOptions::default()).unwrap();
|
||||
let inked: Vec<Vec<u8>> = all_images(&back.document).iter().filter_map(|b| b.cmyk()).map(|i| i.data).collect();
|
||||
assert!(inked.iter().any(|d| close(d, &inks, 3)) && inked.iter().any(|d| close(d, &keyed, 3)), "{:?}", back.warnings);
|
||||
// A mask that hides nothing adds none.
|
||||
let opaque = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255; 4]);
|
||||
let r = import_with_report(
|
||||
&pdf_with_images(&[(cmyk_dict(2, 2, "/SMask 6 0 R /Filter /FlateDecode"), z(&inks))], &[opaque]),
|
||||
&ImportOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(masked(&r.document)[0].1.is_none());
|
||||
}
|
||||
|
||||
/// Every image blob a document's art uses (mask art included).
|
||||
fn all_images(d: &Document) -> Vec<&ImageBlob> {
|
||||
let mut keys = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
keys.push(im.key.clone());
|
||||
}
|
||||
if let Some(m) = &n.mask {
|
||||
m.art.walk(&mut |a| {
|
||||
if let NodeKind::Image(im) = &a.kind {
|
||||
keys.push(im.key.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
keys.iter().filter_map(|k| d.images.get(k)).collect()
|
||||
}
|
||||
|
||||
/// A document with CMYK image `blob` of `w` × `h` pixels placed 40 pt wide.
|
||||
fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document {
|
||||
let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk);
|
||||
|
||||
@@ -103,6 +103,9 @@ pub struct RenderOptions {
|
||||
/// Screen view: Image Trace objects draw as their View asks (outlines, the source image…);
|
||||
/// off, they draw their tracing result, as exports and printing do.
|
||||
pub trace_views: bool,
|
||||
/// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image
|
||||
/// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off).
|
||||
pub smooth_images: bool,
|
||||
}
|
||||
|
||||
/// How edges are rasterized (raster export option).
|
||||
@@ -169,6 +172,7 @@ impl Default for RenderOptions {
|
||||
anti_alias: AntiAlias::Art,
|
||||
progressive_placed: false,
|
||||
trace_views: false,
|
||||
smooth_images: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1419,7 +1423,9 @@ impl Renderer {
|
||||
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink, f.doc.images.get(&im.key)) };
|
||||
let sx = im.width as f64 / pm.width().max(1) as f64;
|
||||
let sy = im.height as f64 / pm.height().max(1) as f64;
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
|
||||
match area {
|
||||
Some((bp, rule)) => {
|
||||
ctx.set_transform(f.view);
|
||||
|
||||
@@ -362,3 +362,26 @@ fn invalid_dash_pattern_strokes_solid() {
|
||||
assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
|
||||
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
|
||||
/// Anti-aliased Images off, #394).
|
||||
#[test]
|
||||
fn images_take_their_nearest_pixel_without_smoothing() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
let img = image::RgbaImage::from_fn(2, 1, |x, _| image::Rgba(if x == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.images.insert("bw".into(), ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
|
||||
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
// How many pixels across the image are neither black nor white.
|
||||
let greys = |smooth_images| {
|
||||
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() });
|
||||
(0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count()
|
||||
};
|
||||
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
|
||||
assert_eq!(greys(false), 0, "nearest: black, then white");
|
||||
}
|
||||
|
||||
@@ -878,6 +878,31 @@ pub fn set_platform_font_files(list: PlatformFontFiles) {
|
||||
let _ = list;
|
||||
}
|
||||
|
||||
/// Folders the user adds to the font scan (Preferences › Type › Additional Fonts Folder, #683),
|
||||
/// read with their subfolders after the platform's.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static USER_FONT_DIRS: std::sync::RwLock<Vec<PathBuf>> = std::sync::RwLock::new(Vec::new());
|
||||
|
||||
/// Have [`system_font_dirs`] also read `dirs` (#683), so the fonts in them are listed and used as
|
||||
/// if installed. The next check for installed fonts ([`FontDb::installed_fonts_changed`]) then
|
||||
/// reports the change, and a scan catalogs them.
|
||||
pub fn set_user_font_dirs(dirs: Vec<PathBuf>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
*USER_FONT_DIRS.write().unwrap_or_else(std::sync::PoisonError::into_inner) = dirs;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = dirs;
|
||||
}
|
||||
|
||||
/// The folders [`set_user_font_dirs`] added (none on wasm).
|
||||
pub fn user_font_dirs() -> Vec<PathBuf> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return USER_FONT_DIRS.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's) and the font files known to it
|
||||
/// outside them (on Windows those registered with it, and those [`set_platform_font_files`]
|
||||
/// lists), scanned by [`FontDb::global`].
|
||||
@@ -926,6 +951,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
let files = fonts_outside(list(), &dirs);
|
||||
dirs.extend(files);
|
||||
}
|
||||
dirs.extend(user_font_dirs());
|
||||
dirs
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ pub mod thread;
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, style_weight,
|
||||
system_font_dirs,
|
||||
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs,
|
||||
style_weight, system_font_dirs, user_font_dirs,
|
||||
};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
|
||||
+185
-7
@@ -79,6 +79,9 @@ pub struct Targets {
|
||||
segments: Vec<(Rect, PathSeg)>,
|
||||
/// Ruler guides, which pull a point into line where they run (Snap to Point).
|
||||
rulers: Vec<Ruler>,
|
||||
/// The bounds of the objects a moved selection spaces itself evenly with (Spacing Guides):
|
||||
/// gathered only for moves ([`Self::for_move`]).
|
||||
boxes: Vec<Rect>,
|
||||
/// How the guides these targets produce look.
|
||||
style: GuideStyle,
|
||||
}
|
||||
@@ -145,11 +148,47 @@ fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point,
|
||||
within(lines, |l| l.0, v, tol)
|
||||
}
|
||||
|
||||
/// `r`'s extent along the x axis (`x`) or the y axis.
|
||||
fn span(r: &Rect, x: bool) -> (f64, f64) {
|
||||
if x { (r.x0, r.x1) } else { (r.y0, r.y1) }
|
||||
}
|
||||
|
||||
/// The objects a moved selection (`moving`) spaces itself evenly with, by their bounds: each
|
||||
/// visible object on a layer as a whole (a group too), and inside a group holding some of the
|
||||
/// moving objects its other objects. At most [`BUDGET`].
|
||||
fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec<Rect> {
|
||||
fn push(nodes: &[Arc<Node>], moving: &HashSet<NodeId>, holders: &HashSet<NodeId>, out: &mut Vec<Rect>) {
|
||||
for n in nodes.iter().filter(|n| n.visible && !moving.contains(&n.id)) {
|
||||
if out.len() >= BUDGET {
|
||||
return;
|
||||
}
|
||||
match n.children() {
|
||||
Some(ch) if n.is_layer() || holders.contains(&n.id) => push(ch, moving, holders, out),
|
||||
_ => out.extend(n.geometric_bounds()),
|
||||
}
|
||||
}
|
||||
}
|
||||
let holders: HashSet<NodeId> = moving.iter().filter_map(|id| doc.ancestry(*id)).flatten().collect();
|
||||
let mut out = vec![];
|
||||
push(&doc.layers, &moving.iter().copied().collect(), &holders, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
impl Targets {
|
||||
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
|
||||
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
|
||||
}
|
||||
|
||||
/// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with
|
||||
/// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
|
||||
pub fn for_move(cx: &ToolContext) -> Self {
|
||||
let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx);
|
||||
if cx.spacing_guides {
|
||||
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its
|
||||
/// anchors, centres, edges and path segments, its anchors in line too. The anchors and
|
||||
/// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets.
|
||||
@@ -508,7 +547,7 @@ impl Targets {
|
||||
let rects: Vec<Rect> = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect();
|
||||
let (adj, mut ov, aligned) = self.align_rects(&rects, tol);
|
||||
let out = p + adj;
|
||||
if aligned {
|
||||
if aligned.contains(&true) {
|
||||
ov.extend(self.label(out, "align"));
|
||||
}
|
||||
(out, ov)
|
||||
@@ -520,15 +559,107 @@ impl Targets {
|
||||
}
|
||||
|
||||
/// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any
|
||||
/// of them nearest a target, per axis. Returns the shift and the guides.
|
||||
/// of them nearest a target, per axis. A single moved selection with Spacing Guides
|
||||
/// ([`Self::for_move`]) is spaced evenly along an axis that lines up with nothing
|
||||
/// ([`Self::even_spacing`]). Returns the shift and the guides.
|
||||
pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>) {
|
||||
let (d, ov, _) = self.align_rects(rects, tol);
|
||||
let (mut d, mut ov, aligned) = self.align_rects(rects, tol);
|
||||
if let [r] = rects
|
||||
&& !self.boxes.is_empty()
|
||||
{
|
||||
let moved = *r + d;
|
||||
let mut spaced = false;
|
||||
for (x, lined_up) in [true, false].into_iter().zip(aligned) {
|
||||
if let Some((shift, _)) = self.even_spacing(moved, x, tol).filter(|_| !lined_up) {
|
||||
*(if x { &mut d.x } else { &mut d.y }) += shift;
|
||||
spaced = true;
|
||||
}
|
||||
}
|
||||
// The equal gaps, marked where the rectangle ends up.
|
||||
if spaced {
|
||||
let done = *r + d;
|
||||
for (x, lined_up) in [true, false].into_iter().zip(aligned) {
|
||||
let gaps = if lined_up { None } else { self.even_spacing(done, x, tol) };
|
||||
ov.extend(gaps.into_iter().flat_map(|(_, gaps)| gaps).flat_map(|g| self.gap_marks(g, x, tol)));
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, ov)
|
||||
}
|
||||
|
||||
/// [`Self::snap_rects`], also saying whether an axis lined up (even with Alignment Guides
|
||||
/// off, or already in line, when there is no line or shift to tell).
|
||||
fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>, bool) {
|
||||
/// Spacing Guides along the x axis (`x`) or the y axis for the moved rectangle `r`, among the
|
||||
/// objects of its row (column): the shift (within `tol`) that leaves it as far from its
|
||||
/// neighbour before or after it as two other objects of the row are apart, or as far from both
|
||||
/// neighbours; and the equal gaps then, as (start, end, where across) along that axis.
|
||||
fn even_spacing(&self, r: Rect, x: bool, tol: f64) -> Option<(f64, Vec<[f64; 3]>)> {
|
||||
let (r0, r1) = span(&r, x);
|
||||
let len = r1 - r0;
|
||||
let (c0, c1) = span(&r, !x);
|
||||
// The objects beside it, before or after, overlapping it across, by where they start.
|
||||
let mut row: Vec<(Rect, f64, f64)> = self
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|b| (*b, span(b, x).0, span(b, x).1))
|
||||
.filter(|(b, a0, a1)| span(b, !x).0 < c1 && span(b, !x).1 > c0 && (*a1 <= r0 + tol || *a0 >= r1 - tol))
|
||||
.collect();
|
||||
row.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
// Where two boxes overlap across: the middle of that (or of the space between them).
|
||||
let mid = |a: &Rect, b: &Rect| (span(a, !x).0.max(span(b, !x).0) + span(a, !x).1.min(span(b, !x).1)) / 2.0;
|
||||
// Each object and the nearest one after it, unless the moved rectangle sits between them.
|
||||
let pairs: Vec<(f64, [f64; 3])> = row
|
||||
.iter()
|
||||
.filter_map(|(p, _, p1)| {
|
||||
let next = row.partition_point(|(_, a0, _)| *a0 < *p1);
|
||||
let (q, q0, _) = row.get(next)?;
|
||||
let straddles = *p1 <= r0 + tol && *q0 >= r1 - tol;
|
||||
(*q0 > *p1 && !straddles).then(|| (q0 - p1, [*p1, *q0, mid(p, q)]))
|
||||
})
|
||||
.collect();
|
||||
let before = row.iter().filter(|(_, _, a1)| *a1 <= r0 + tol).max_by(|a, b| a.2.total_cmp(&b.2));
|
||||
let after = row.iter().filter(|(_, a0, _)| *a0 >= r1 - tol).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
// Where it could start, and the gap it repeats (None: halfway between its neighbours).
|
||||
let mut starts: Vec<(f64, Option<f64>)> = vec![];
|
||||
if let (Some(a), Some(b)) = (before, after)
|
||||
&& b.1 - a.2 > len
|
||||
{
|
||||
starts.push(((a.2 + b.1 - len) / 2.0, None));
|
||||
}
|
||||
for (g, _) in &pairs {
|
||||
starts.extend(before.map(|a| (a.2 + g, Some(*g))));
|
||||
starts.extend(after.map(|b| (b.1 - g - len, Some(*g))));
|
||||
}
|
||||
let (start, repeats) =
|
||||
starts.into_iter().filter(|(t, _)| (t - r0).abs() <= tol).min_by(|a, b| (a.0 - r0).abs().total_cmp(&(b.0 - r0).abs()))?;
|
||||
let at = r + if x { Vec2::new(start - r0, 0.0) } else { Vec2::new(0.0, start - r0) };
|
||||
let (end, mut gaps) = (start + len, vec![]);
|
||||
if let Some((a, _, a1)) = before
|
||||
&& repeats.is_none_or(|g| (start - a1 - g).abs() <= 1e-9)
|
||||
{
|
||||
gaps.push([*a1, start, mid(a, &at)]);
|
||||
}
|
||||
if let Some((b, b0, _)) = after
|
||||
&& repeats.is_none_or(|g| (b0 - end - g).abs() <= 1e-9)
|
||||
{
|
||||
gaps.push([end, *b0, mid(b, &at)]);
|
||||
}
|
||||
if let Some(g) = repeats {
|
||||
gaps.extend(pairs.iter().filter(|(pg, _)| (pg - g).abs() <= 1e-9).map(|(_, gap)| *gap));
|
||||
}
|
||||
Some((start - r0, gaps))
|
||||
}
|
||||
|
||||
/// A Spacing Guides mark: a line across the gap (start, end, where across) along the x axis
|
||||
/// (`x`) or the y axis, with a tick `tick` long either side of it at each end.
|
||||
fn gap_marks(&self, [u0, u1, c]: [f64; 3], x: bool, tick: f64) -> [Overlay; 3] {
|
||||
let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) };
|
||||
let line = |a: Point, b: Point| Overlay::Line { a, b, color: self.style.color, dashed: false };
|
||||
[line(pt(u0, c), pt(u1, c)), line(pt(u0, c - tick), pt(u0, c + tick)), line(pt(u1, c - tick), pt(u1, c + tick))]
|
||||
}
|
||||
|
||||
/// [`Self::snap_rects`] before Spacing Guides, also saying whether each axis (x, y) lined up
|
||||
/// (even with Alignment Guides off, or already in line, when there is no line or shift to
|
||||
/// tell).
|
||||
fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>, [bool; 2]) {
|
||||
let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| {
|
||||
rects
|
||||
.iter()
|
||||
@@ -553,7 +684,7 @@ impl Targets {
|
||||
let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1));
|
||||
ov.extend(self.line(Point::new(x0, y), Point::new(x1, y)));
|
||||
}
|
||||
(d, ov, best_x.is_some() || best_y.is_some())
|
||||
(d, ov, [best_x.is_some(), best_y.is_some()])
|
||||
}
|
||||
|
||||
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
|
||||
@@ -1064,6 +1195,53 @@ mod tests {
|
||||
assert!(anchor(&ov), "{ov:?}");
|
||||
}
|
||||
|
||||
/// Smart Guides › Spacing Guides (#394): a moved selection lands as far from its neighbour as
|
||||
/// two other objects of its row are apart, or halfway between its two neighbours, and the
|
||||
/// equal gaps are marked; up and down the same in a column. Off, it stays where it is dragged.
|
||||
#[test]
|
||||
fn spacing_guides_space_a_moved_selection_evenly() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
// A and B 20 apart in a row at y 300–340, C and D 30 apart in a column at x 400–440.
|
||||
for r in [
|
||||
Rect::new(20.0, 300.0, 60.0, 340.0),
|
||||
Rect::new(80.0, 300.0, 120.0, 340.0),
|
||||
Rect::new(400.0, 20.0, 440.0, 50.0),
|
||||
Rect::new(400.0, 80.0, 440.0, 110.0),
|
||||
] {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Default::default())).unwrap();
|
||||
}
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count();
|
||||
let t = Targets::for_move(&cx(&d, &s, &p));
|
||||
// 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each).
|
||||
let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(-1.0, 0.0));
|
||||
assert_eq!(lines(&ov), 6, "{ov:?}");
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (120.0, 140.0) && a.y == b.y)), "{ov:?}");
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (60.0, 80.0) && a.y == b.y)), "{ov:?}");
|
||||
// Halfway between B and a box at 220–260: 50 either side.
|
||||
let mut wide = d.clone();
|
||||
let id = wide.alloc_id();
|
||||
wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default()))
|
||||
.unwrap();
|
||||
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(2.0, 0.0));
|
||||
assert!(lines(&ov) >= 6, "{ov:?}");
|
||||
// A column: 28 under D, as C and D are 30 apart.
|
||||
let (dv, ov) = t.snap_rect(Rect::new(405.0, 138.0, 445.0, 168.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(0.0, 2.0));
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.y, b.y) == (110.0, 140.0) && a.x == b.x)), "{ov:?}");
|
||||
// Above C, 32 over it: 30.
|
||||
let (dv, _) = t.snap_rect(Rect::new(405.0, -42.0, 445.0, -12.0), 4.0);
|
||||
assert_eq!(dv, Vec2::new(0.0, 2.0));
|
||||
// Off: nowhere to go, and no marks.
|
||||
let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) };
|
||||
let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
|
||||
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
|
||||
}
|
||||
|
||||
/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and
|
||||
/// Anchor/Path Labels off, change what shows and never where a point lands.
|
||||
#[test]
|
||||
|
||||
@@ -322,6 +322,10 @@ pub struct ToolContext<'a> {
|
||||
pub measurement_labels: bool,
|
||||
/// Smart Guides → Transform Tools: the readouts while scaling, rotating and shearing.
|
||||
pub transform_tools_guides: bool,
|
||||
/// Smart Guides → Spacing Guides: a moved selection snaps to spacing equal to the gap between
|
||||
/// two other objects in its row or column, or evenly between its two neighbours, and the
|
||||
/// equal gaps show.
|
||||
pub spacing_guides: bool,
|
||||
/// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the
|
||||
/// pointer, a dragged edge or a drawn point.
|
||||
pub snapping_tolerance: f64,
|
||||
@@ -695,6 +699,7 @@ pub(crate) mod testutil {
|
||||
anchor_path_labels: true,
|
||||
measurement_labels: true,
|
||||
transform_tools_guides: true,
|
||||
spacing_guides: true,
|
||||
snapping_tolerance: 4.0,
|
||||
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
|
||||
screen: None,
|
||||
|
||||
@@ -87,7 +87,7 @@ impl MoveSnap {
|
||||
pub(crate) fn new(cx: &ToolContext) -> Self {
|
||||
Self {
|
||||
bounds: selection_bounds(cx),
|
||||
targets: cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx)),
|
||||
targets: cx.smart_guides.then(|| Targets::for_move(cx)),
|
||||
points: Targets::snap_to_point(cx, &cx.selection.objects),
|
||||
}
|
||||
}
|
||||
|
||||
+202
-20
@@ -69,9 +69,17 @@ enum Drag {
|
||||
center: Point,
|
||||
middle: bool,
|
||||
},
|
||||
/// A Zoom tool drag without Animated Zoom: the area to zoom to.
|
||||
ZoomBox {
|
||||
start: Pos2,
|
||||
},
|
||||
/// A Zoom tool press with Animated Zoom ([`animated_zoom`]): dragged sideways it zooms about
|
||||
/// `start`, held still (for `held` seconds so far) it zooms on, else it's a click.
|
||||
ZoomScrub {
|
||||
start: Pos2,
|
||||
held: f32,
|
||||
moved: bool,
|
||||
},
|
||||
RotateView {
|
||||
start_angle: f64,
|
||||
start_rot: f64,
|
||||
@@ -221,6 +229,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel
|
||||
// is bigger than a screen pixel (below that the screen render already shows it).
|
||||
let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
|
||||
// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show
|
||||
// their pixels as they rasterize, hard-edged.
|
||||
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
|
||||
let key = CacheKey {
|
||||
doc: st.uid as usize,
|
||||
revision: st.revision,
|
||||
@@ -237,6 +248,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
anti_alias: app.session.prefs.anti_aliased_artwork,
|
||||
placed: vectorcraft_render::placed_document::generation(),
|
||||
pixel,
|
||||
smooth_images,
|
||||
};
|
||||
// Placed documents' bitmaps are being made: draw again when they are ready.
|
||||
if vectorcraft_render::placed_document::busy() {
|
||||
@@ -285,6 +297,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
|
||||
progressive_placed: true,
|
||||
trace_views: true,
|
||||
smooth_images,
|
||||
..opts
|
||||
};
|
||||
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
|
||||
@@ -438,15 +451,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
|
||||
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
|
||||
// User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling;
|
||||
// off, they keep their size.
|
||||
let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() };
|
||||
match ui.input(|i| i.pointer.hover_pos()) {
|
||||
// An OS cursor: the system moves it at once, where a painted one trails the pointer
|
||||
// (#444). The system cursor stands in for cursors without a glyph.
|
||||
Some(_) if custom && crate::cursors::OS_CURSORS => {
|
||||
let ppp = ui.ctx().native_pixels_per_point().unwrap_or_else(|| ui.ctx().pixels_per_point());
|
||||
let ppp = ui.ctx().pixels_per_point() / ui_scale;
|
||||
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
|
||||
cursor_icon(c)
|
||||
}
|
||||
Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None,
|
||||
Some(hp) if custom && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => egui::CursorIcon::None,
|
||||
_ => cursor_icon(c),
|
||||
}
|
||||
} else {
|
||||
@@ -522,11 +538,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
if (factor - 1.0).abs() > 1e-6
|
||||
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
|
||||
{
|
||||
let before = xf.to_doc(p);
|
||||
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
|
||||
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
|
||||
let after = nx.to_doc(p);
|
||||
vm.center += before - after;
|
||||
zoom_about(vm, rect, p, vm.zoom * factor);
|
||||
}
|
||||
if scroll != egui::Vec2::ZERO
|
||||
&& let Some(vm) = app.view_mut()
|
||||
@@ -552,7 +564,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
ui.ctx().memory_mut(|mem| mem.stop_text_input());
|
||||
app.ui.flyout = None;
|
||||
let d = if zoom_mode {
|
||||
Drag::ZoomBox { start: p }
|
||||
if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } }
|
||||
} else if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if tool == "rotateView" {
|
||||
@@ -599,7 +611,26 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
vm.rotation = vectorcraft_geom::normalize_deg(deg);
|
||||
}
|
||||
}
|
||||
Drag::ZoomBox { .. } | Drag::Art | Drag::Gesture => {}
|
||||
Drag::ZoomBox { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)),
|
||||
Drag::ZoomScrub { start, held, moved } => {
|
||||
let dt = ui.input(|i| i.unstable_dt).min(0.25);
|
||||
let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP);
|
||||
// Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out).
|
||||
let exponent = if moved {
|
||||
f64::from(pointer.delta().x) * SCRUB_ZOOM
|
||||
} else if held > HOLD_DELAY {
|
||||
f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if exponent != 0.0
|
||||
&& let Some(vm) = app.view_mut()
|
||||
{
|
||||
zoom_about(vm, rect, start, vm.zoom * exponent.exp());
|
||||
}
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved }));
|
||||
}
|
||||
Drag::Art | Drag::Gesture => {}
|
||||
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
|
||||
}
|
||||
@@ -632,13 +663,17 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
vm.center = Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
|
||||
vm.zoom = (rect.width() as f64 / (b.x - a.x)).min(rect.height() as f64 / (b.y - a.y)).clamp(0.0313, 640.0);
|
||||
} else {
|
||||
let before = xf.to_doc(p);
|
||||
vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt);
|
||||
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
|
||||
vm.center += before - nx.to_doc(p);
|
||||
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A click (not held, not dragged) steps the zoom as with the marquee.
|
||||
Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => {
|
||||
if let Some(vm) = app.view_mut() {
|
||||
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
|
||||
}
|
||||
}
|
||||
Drag::ZoomScrub { .. } => {}
|
||||
Drag::Tool => {
|
||||
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
|
||||
dispatch(app, &ev, view);
|
||||
@@ -674,6 +709,36 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
|
||||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||||
const WHEEL_ZOOM: f64 = 0.01;
|
||||
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
|
||||
const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
|
||||
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
|
||||
const SCRUB_SLOP: f32 = 3.0;
|
||||
/// Animated Zoom: how long a press is held still before it zooms on, in seconds…
|
||||
const HOLD_DELAY: f32 = 0.3;
|
||||
/// …and how fast it zooms then (as `exp(seconds × HOLD_ZOOM)`: twice as close each second).
|
||||
const HOLD_ZOOM: f64 = std::f64::consts::LN_2;
|
||||
|
||||
/// Performance › Animated Zoom, which needs GPU Performance as in Illustrator: the Zoom tool zooms
|
||||
/// as it is dragged sideways or held, instead of zooming to the area dragged across.
|
||||
pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool {
|
||||
p.animated_zoom && p.gpu_performance
|
||||
}
|
||||
|
||||
/// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document
|
||||
/// point under screen point `p` where it is.
|
||||
fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) {
|
||||
let before = Xf::new(rect, vm).to_doc(p);
|
||||
vm.zoom = zoom.clamp(0.0313, 640.0);
|
||||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||||
}
|
||||
|
||||
/// A dashed marquee around screen rectangle `r`.
|
||||
fn marquee(p: &egui::Painter, r: egui::Rect) {
|
||||
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
for w in pts.windows(2) {
|
||||
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
|
||||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
@@ -1635,13 +1700,7 @@ fn names_an_artboard(text: &str) -> bool {
|
||||
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
|
||||
for o in overlays {
|
||||
match o {
|
||||
Overlay::Marquee(r) => {
|
||||
let sr = xf.rect_to_screen(*r);
|
||||
let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()];
|
||||
for w in pts.windows(2) {
|
||||
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
|
||||
}
|
||||
}
|
||||
Overlay::Marquee(r) => marquee(p, xf.rect_to_screen(*r)),
|
||||
Overlay::Path { path, color, width, dashed } => {
|
||||
let s = Stroke::new(*width, c32(*color));
|
||||
if *dashed {
|
||||
@@ -1723,6 +1782,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
app.run("file.open", json!({})).ok();
|
||||
}
|
||||
});
|
||||
recent(app, ui);
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
@@ -1743,6 +1803,35 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||||
crate::community::links(app, ui);
|
||||
}
|
||||
|
||||
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
|
||||
/// folder; a click opens it. Nothing when there are none.
|
||||
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
const SHOWN: usize = 6;
|
||||
let files: Vec<String> = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(28.0);
|
||||
ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
for path in files {
|
||||
let p = std::path::Path::new(&path);
|
||||
let name = p.file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned());
|
||||
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
crate::icons::icon(ui, "history", 16.0, t.icon);
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(&name).color(t.accent).size(13.0)).frame(false)).on_hover_text(&path);
|
||||
ui.add(egui::Label::new(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).truncate());
|
||||
if r.clicked()
|
||||
&& let Err(e) = crate::io::open_path(app, &path)
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
|
||||
vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
|
||||
}
|
||||
@@ -2380,6 +2469,28 @@ mod tests {
|
||||
assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again");
|
||||
}
|
||||
|
||||
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
|
||||
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
|
||||
/// canvas always does out of Pixel Preview.
|
||||
#[test]
|
||||
fn pixel_preview_smooths_bitmaps_only_when_asked() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
let ctx = egui::Context::default();
|
||||
let smooth = |app: &mut VectorcraftApp| {
|
||||
let (_, mut delta) = frame_output(app, &ctx);
|
||||
delta.clear();
|
||||
app.canvas.key.as_ref().map(|k| k.smooth_images).unwrap()
|
||||
};
|
||||
assert!(smooth(&mut app), "out of Pixel Preview");
|
||||
app.ui.view.pixel_preview = true;
|
||||
app.view_mut().unwrap().zoom = 8.0;
|
||||
assert!(!smooth(&mut app), "Pixel Preview, off by default");
|
||||
app.run("prefs.set", json!({"key": "antiAliasedBitmaps", "value": true})).unwrap();
|
||||
assert!(smooth(&mut app), "Pixel Preview, on");
|
||||
}
|
||||
|
||||
/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and
|
||||
/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview,
|
||||
/// or with the option off.
|
||||
@@ -2692,6 +2803,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance › Animated Zoom (#394): the Zoom tool dragged sideways zooms about where it was
|
||||
/// pressed (right in, left out), held still it zooms on, and a click still steps. Off, or with
|
||||
/// GPU Performance off, a drag zooms to the area dragged across.
|
||||
#[test]
|
||||
fn animated_zoom_scrubs_and_holds_else_the_zoom_tool_zooms_to_an_area() {
|
||||
use egui::Event;
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool("zoom");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let at = rect.center() - vec2(100.0, 50.0);
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
// Press at `at`, move by `by` and hold for `frames` frames (a 60th of a second each), let go.
|
||||
let press = |app: &mut VectorcraftApp, by: egui::Vec2, frames: usize| {
|
||||
frame(app, &ctx, vec![Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(at, true)]);
|
||||
frame(app, &ctx, vec![Event::PointerMoved(at + by)]);
|
||||
for _ in 0..frames {
|
||||
frame(app, &ctx, vec![]);
|
||||
}
|
||||
frame(app, &ctx, vec![button(at + by, false)]);
|
||||
};
|
||||
let doc_at = |app: &VectorcraftApp, p: Pos2| Xf::new(rect, app.view().unwrap()).to_doc(p);
|
||||
let pinned = doc_at(&app, at);
|
||||
let z0 = app.view().unwrap().zoom;
|
||||
press(&mut app, vec2(100.0, 0.0), 0);
|
||||
let z1 = app.view().unwrap().zoom;
|
||||
assert!((z1 / z0 - 2.0).abs() < 1e-6, "100 points right doubles the zoom: {z0} → {z1}");
|
||||
assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press");
|
||||
press(&mut app, vec2(-100.0, 0.0), 0);
|
||||
assert!((app.view().unwrap().zoom - z0).abs() < 1e-6, "and back to the left");
|
||||
press(&mut app, vec2(0.0, 0.0), 0);
|
||||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z0, true), "a click steps");
|
||||
let z2 = app.view().unwrap().zoom;
|
||||
press(&mut app, vec2(0.0, 0.0), 40);
|
||||
assert!(app.view().unwrap().zoom > z2 * 1.2, "held, it zooms on");
|
||||
assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press");
|
||||
// Off (or without GPU Performance): the area dragged across fills the view.
|
||||
for key in ["animatedZoom", "gpuPerformance"] {
|
||||
app.run("prefs.reset", json!({})).unwrap();
|
||||
app.run("prefs.set", json!({"key": key, "value": false})).unwrap();
|
||||
app.view_mut().unwrap().zoom = z0;
|
||||
press(&mut app, vec2(100.0, 50.0), 0);
|
||||
let z = app.view().unwrap().zoom;
|
||||
assert!((z - z0 * (rect.width() / 100.0).min(rect.height() / 50.0) as f64).abs() < 1e-6, "{key} off: {z0} → {z}");
|
||||
}
|
||||
}
|
||||
|
||||
/// User Interface › Large Tabs (#394): the document tabs are taller, so the canvas starts lower.
|
||||
#[test]
|
||||
fn large_tabs_make_the_document_tabs_taller() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let canvas_top = |app: &mut VectorcraftApp| {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::chrome::doc_tabs(app, ui);
|
||||
show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
app.canvas_rect.unwrap().top()
|
||||
};
|
||||
let small = canvas_top(&mut app);
|
||||
app.run("prefs.set", json!({"key": "largeTabs", "value": true})).unwrap();
|
||||
assert_eq!(canvas_top(&mut app) - small, 9.0, "44 points instead of 35");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_space_zooms_and_space_pans_whatever_the_tool() {
|
||||
use egui::{Event, Modifiers};
|
||||
|
||||
@@ -319,10 +319,12 @@ fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> Stri
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
|
||||
}
|
||||
|
||||
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)".
|
||||
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs
|
||||
/// taller, with larger titles.
|
||||
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 35.0), Sense::hover());
|
||||
let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) };
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let mut x = strip.left();
|
||||
@@ -334,7 +336,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let title = tab_title(d, zoom, app.ui.view.outline);
|
||||
// On the Home screen no tab is the current one.
|
||||
let is_active = Some(i) == active && app.ui.home.is_none();
|
||||
let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim });
|
||||
let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), if is_active { t.text_strong } else { t.text_dim });
|
||||
let w = galley.size().x + 50.0;
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click());
|
||||
@@ -517,6 +519,9 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
/// Contextual hint for the active tool: segments of (text, bold). Keys in bold segments are
|
||||
/// written as shortcuts are ("Alt+Click"); [`hint_segments`] names them for the platform.
|
||||
/// The hint of the Zoom tool with Performance › Animated Zoom on.
|
||||
const ANIMATED_ZOOM_HINT: &str = "zoom.animated";
|
||||
|
||||
fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
|
||||
Some(match tool {
|
||||
"selection" => &[
|
||||
@@ -588,6 +593,16 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
|
||||
("Drag", true),
|
||||
(" to zoom into an area", false),
|
||||
],
|
||||
ANIMATED_ZOOM_HINT => &[
|
||||
("Click", true),
|
||||
(" to zoom in | ", false),
|
||||
("Alt+Click", true),
|
||||
(" to zoom out | ", false),
|
||||
("Drag", true),
|
||||
(" right or left to zoom in or out | ", false),
|
||||
("Hold", true),
|
||||
(" to keep zooming", false),
|
||||
],
|
||||
"eyedropper" => &[
|
||||
("Click", true),
|
||||
(" an object to copy its attributes | ", false),
|
||||
@@ -674,8 +689,8 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
|
||||
/// can insist a complete language covers them.
|
||||
pub fn hint_strings() -> Vec<&'static str> {
|
||||
let mut v = Vec::new();
|
||||
for tool in vectorcraft_tools::catalog::all_tools() {
|
||||
for &(text, bold) in hint_for(tool.id).unwrap_or(&[]) {
|
||||
for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id).chain([ANIMATED_ZOOM_HINT]) {
|
||||
for &(text, bold) in hint_for(tool).unwrap_or(&[]) {
|
||||
let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text };
|
||||
if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) {
|
||||
v.push(text);
|
||||
@@ -729,7 +744,10 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text);
|
||||
ui.add_space(6.0);
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
for (txt, bold) in hint_segments(app.session.tool_id()) {
|
||||
let tool = app.session.tool_id();
|
||||
// The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes.
|
||||
let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool };
|
||||
for (txt, bold) in hint_segments(hint) {
|
||||
let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) };
|
||||
job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() });
|
||||
}
|
||||
@@ -832,4 +850,33 @@ mod tests {
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
assert!(title(&s).ends_with("(RGB/Preview)"));
|
||||
}
|
||||
|
||||
/// The Zoom tool's hint follows Performance › Animated Zoom (#394): a drag zooms as it goes,
|
||||
/// or (off) zooms into the area dragged across.
|
||||
#[test]
|
||||
fn the_zoom_hint_follows_animated_zoom() {
|
||||
fn texts(s: &egui::Shape, out: &mut String) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push_str(t.galley.text()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.select_tool("zoom");
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let hint = |app: &mut crate::VectorcraftApp| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::hint_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut shown = String::new();
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
shown
|
||||
};
|
||||
let on = hint(&mut app);
|
||||
assert!(on.contains(" right or left to zoom in or out") && on.contains("Hold"), "{on}");
|
||||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||||
let off = hint(&mut app);
|
||||
assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,10 +288,19 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Some(std::mem::take(&mut ink.0))
|
||||
}
|
||||
|
||||
/// Paint cursor `c` at `p` on the given (foreground) painter, where the window can't show it as an
|
||||
/// OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay system cursors.
|
||||
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
||||
glyph(c, p).map(|shapes| painter.extend(shapes)).is_some()
|
||||
/// Paint cursor `c` at `p`, `scale` times its size, on the given (foreground) painter, where the
|
||||
/// window can't show it as an OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay
|
||||
/// system cursors.
|
||||
pub fn paint(painter: &Painter, c: Cursor, p: Pos2, scale: f32) -> bool {
|
||||
let place = egui::emath::TSTransform::new(p.to_vec2(), scale);
|
||||
glyph(c, Pos2::ZERO)
|
||||
.map(|shapes| {
|
||||
painter.extend(shapes.into_iter().map(|mut s| {
|
||||
s.transform(place);
|
||||
s
|
||||
}))
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor
|
||||
@@ -555,6 +564,43 @@ mod tests {
|
||||
assert!(off.platform_output.cursor_image.is_none());
|
||||
}
|
||||
|
||||
/// User Interface › Scale Cursor Proportional to UI (#394): off, the tool cursors keep their
|
||||
/// size whatever the UI Scaling; on, they grow with it.
|
||||
#[test]
|
||||
fn tool_cursors_scale_with_the_ui_only_when_asked() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("prefs.set", json!({"key": "uiScaling", "value": 2.0})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
}
|
||||
assert_eq!(ctx.zoom_factor(), 2.0);
|
||||
let over = app.canvas_rect.unwrap().center();
|
||||
let shown =
|
||||
|app: &mut VectorcraftApp, d: f32| frame(app, &ctx, vec![egui::Event::PointerMoved(over + vec2(d, 0.0))]).platform_output.cursor_image;
|
||||
if OS_CURSORS {
|
||||
assert_eq!(shown(&mut app, 0.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap(), "off: its own size");
|
||||
app.run("prefs.set", json!({"key": "scaleCursorWithUi", "value": true})).unwrap();
|
||||
assert_eq!(shown(&mut app, 4.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 2.0).unwrap(), "on: twice as big");
|
||||
}
|
||||
// Painted into the window (macOS, the web), the glyph is drawn at the scale asked for, its
|
||||
// hotspot where the pointer is.
|
||||
let painted = |scale: f32| {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("probe"));
|
||||
assert!(paint(&ui.ctx().layer_painter(layer), Cursor::Arrow, pos2(100.0, 100.0), scale));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().fold(egui::Rect::NOTHING, |r, c| r.union(c.shape.visual_bounding_rect()))
|
||||
};
|
||||
let (full, half) = (painted(1.0), painted(0.5));
|
||||
assert!((half.height() * 2.0 - full.height()).abs() < 1.5 && full.height() > 15.0, "{full:?} {half:?}");
|
||||
assert!(full.contains(pos2(100.0, 100.0)) && half.contains(pos2(100.0, 100.0)), "{full:?} {half:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_hotspot_is_tip() {
|
||||
let pts = arrow_points(pos2(10.0, 20.0));
|
||||
|
||||
@@ -94,6 +94,7 @@ pub use export_for_screens::open as open_export_for_screens;
|
||||
pub(crate) use export_for_screens::{
|
||||
KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_rows as screen_saved_rows,
|
||||
};
|
||||
pub(crate) use new_document::preset_name;
|
||||
pub use new_document::{open as open_new_document, preset_card};
|
||||
pub use png_options::open as open_raster_options;
|
||||
pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
|
||||
|
||||
@@ -190,7 +190,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(640.0);
|
||||
ui.set_min_height(HEIGHT - 32.0);
|
||||
presets(app, ui, &mut d);
|
||||
b.create |= presets(app, ui, &mut d);
|
||||
});
|
||||
});
|
||||
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::same(18)).show(ui, |ui| {
|
||||
@@ -205,8 +205,9 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
b.finish(app, ctx, d);
|
||||
}
|
||||
|
||||
/// The category tabs and the chosen category's preset cards.
|
||||
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
/// The category tabs and the chosen category's preset cards: a click chooses one, a double-click
|
||||
/// also creates it (true then).
|
||||
fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let names: Vec<&str> = newdoc::category_names().collect();
|
||||
let cat = names.iter().position(|n| n.eq_ignore_ascii_case(&d.str("category"))).unwrap_or(0);
|
||||
@@ -227,20 +228,24 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
if list.is_empty() {
|
||||
let hint = if cat == 1 { tl!("Presets you save with the Save Preset button appear here.") } else { tl!("Documents you create appear here.") };
|
||||
ui.label(egui::RichText::new(hint).color(t.text_dim));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let mut create = false;
|
||||
egui::ScrollArea::vertical().id_salt(("newdoc-presets", cat)).max_height(HEIGHT - 110.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
|
||||
let (w, h, preset) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"));
|
||||
for s in &list {
|
||||
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
|
||||
if preset_card(ui, s, selected).clicked() {
|
||||
let card = preset_card(ui, s, selected);
|
||||
if card.clicked() || card.double_clicked() {
|
||||
apply(d, s);
|
||||
}
|
||||
create |= card.double_clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
create
|
||||
}
|
||||
|
||||
/// Is `name` a built-in preset's? Those are ours (translated); Recent and Saved hold the names of
|
||||
@@ -250,7 +255,7 @@ fn is_builtin_preset(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// A preset's name as shown: a built-in one translated, the user's as it is.
|
||||
fn preset_name(name: &str) -> &str {
|
||||
pub(crate) fn preset_name(name: &str) -> &str {
|
||||
if is_builtin_preset(name) { tl!(name) } else { name }
|
||||
}
|
||||
|
||||
@@ -345,7 +350,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
|
||||
// Close and Create at the bottom right.
|
||||
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
|
||||
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
b.create = widgets::primary_button(ui, tl!("Create")).clicked();
|
||||
b.create |= widgets::primary_button(ui, tl!("Create")).clicked();
|
||||
ui.add_space(8.0);
|
||||
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
|
||||
});
|
||||
@@ -558,7 +563,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.horizontal(|ui| {
|
||||
templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked();
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
b.create = widgets::primary_button(ui, tl!("Create Document")).clicked();
|
||||
b.create |= widgets::primary_button(ui, tl!("Create Document")).clicked();
|
||||
ui.add_space(8.0);
|
||||
// Cancel goes back to New Document.
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
@@ -610,6 +615,51 @@ mod tests {
|
||||
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A double-click on a preset card creates the document from it (#663); a click only chooses it.
|
||||
#[test]
|
||||
fn a_double_clicked_preset_creates_the_document() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.newDialog", json!({})).unwrap();
|
||||
set(&mut app, "category", json!("Mobile"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Frames 50 ms apart, so two clicks a frame apart make a double-click.
|
||||
let mut time = 0.0;
|
||||
let mut run = |app: &mut VectorcraftApp, events| {
|
||||
time += 0.05;
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, time: Some(time), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
run(&mut app, vec![]);
|
||||
// The first card of the category, found by its size line.
|
||||
let card = |app: &mut VectorcraftApp, run: &mut dyn FnMut(&mut VectorcraftApp, Vec<egui::Event>) -> egui::FullOutput| {
|
||||
let out = run(app, vec![]);
|
||||
let mut texts = vec![];
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
|
||||
texts.into_iter().find(|(t, _)| t.starts_with("Phone 393")).map(|(_, r)| r.center()).unwrap()
|
||||
};
|
||||
let at = card(&mut app, &mut run);
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
run(&mut app, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
run(&mut app, vec![press(false)]);
|
||||
assert!(app.ui.dialog.is_some() && app.session.documents().is_empty(), "a click only chooses it");
|
||||
assert!(field(&app, "preset").as_str().is_some_and(|p| p == "Phone 393×852"), "{:?}", field(&app, "preset"));
|
||||
run(&mut app, vec![press(true)]);
|
||||
run(&mut app, vec![press(false)]);
|
||||
assert!(app.ui.dialog.is_none(), "the double-click created it: {:?}", app.ui.status);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_fills_the_details_and_create_makes_the_document() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
Add to Swatches Agregar a muestras
|
||||
Add Used Colors Agregar colores usados
|
||||
Additional Plug-ins Folder Carpeta de plugins adicionales
|
||||
Additional Fonts Folder Carpeta de fuentes adicionales
|
||||
Adjust Adjoining Width Points Ajustar puntos de anchura contiguos
|
||||
Adjust Color Balance… Ajustar equilibrio de color…
|
||||
Adjust Colors Ajustar colores
|
||||
@@ -2987,6 +2988,9 @@
|
||||
to zoom in | para acercar |
|
||||
to zoom out | para alejar |
|
||||
to zoom into an area para acercar un área
|
||||
Hold Mantener pulsado
|
||||
right or left to zoom in or out | a la derecha o a la izquierda para acercar o alejar |
|
||||
to keep zooming para seguir haciendo zoom
|
||||
an object to copy its attributes | en un objeto para copiar sus atributos |
|
||||
to apply the selection's to it | para aplicarle los de la selección |
|
||||
to sample a color para muestrear un color
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -77,6 +77,7 @@
|
||||
Add to Swatches Ajouter au nuancier
|
||||
Add Used Colors Ajouter les couleurs utilisées
|
||||
Additional Plug-ins Folder Dossier de modules externes supplémentaires
|
||||
Additional Fonts Folder Dossier de polices supplémentaires
|
||||
Adjust Adjoining Width Points Ajuster les points de largeur adjacents
|
||||
Adjust Color Balance… Régler la balance des couleurs…
|
||||
Adjust Colors Régler les couleurs
|
||||
@@ -2984,6 +2985,9 @@
|
||||
to zoom in | pour effectuer un zoom avant |
|
||||
to zoom out | pour effectuer un zoom arrière |
|
||||
to zoom into an area pour zoomer sur une zone
|
||||
Hold Maintenir
|
||||
right or left to zoom in or out | vers la droite ou la gauche pour effectuer un zoom avant ou arrière |
|
||||
to keep zooming pour continuer le zoom
|
||||
an object to copy its attributes | sur un objet pour copier ses attributs |
|
||||
to apply the selection's to it | pour lui appliquer ceux de la sélection |
|
||||
to sample a color pour prélever une couleur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -76,6 +76,7 @@
|
||||
Add to Swatches Aggiungi ai campioni
|
||||
Add Used Colors Aggiungi colori usati
|
||||
Additional Plug-ins Folder Cartella plug-in aggiuntivi
|
||||
Additional Fonts Folder Cartella font aggiuntivi
|
||||
Adjust Adjoining Width Points Regola punti di larghezza adiacenti
|
||||
Adjust Color Balance… Regola bilanciamento colore…
|
||||
Adjust Colors Regola colori
|
||||
@@ -2987,6 +2988,9 @@
|
||||
to zoom in | per ingrandire |
|
||||
to zoom out | per ridurre |
|
||||
to zoom into an area per ingrandire un'area
|
||||
Hold Tieni premuto
|
||||
right or left to zoom in or out | a destra o a sinistra per ingrandire o ridurre |
|
||||
to keep zooming per continuare lo zoom
|
||||
an object to copy its attributes | su un oggetto per copiarne gli attributi |
|
||||
to apply the selection's to it | per applicargli quelli della selezione |
|
||||
to sample a color per campionare un colore
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -675,6 +675,9 @@
|
||||
to transform | :変形 |
|
||||
to zoom in | :拡大 |
|
||||
to zoom into an area :範囲を拡大
|
||||
Hold 長押し
|
||||
right or left to zoom in or out | :右で拡大、左で縮小 |
|
||||
to keep zooming :拡大または縮小し続ける
|
||||
to zoom out | :縮小 |
|
||||
while dragging to move perpendicular to the plane | :ドラッグ中に面と垂直に移動 |
|
||||
(closest) (最も近い)
|
||||
@@ -721,6 +724,7 @@
|
||||
Add to Selection 選択範囲に追加
|
||||
Add to Swatches スウォッチに追加
|
||||
Additional Plug-ins Folder 追加プラグインフォルダー
|
||||
Additional Fonts Folder 追加フォントフォルダー
|
||||
Adjust Adjoining Width Points 隣接する幅ポイントを調整
|
||||
Adjust Colors カラーを調整
|
||||
Adjust Options: 調整オプション:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -78,6 +78,9 @@
|
||||
to transform | para transformar |
|
||||
to zoom in | para ampliar |
|
||||
to zoom into an area para ampliar uma área
|
||||
Hold Manter pressionado
|
||||
right or left to zoom in or out | para a direita ou a esquerda para ampliar ou reduzir |
|
||||
to keep zooming para continuar o zoom
|
||||
to zoom out | para reduzir |
|
||||
while dragging to move perpendicular to the plane | ao arrastar para mover perpendicularmente ao plano |
|
||||
(closest) (mais próximo)
|
||||
@@ -135,6 +138,7 @@
|
||||
Add to Selection Adicionar à Seleção
|
||||
Add to Swatches Adicionar às Amostras
|
||||
Additional Plug-ins Folder Pasta Adicional de Plug-ins
|
||||
Additional Fonts Folder Pasta Adicional de Fontes
|
||||
Adjust Adjoining Width Points Ajustar Pontos de Largura Adjacentes
|
||||
Adjust Color Balance… Ajustar Equilíbrio de Cores…
|
||||
Adjust Colors Ajustar Cores
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -74,6 +74,7 @@
|
||||
Add to Swatches Добавить в образцы
|
||||
Add Used Colors Добавить использованные цвета
|
||||
Additional Plug-ins Folder Папка дополнительных модулей
|
||||
Additional Fonts Folder Папка дополнительных шрифтов
|
||||
Adjust Adjoining Width Points Настроить смежные точки ширины
|
||||
Adjust Color Balance… Настроить цветовой баланс…
|
||||
Adjust Colors Настроить цвета
|
||||
@@ -2983,6 +2984,9 @@
|
||||
to zoom in | для увеличения |
|
||||
to zoom out | для уменьшения |
|
||||
to zoom into an area для увеличения области
|
||||
Hold Удерживать
|
||||
right or left to zoom in or out | вправо или влево для увеличения или уменьшения |
|
||||
to keep zooming для непрерывного масштабирования
|
||||
an object to copy its attributes | в объект для копирования его атрибутов |
|
||||
to apply the selection's to it | для применения атрибутов выделения к нему |
|
||||
to sample a color для взятия образца цвета
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -79,6 +79,7 @@
|
||||
Add to Swatches 添加到色板
|
||||
Add Used Colors 添加已用颜色
|
||||
Additional Plug-ins Folder 附加增效工具文件夹
|
||||
Additional Fonts Folder 附加字体文件夹
|
||||
Adjust Adjoining Width Points 调整相邻宽度点
|
||||
Adjust Color Balance… 调整色彩平衡…
|
||||
Adjust Colors 调整颜色
|
||||
@@ -2927,6 +2928,9 @@
|
||||
to zoom in | 以放大 |
|
||||
to zoom out | 以缩小 |
|
||||
to zoom into an area 以放大指定区域
|
||||
Hold 按住
|
||||
right or left to zoom in or out | 向右以放大、向左以缩小 |
|
||||
to keep zooming 以持续缩放
|
||||
an object to copy its attributes | 选择对象以复制其属性 |
|
||||
to apply the selection's to it | 以将所选对象属性应用到它 |
|
||||
to sample a color 以取样颜色
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -87,6 +87,7 @@
|
||||
Add to Swatches 新增至色票
|
||||
Add Used Colors 新增使用中的顏色
|
||||
Additional Plug-ins Folder 額外的增效模組資料夾
|
||||
Additional Fonts Folder 額外的字體資料夾
|
||||
Adjust Adjoining Width Points 調整相鄰寬度點
|
||||
Adjust Color Balance… 調整色彩平衡…
|
||||
Adjust Colors 調整顏色
|
||||
@@ -3012,6 +3013,9 @@
|
||||
to zoom in | 可放大 |
|
||||
to zoom out | 可縮小 |
|
||||
to zoom into an area 可放大至指定區域
|
||||
Hold 按住
|
||||
right or left to zoom in or out | 向右可放大、向左可縮小 |
|
||||
to keep zooming 可持續縮放
|
||||
an object to copy its attributes | 物件可複製其屬性 |
|
||||
to apply the selection's to it | 可將選取範圍的屬性套用至該物件 |
|
||||
to sample a color 可取樣顏色
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -291,6 +291,8 @@ pub struct CacheKey {
|
||||
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
|
||||
/// with hard edges. None: the art is rendered for the screen.
|
||||
pub pixel: Option<[i64; 4]>,
|
||||
/// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]).
|
||||
pub smooth_images: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{LiveShape, NodeKind, Unit};
|
||||
use vectorcraft_engine::cmd::newdoc;
|
||||
|
||||
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
@@ -196,8 +197,8 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, New Artboard
|
||||
/// and Delete Artboard, and Exit back to the Selection tool (#530).
|
||||
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, its preset and
|
||||
/// orientation (#671), New Artboard and Delete Artboard, and Exit back to the Selection tool (#530).
|
||||
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.general_unit();
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
@@ -220,6 +221,7 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
artboard_size_row(app, ui, i, ab.rect.width(), ab.rect.height());
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let w = (ui.available_width() - 6.0) / 2.0;
|
||||
@@ -238,6 +240,55 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Artboard `i`'s size preset (New Document's saved and built-in presets, by category, either way
|
||||
/// round; Custom when its `w` × `h` points match none) and its orientation. A preset sizes it in its
|
||||
/// current orientation; the other orientation swaps its width and height. Its top-left corner stays
|
||||
/// put either way.
|
||||
fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, w: f64, h: f64) {
|
||||
let landscape = w > h;
|
||||
// A preset `pw` × `ph` turned to the artboard's orientation.
|
||||
let turned = |pw: f64, ph: f64| if (pw > ph) == landscape || pw == ph { (pw, ph) } else { (ph, pw) };
|
||||
let same = |s: &newdoc::DocSettings| {
|
||||
let (pw, ph) = turned(s.width, s.height);
|
||||
(pw - w).abs() < 0.01 && (ph - h).abs() < 0.01
|
||||
};
|
||||
// Recent holds documents made, not presets.
|
||||
let cats: Vec<(&str, Vec<newdoc::DocSettings>)> = newdoc::category_names()
|
||||
.filter(|c| !c.eq_ignore_ascii_case("Recent"))
|
||||
.filter_map(|c| Some((c, newdoc::category(&app.session, c)?)))
|
||||
.filter(|(_, l)| !l.is_empty())
|
||||
.collect();
|
||||
let current = cats.iter().flat_map(|(_, l)| l).find(|s| same(s)).map_or(tl!("Custom"), |s| crate::dialogs::preset_name(&s.name)).to_string();
|
||||
let mut size = None;
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Preset:"));
|
||||
size = widgets::combo(ui, "ab-preset", ¤t, 140.0, false, |ui| {
|
||||
let mut chosen = None;
|
||||
for (cat, list) in &cats {
|
||||
ui.menu_button(tl!(cat), |ui| {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
for s in list {
|
||||
if widgets::menu_item_name(ui, crate::dialogs::preset_name(&s.name), true, same(s)) {
|
||||
chosen = Some(turned(s.width, s.height));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
chosen
|
||||
});
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (is_landscape, tip) in [(false, tl!("Portrait")), (true, tl!("Landscape"))] {
|
||||
if widgets::orientation_button(ui, is_landscape, landscape == is_landscape, tip) && landscape != is_landscape {
|
||||
size = Some((h, w));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((width, height)) = size {
|
||||
app.run("artboard.setProps", json!({"index": i, "width": width, "height": height})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.general_unit();
|
||||
section_header(ui, tl!("Document"));
|
||||
@@ -520,4 +571,41 @@ mod tests {
|
||||
click(&mut app, &ctx, &texts, "Exit");
|
||||
assert_eq!(app.session.tool_id(), "selection");
|
||||
}
|
||||
|
||||
/// #671: Edit Artboards shows the artboard's preset (Custom when it matches none) and its
|
||||
/// orientation; the other orientation swaps its width and height, its corner staying put.
|
||||
#[test]
|
||||
fn edit_artboards_shows_the_preset_and_flips_the_orientation() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"preset": "Letter"})).unwrap();
|
||||
app.run("artboard.setProps", json!({"index": 0, "x": 10, "y": 20})).unwrap();
|
||||
app.select_tool("artboard");
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Preset:") && texts.iter().any(|(t, _)| t == "Letter"), "{texts:?}");
|
||||
// The row's clickable widgets, left to right: the preset combo, Portrait, Landscape.
|
||||
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
|
||||
let buttons: Vec<Rect> = ctx.viewport(|vp| {
|
||||
let mut r: Vec<Rect> = vp
|
||||
.prev_pass
|
||||
.widgets
|
||||
.layers()
|
||||
.flat_map(|(_, w)| w.iter())
|
||||
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
|
||||
.map(|w| w.rect)
|
||||
.collect();
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
r
|
||||
});
|
||||
let landscape = buttons.last().unwrap().center();
|
||||
let press = |pressed| Event::PointerButton { pos: landscape, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(landscape), press(true)]);
|
||||
frame(&mut app, &ctx, vec![press(false)]);
|
||||
let rect = app.session.active().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((rect.x0, rect.y0, rect.width(), rect.height()), (10.0, 20.0, 792.0, 612.0), "landscape, same corner");
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Letter"), "still Letter, turned: {texts:?}");
|
||||
app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap();
|
||||
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
PrefKind::Bool if sp.key == "numbersWithoutUnitsArePoints" => {
|
||||
ui.add_enabled_ui(picas_in_use(d), |ui| bool_row(ui, d, sp.key, sp.label));
|
||||
}
|
||||
// Performance › Animated Zoom needs GPU Performance: dimmed while it is off.
|
||||
PrefKind::Bool if sp.key == "animatedZoom" => {
|
||||
ui.add_enabled_ui(d.bool("gpuPerformance"), |ui| bool_row(ui, d, sp.key, sp.label));
|
||||
}
|
||||
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
|
||||
@@ -90,3 +90,46 @@ fn cmd_n_opens_the_new_document_dialog() {
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("newDocument"));
|
||||
assert_eq!(app.session.documents().len(), 1, "no document is made until the dialog's OK");
|
||||
}
|
||||
|
||||
/// One Home screen frame (no document open) with `events`: the texts drawn and where.
|
||||
fn home_texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut texts = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts));
|
||||
texts
|
||||
}
|
||||
|
||||
/// The Home screen lists the first recent files (#663), each by name and folder; a click opens it,
|
||||
/// and one that can't be opened says so. With none, there is no Recent Files section.
|
||||
#[test]
|
||||
fn home_lists_recent_files_and_a_click_opens_one() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let has = |texts: &[(String, egui::Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
|
||||
assert!(!has(&home_texts(&mut app, &ctx, vec![]), "Recent Files"), "none: no section");
|
||||
let dir = std::env::temp_dir().join("vectorcraft-home-recent");
|
||||
let missing = dir.join("Gone.svg").to_string_lossy().into_owned();
|
||||
app.ui.recent_files = (0..9).map(|i| dir.join(format!("Drawing {i}.svg")).to_string_lossy().into_owned()).collect();
|
||||
app.ui.recent_files.insert(0, missing);
|
||||
let texts = home_texts(&mut app, &ctx, vec![]);
|
||||
assert!(has(&texts, "Recent Files") && has(&texts, "Gone.svg") && has(&texts, "Drawing 4.svg"), "{texts:?}");
|
||||
assert!(!has(&texts, "Drawing 5.svg"), "the first six only");
|
||||
assert!(has(&texts, &dir.to_string_lossy()), "with its folder");
|
||||
let at = texts.iter().find(|(t, _)| t == "Gone.svg").map(|(_, r)| r.center()).unwrap();
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
home_texts(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
home_texts(&mut app, &ctx, vec![press(false)]);
|
||||
assert!(app.session.documents().is_empty());
|
||||
assert!(!app.ui.status.is_empty(), "a file that can't be opened says so");
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ fn slice_tool_cursors_are_drawn_in_code() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
for c in [vectorcraft_tools::Cursor::Slice, vectorcraft_tools::Cursor::SliceSelect] {
|
||||
assert!(crate::cursors::paint(ui.painter(), c, egui::pos2(50.0, 50.0)), "{c:?}");
|
||||
assert!(crate::cursors::paint(ui.painter(), c, egui::pos2(50.0, 50.0), 1.0), "{c:?}");
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
|
||||
@@ -12,7 +12,7 @@ fn the_width_tool_has_its_own_cursors() {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let painter = ui.painter().clone();
|
||||
for c in [Cursor::Width, Cursor::WidthAdd, Cursor::WidthPoint] {
|
||||
assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0)), "{c:?} is drawn");
|
||||
assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0), 1.0), "{c:?} is drawn");
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
|
||||
Reference in New Issue
Block a user