Rulers: remember View › Rulers for new documents; guides as f64 (#2918) (#2954)

View › Rulers (⌘R) now writes unitsAndRulers.showRulers, which is applied
at launch and whenever the preference changes, so new and opened documents
show the rulers as last set, as in Photoshop. The Preferences dialog doesn't
list it: the View menu sets it.

Guides are Vec<f64> (doc, New Guide/Move Guide, guide layouts, artboard and
canvas geometry, snapping, PSD resource 1032), so a guide placed in ruler
units or read from a PSD's 1/32 px positions keeps its exact place.

Scorecard UI-217-20 done (part of #217).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 16:05:13 +02:00 committed by GitHub
parent 2d2e4993a1
commit 1a54384d80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 140 additions and 38 deletions

View File

@ -716,10 +716,13 @@ impl Layer {
}
}
/// Ruler guides in document pixels. f64 like the rest of the view geometry: a guide placed in
/// ruler units (1 cm at 300 ppi is 118.110236… px) or read from a PSD's 1/32 px positions keeps
/// its exact place.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Guides {
pub horizontal: Vec<f32>,
pub vertical: Vec<f32>,
pub horizontal: Vec<f64>,
pub vertical: Vec<f64>,
}
/// Which areas an alpha channel's overlay colour marks (Channel Options › Color Indicates).

View File

@ -367,8 +367,8 @@ fn clear_artboard_guides(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = active_artboard(s).ok_or(EngineError::Other("no artboard selected".into()))?;
let r = d.doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?;
let inside_x = |x: &f32| f64::from(*x) > f64::from(r.x0) && f64::from(*x) < f64::from(r.x1);
let inside_y = |y: &f32| f64::from(*y) > f64::from(r.y0) && f64::from(*y) < f64::from(r.y1);
let inside_x = |x: &f64| *x > f64::from(r.x0) && *x < f64::from(r.x1);
let inside_y = |y: &f64| *y > f64::from(r.y0) && *y < f64::from(r.y1);
let n = d.doc.guides.vertical.iter().filter(|x| inside_x(x)).count() + d.doc.guides.horizontal.iter().filter(|y| inside_y(y)).count();
if n == 0 {
return Ok(json!({"cleared": 0}));
@ -405,7 +405,7 @@ pub fn artboard_document(doc: &Document, id: LayerId) -> Result<Option<Document>
one.layer_comps.clear();
one.last_applied_comp = None;
one.last_document_state = None;
let shift = |v: &[f32], o: i32, len: u32| v.iter().map(|g| g - o as f32).filter(|g| *g > 0.0 && *g < len as f32).collect::<Vec<_>>();
let shift = |v: &[f64], o: i32, len: u32| v.iter().map(|g| g - f64::from(o)).filter(|g| *g > 0.0 && *g < f64::from(len)).collect::<Vec<_>>();
one.guides.vertical = shift(&doc.guides.vertical, r.x0, r.width());
one.guides.horizontal = shift(&doc.guides.horizontal, r.y0, r.height());
Ok(Some(one))

View File

@ -148,12 +148,12 @@ pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) {
let [m0, m1, m2, m3, tx, ty] = a.m;
let g = &mut doc.guides;
if m1.abs() < EPS && m2.abs() < EPS {
g.horizontal.iter_mut().for_each(|y| *y = (m3 * f64::from(*y) + ty) as f32);
g.vertical.iter_mut().for_each(|x| *x = (m0 * f64::from(*x) + tx) as f32);
g.horizontal.iter_mut().for_each(|y| *y = m3 * *y + ty);
g.vertical.iter_mut().for_each(|x| *x = m0 * *x + tx);
} else if m0.abs() < EPS && m3.abs() < EPS {
// A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa.
let v: Vec<f32> = g.horizontal.iter().map(|y| (m2 * f64::from(*y) + tx) as f32).collect();
let h: Vec<f32> = g.vertical.iter().map(|x| (m1 * f64::from(*x) + ty) as f32).collect();
let v: Vec<f64> = g.horizontal.iter().map(|y| m2 * y + tx).collect();
let h: Vec<f64> = g.vertical.iter().map(|x| m1 * x + ty).collect();
g.horizontal = h;
g.vertical = v;
} else {

View File

@ -146,7 +146,7 @@ fn turns_map_guides_paths_slices_and_live_shapes() {
// A vertical guide at x=10 becomes a horizontal one at y=10; a horizontal one at y=5 becomes
// a vertical one at x = H − 5.
assert_eq!(d.guides.horizontal, vec![10.0]);
assert_eq!(d.guides.vertical, vec![(H - 5) as f32]);
assert_eq!(d.guides.vertical, vec![f64::from(H - 5)]);
// Slice [1,2 10×6] → x' = H − y: [H−8, 1, 6×10].
let sl = &d.slices.list[0];
assert_eq!(sl.rect, Rect::new(H as i32 - 8, 1, H as i32 - 2, 11));

View File

@ -595,7 +595,7 @@ fn transform_again_params(s: &Session) -> Result<Value> {
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
let pos = p.get("position").and_then(Value::as_f64).map(|v| v as f32);
let pos = p.get("position").and_then(Value::as_f64);
let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
let label = match op {
"new" => "New Guide",
@ -1406,4 +1406,25 @@ mod tests {
assert!(s.active().unwrap().doc.guides.vertical.is_empty());
assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
}
/// Guides keep their exact place (#2918): 1 cm at 300 ppi through New Guide and .pcraft, and
/// a 1/32 px PSD position past 2^19 px, where f32 has no 1/32 steps left.
#[test]
fn guides_keep_sub_pixel_positions_through_pcraft_and_psd() {
let (cm, far) = (300.0 / 2.54, 600_000.031_25);
let mut s = session();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": cm})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": far})).unwrap();
fn wide(v: impl Into<f64>) -> f64 {
v.into()
}
let exact = |g: &photocraft_doc::Guides| (wide(g.horizontal[0]), wide(g.vertical[0]));
let doc = &s.active().unwrap().doc;
assert_eq!(exact(&doc.guides), (cm, far));
let pcraft = photocraft_format::save_to_bytes(doc, &Default::default()).unwrap();
assert_eq!(exact(&photocraft_format::load_from_bytes(&pcraft).unwrap().guides), (cm, far));
let psd = photocraft_io::export(doc, "psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("g.psd", &psd).unwrap().document.guides;
assert_eq!(wide(back.vertical[0]), far, "1032 stores 1/32 px");
}
}

View File

@ -1270,8 +1270,7 @@ fn color_lookup_tables(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- guides ----------
fn push_unique(v: &mut Vec<f32>, x: f64) {
let x = x as f32;
fn push_unique(v: &mut Vec<f64>, x: f64) {
if !v.iter().any(|g| (g - x).abs() < 0.01) {
v.push(x);
}
@ -1304,9 +1303,9 @@ fn clear_canvas_guides(s: &mut Session) -> Result<Value> {
// Guides inside an artboard belong to it (View › Clear Selected Artboard Guides); the rest
// are canvas guides. Without artboards every guide is a canvas guide.
let boards: Vec<Rect> = d.doc.artboards().iter().map(|b| b.2.rect).collect();
let in_x = move |x: f32| boards.iter().any(|r| f64::from(x) > f64::from(r.x0) && f64::from(x) < f64::from(r.x1));
let in_x = move |x: f64| boards.iter().any(|r| x > f64::from(r.x0) && x < f64::from(r.x1));
let boards_y: Vec<Rect> = d.doc.artboards().iter().map(|b| b.2.rect).collect();
let in_y = move |y: f32| boards_y.iter().any(|r| f64::from(y) > f64::from(r.y0) && f64::from(y) < f64::from(r.y1));
let in_y = move |y: f64| boards_y.iter().any(|r| y > f64::from(r.y0) && y < f64::from(r.y1));
let n = d.doc.guides.vertical.iter().filter(|x| !in_x(**x)).count() + d.doc.guides.horizontal.iter().filter(|y| !in_y(**y)).count();
if n == 0 {
return Ok(json!({"cleared": 0}));

View File

@ -67,8 +67,7 @@ fn targets(s: &Session, p: &Value) -> Result<Vec<Rect>> {
}
}
fn push_unique(out: &mut Vec<f32>, value: f64) -> Result<()> {
let value = value as f32;
fn push_unique(out: &mut Vec<f64>, value: f64) -> Result<()> {
if !value.is_finite() {
return Err(bad("guide coordinates exceed the supported range"));
}
@ -80,7 +79,7 @@ fn push_unique(out: &mut Vec<f32>, value: f64) -> Result<()> {
/// Guide positions for `count` columns (or rows) across `[start, end]`, Photoshop-style: both
/// edges of every column; a fixed `width` packs columns from `start` (or centres them).
fn axis(start: f64, end: f64, count: u64, fixed: f64, gap: f64, center: bool, out: &mut Vec<f32>) -> Result<()> {
fn axis(start: f64, end: f64, count: u64, fixed: f64, gap: f64, center: bool, out: &mut Vec<f64>) -> Result<()> {
if count == 0 {
return Ok(());
}
@ -158,17 +157,17 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
if clear {
if p.get("target").and_then(Value::as_str) == Some("selectedArtboards") {
// Guides are stored as document-wide axes, matching Clear Selected Artboard Guides.
guides.vertical.retain(|v| !bounds.iter().any(|r| f64::from(*v) >= f64::from(r.x0) && f64::from(*v) <= f64::from(r.x1)));
guides.horizontal.retain(|v| !bounds.iter().any(|r| f64::from(*v) >= f64::from(r.y0) && f64::from(*v) <= f64::from(r.y1)));
guides.vertical.retain(|v| !bounds.iter().any(|r| *v >= f64::from(r.x0) && *v <= f64::from(r.x1)));
guides.horizontal.retain(|v| !bounds.iter().any(|r| *v >= f64::from(r.y0) && *v <= f64::from(r.y1)));
} else {
guides = Guides::default();
}
}
for x in &added.vertical {
push_unique(&mut guides.vertical, f64::from(*x))?;
push_unique(&mut guides.vertical, *x)?;
}
for y in &added.horizontal {
push_unique(&mut guides.horizontal, f64::from(*y))?;
push_unique(&mut guides.horizontal, *y)?;
}
Ok(Layout { added, guides })
}

View File

@ -697,6 +697,10 @@ pub struct UnitsAndRulers {
pub print_resolution: f64,
pub screen_resolution: f64,
pub point_size: PointSize,
/// View › Rulers as last set: written when the user toggles the rulers and applied at
/// launch, so new and opened documents show them as Photoshop does. Set from the View
/// menu (⌘R), not from the Preferences dialog.
pub show_rulers: bool,
}
impl Default for UnitsAndRulers {
@ -709,6 +713,7 @@ impl Default for UnitsAndRulers {
print_resolution: 300.0,
screen_resolution: 72.0,
point_size: PointSize::PostScript,
show_rulers: false,
}
}
}

View File

@ -283,8 +283,8 @@ fn from_guides(s: &mut Session) -> Result<Value> {
let b = d.doc.bounds();
let mut xs: Vec<i32> = vec![b.x0, b.x1];
let mut ys: Vec<i32> = vec![b.y0, b.y1];
xs.extend(d.doc.guides.vertical.iter().map(|g| (*g as f64).round() as i32).filter(|x| *x > b.x0 && *x < b.x1));
ys.extend(d.doc.guides.horizontal.iter().map(|g| (*g as f64).round() as i32).filter(|y| *y > b.y0 && *y < b.y1));
xs.extend(d.doc.guides.vertical.iter().map(|g| g.round() as i32).filter(|x| *x > b.x0 && *x < b.x1));
ys.extend(d.doc.guides.horizontal.iter().map(|g| g.round() as i32).filter(|y| *y > b.y0 && *y < b.y1));
if xs.len() == 2 && ys.len() == 2 {
return Err(EngineError::Other("the document has no guides inside the canvas".into()));
}

View File

@ -107,10 +107,10 @@ impl SnapTargets {
}
if opts.guides {
for g in &doc.guides.vertical {
t.x.push(Target { pos: *g as f64, kind: SnapKind::Guide, span: (0.0, h) });
t.x.push(Target { pos: *g, kind: SnapKind::Guide, span: (0.0, h) });
}
for g in &doc.guides.horizontal {
t.y.push(Target { pos: *g as f64, kind: SnapKind::Guide, span: (0.0, w) });
t.y.push(Target { pos: *g, kind: SnapKind::Guide, span: (0.0, w) });
}
}
if opts.grid && opts.grid_step.iter().all(|step| step.is_finite() && *step > 0.0) {

View File

@ -105,8 +105,8 @@ pub fn write_artboard(a: &Artboard, guides: &[i32]) -> Vec<u8> {
/// Indices of the document guides (vertical first, then horizontal, as in resource 1032) that
/// lie inside `r`.
pub fn guides_in(guides: &Guides, r: Rect) -> Vec<i32> {
let v = guides.vertical.iter().map(|x| (*x as f64) > f64::from(r.x0) && (*x as f64) < f64::from(r.x1));
let h = guides.horizontal.iter().map(|y| (*y as f64) > f64::from(r.y0) && (*y as f64) < f64::from(r.y1));
let v = guides.vertical.iter().map(|x| *x > f64::from(r.x0) && *x < f64::from(r.x1));
let h = guides.horizontal.iter().map(|y| *y > f64::from(r.y0) && *y < f64::from(r.y1));
v.chain(h).enumerate().filter(|(_, inside)| *inside).map(|(i, _)| i as i32).collect()
}

View File

@ -528,7 +528,7 @@ fn parse_guides(data: &[u8], doc: &mut Document) {
for i in 0..count as usize {
let at = 16 + i * 5;
let (Some(loc), Some(&dir)) = (rd(at), data.get(at + 4)) else { break };
let pos = loc as i32 as f32 / 32.0;
let pos = f64::from(loc as i32) / 32.0;
if dir == 1 {
doc.guides.horizontal.push(pos);
} else {

View File

@ -2562,3 +2562,4 @@ cameraData Lens Objektiv
Snap to Origin Přichytit k počátku
Search settings… Hledat nastavení…
No matching settings Žádná odpovídající nastavení
Show rulers Zobrazit pravítka

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2593,3 +2593,4 @@ cameraData Lens Objektiv
Snap to Origin An Ursprung ausrichten
Search settings… Einstellungen durchsuchen…
No matching settings Keine passenden Einstellungen
Show rulers Lineale anzeigen

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3654,3 +3654,4 @@ cameraData Lens Φακός
Snap to Origin Προσκόλληση στην αρχή
Search settings… Αναζήτηση ρυθμίσεων…
No matching settings Δεν βρέθηκαν ρυθμίσεις
Show rulers Εμφάνιση χαράκων

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -2634,3 +2634,4 @@ cameraData Lens Objetivo
Snap to Origin Ajustar a origen
Search settings… Buscar ajustes…
No matching settings No hay ajustes que coincidan
Show rulers Mostrar reglas

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2971,3 +2971,4 @@ cameraData Lens Objectif
Snap to Origin Aligner sur l'origine
Search settings… Rechercher des réglages…
No matching settings Aucun réglage correspondant
Show rulers Afficher les règles

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2958,3 +2958,4 @@ cameraData Lens Lensa
Snap to Origin Jepret ke Titik Asal
Search settings… Cari pengaturan…
No matching settings Tidak ada pengaturan yang cocok
Show rulers Tampilkan Penggaris

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2625,3 +2625,4 @@ cameraData Lens Obiettivo
Snap to Origin Aggancia all'origine
Search settings… Cerca impostazioni…
No matching settings Nessuna impostazione corrispondente
Show rulers Mostra righelli

Can't render this file because it has a wrong number of fields in line 16.

View File

@ -3568,3 +3568,4 @@ cameraData Lens レンズ
Snap to Origin 原点にスナップ
Search settings… 設定を検索…
No matching settings 一致する設定はありません
Show rulers 定規を表示

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3137,3 +3137,4 @@ cameraData Lens 렌즈
Snap to Origin 원점에 스냅
Search settings… 설정 검색…
No matching settings 일치하는 설정 없음
Show rulers 눈금자 표시

Can't render this file because it has a wrong number of fields in line 11.

View File

@ -3714,6 +3714,7 @@ cameraData Lens Lens
Search settings… Instellingen zoeken…
No matching settings Geen overeenkomende instellingen
PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer
Show rulers Linialen tonen
Frame Tool Kadergereedschap
Create a new rectangular frame Nieuw rechthoekig kader maken
Create a new elliptical frame Nieuw ovaal kader maken

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -3569,3 +3569,4 @@ cameraData Lens Obiektyw
Snap to Origin Przyciągnij do początku
Search settings… Szukaj ustawień…
No matching settings Brak pasujących ustawień
Show rulers Pokaż linijki

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2978,3 +2978,4 @@ cameraData Lens Lente
Snap to Origin Ajustar à origem
Search settings… Pesquisar configurações…
No matching settings Nenhuma configuração encontrada
Show rulers Mostrar réguas

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2894,6 +2894,7 @@ cameraData Lens Объектив
Rasterize Layer Style Растрировать стиль слоя
Search settings… Поиск настроек…
No matching settings Нет подходящих настроек
Show rulers Показать линейки
New Crop Preset… Новый набор параметров кадрирования…
Delete Crop Preset… Удалить набор параметров кадрирования…
Select the crop presets to delete: Выберите наборы параметров кадрирования для удаления:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3644,6 +3644,7 @@ cameraData Lens Об'єктив
Search settings… Пошук налаштувань…
No matching settings Немає відповідних налаштувань
PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана
Show rulers Показати лінійки
Frame Tool Інструмент «Рамка»
Create a new rectangular frame Створити прямокутну рамку
Create a new elliptical frame Створити еліптичну рамку

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2563,6 +2563,7 @@ cameraData Lens 镜头
Snap to Origin 贴紧原点
Search settings… 搜索设置…
No matching settings 没有匹配的设置
Show rulers 显示标尺
New Crop Preset… 新建裁剪预设…
Delete Crop Preset… 删除裁剪预设…
Select the crop presets to delete: 选择要删除的裁剪预设:

Can't render this file because it has a wrong number of fields in line 15.

View File

@ -2533,6 +2533,7 @@ cameraData Lens 鏡頭
Snap to Origin 靠齊原點
Search settings… 搜尋設定…
No matching settings 沒有符合的設定
Show rulers 顯示尺標
New Crop Preset… 新增裁切預設集…
Delete Crop Preset… 刪除裁切預設集…
Select the crop presets to delete: 選取要刪除的裁切預設集:

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -502,7 +502,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
_ => &mut e.lock_guides,
};
*slot = !*slot;
Ok(json!(*slot))
let on = *slot;
if id == "view.rulers" {
crate::prefs_ui::remember_rulers(app, on);
}
Ok(json!(on))
}
"edit.freeTransform"
| "edit.transform.scale"

View File

@ -48,6 +48,8 @@ pub struct Runtime {
pub(crate) checker_key: Option<[[u8; 3]; 2]>,
/// Style last sent to the GPU canvas.
pub(crate) gpu_style: Option<crate::gpu_canvas::CanvasStyle>,
/// `unitsAndRulers.showRulers` as last applied to View › Rulers.
show_rulers: Option<bool>,
/// Tool options as last remembered in the preferences (see `tool_memory`).
pub(crate) tool_options: Option<crate::state::ToolOptions>,
}
@ -180,6 +182,24 @@ fn system_theme(app: &PhotocraftApp, ctx: &egui::Context) -> Option<egui::Theme>
app.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme())
}
/// View › Rulers follows `unitsAndRulers.showRulers` when the preference changes (at launch, from
/// `prefs.set` or a reset), so documents open with the rulers as last set.
fn sync_rulers(app: &mut PhotocraftApp) {
let want = app.session.prefs().units_and_rulers.show_rulers;
if app.prefs_rt.show_rulers != Some(want) {
app.ui.extras.rulers = want;
app.prefs_rt.show_rulers = Some(want);
}
}
/// View › Rulers was toggled: remember it for the next launch (Photoshop keeps the last state).
pub(crate) fn remember_rulers(app: &mut PhotocraftApp, on: bool) {
if app.session.prefs().units_and_rulers.show_rulers != on {
app.session.prefs.edit(|p| p.units_and_rulers.show_rulers = on);
}
app.prefs_rt.show_rulers = Some(on);
}
// ------------------------------------------------------------------ lifecycle
/// Load saved preferences once, when the app is created. Recovery starts during frame upkeep.
@ -195,6 +215,7 @@ pub fn load(app: &mut PhotocraftApp) {
}
crate::dock::restore(app);
crate::brush_picker::restore(app);
sync_rulers(app);
crate::tool_memory::restore(app);
app.sync_recent();
app.prefs_rt.saved_rev = app.session.prefs.rev();
@ -375,6 +396,7 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) {
load(app);
}
recovery(app);
sync_rulers(app);
sync_display_scale(app, ctx);
sync_appearance(app, ctx);
crate::theme::set_ui_font_size(ctx, app.session.prefs().interface.ui_font_size);
@ -1425,6 +1447,8 @@ fn section_fields(
// Settings nothing reads yet stay out of the dialog (issue #204); their stored values
// pass through untouched.
if (section == "interface" && matches!(k.as_str(), "theme" | "appearanceMode" | "darkTheme" | "lightTheme" | "favoriteThemes"))
// View › Rulers (⌘R) sets it, as in Photoshop.
|| path == "unitsAndRulers.showRulers"
|| prefs::is_hidden(&path)
|| (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode"))
|| (section == "export" && !export_field_visible(obj, &k))

View File

@ -177,7 +177,7 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
if drag.is_some_and(|d| d.vertical == vertical && d.index == Some(i)) {
continue;
}
line(vertical, *p as f64, guide);
line(vertical, *p, guide);
}
}
}
@ -196,12 +196,12 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> {
let doc = &app.session.active()?.doc;
let tol = 4.0 / app.point_zoom().max(0.01) as f64;
for (i, g) in doc.guides.vertical.iter().enumerate() {
if (*g as f64 - x).abs() <= tol {
if (*g - x).abs() <= tol {
return Some((true, i));
}
}
for (i, g) in doc.guides.horizontal.iter().enumerate() {
if (*g as f64 - y).abs() <= tol {
if (*g - y).abs() <= tol {
return Some((false, i));
}
}
@ -467,6 +467,37 @@ mod tests {
assert_eq!(app.session.prefs().units_and_rulers.rulers, Unit::Centimeters);
}
/// View › Rulers is remembered: the next launch, and every document created or opened in it,
/// shows the rulers as last set, on or off (#2918).
#[test]
fn rulers_open_as_last_set_after_a_restart() {
use std::{cell::RefCell, rc::Rc};
let saved = Rc::new(RefCell::new(None::<String>));
let launch = |saved: &Rc<RefCell<Option<String>>>| {
let (load, store) = (saved.clone(), saved.clone());
PhotocraftApp::new(
photocraft_engine::Session::new(),
crate::Services {
load_prefs: Some(Box::new(move || load.borrow().clone())),
save_prefs: Some(Box::new(move |text| {
*store.borrow_mut() = Some(text.to_string());
Ok(())
})),
..Default::default()
},
)
};
for on in [true, false] {
let mut app = launch(&saved);
assert_eq!(crate::menus::invoke(&mut app, &egui::Context::default(), "view.rulers", json!({})).unwrap(), json!(on));
crate::prefs_ui::save_preferences(&mut app).unwrap();
let mut next = launch(&saved);
next.session.execute("file.new", json!({"width": 20, "height": 10})).unwrap();
next.sync_views();
assert_eq!(next.ui.extras.rulers, on, "a new document after a restart shows the rulers as last set");
}
}
#[test]
fn guide_drags_create_move_and_delete() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());

View File

@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 6 | 12 | 9 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 25 | 16 | 2 | |
| [Workspace and UI](#workspace-and-ui) | 8 | 8 | 7 | settings that do nothing: 33 of 153 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 33 of 154 (target 0) |
| [Type](#type) | 1 | 4 | 4 | |
| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@ -176,7 +176,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 33 of 153** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 33 of 154** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
@ -201,7 +201,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout |
| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling |
| UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager |
| UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No rulers pref; doc lib.rs:523 guides are Vec<f32> |
| UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec<f64> through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) |
| UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder |
| UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | done | [#217](https://github.com/storytold/photocraft/issues/217) | stamp_cmds.rs layer.stampVisible (Cmd+Alt+Shift+E) merges every visible layer into a new layer above the active one and keeps the originals; layer.stampDown (Cmd+Alt+E) too. As in Photoshop they have no menu item of their own: Alt + Layer > Merge Visible / Merge Down / Merge Layers run them (stamp_cmds::alt_variant, menus.rs); stamp_tests.rs alt_click_on_merge_items_stamps and cmd_alt_shift_e_stamps_visible_and_can_be_rebound |
| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | layer.copyToDocument copies layers into another open document keeping names, content, masks, styles and blending, converting to the destination's profile and depth, as one history step (layer_copy_cmds.rs, #589). It is the command a drag between documents dispatches; there is still no copy/paste layer clipboard |

View File

@ -141,9 +141,9 @@ note = "hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_
[[item]]
id = "UI-217-20"
target = "'Show rulers in new documents' preference; guides stored as f64"
status = "missing"
status = "done"
issue = 217
note = "No rulers pref; doc lib.rs:523 guides are Vec<f32>"
note = "View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec<f64> through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd)"
[[item]]
id = "UI-217-21"