diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d0a96d23e6..7d5ac68c94 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3989,6 +3989,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View if let Some(lasso) = &d.lasso { pts.push(xf.to_screen(lasso.cursor[0] as f32, lasso.cursor[1] as f32)); } + // On pixel centres like the marquee's ants, so a 1 pt line stays crisp wherever the + // canvas sits (a pixel edge would blur it to grey). + let pts: Vec = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect(); crate::tool_feedback::draw_ants(painter, &pts, false); } Tool::Gradient => { diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index 4d2cdf5b7f..caa3b82203 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -148,7 +148,8 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &app.session.prefs().units_and_rulers, ); let mut pct = app.ui.views[i].zoom * 100.0; - if widgets::value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0).changed() { + let control_h = crate::panels::status_bar_heights(&t).1; + if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0, control_h).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); app.ui.views[i].fit_pending = false; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b84ada001..378b4d7687 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2177,6 +2177,8 @@ mod stamp_tests; #[cfg(test)] mod alt_click_tests; #[cfg(test)] +mod status_bar_tests; +#[cfg(test)] mod stroke_timing_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1b3d9353ed..87a1d58e97 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1479,12 +1479,19 @@ fn hint(ui: &mut egui::Ui, s: &str) { // ----------------------------------------------------------------------------- status bar +/// The status bar's height and the height of the controls in it (zoom field, Fit), which leaves +/// them an even margin above and below inside the bar (#2823). +pub fn status_bar_heights(t: &Tokens) -> (f32, f32) { + if t.pro { (24.0, 18.0) } else { (30.0, 22.0) } +} + pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("status_bar") - .exact_size(if t.pro { 24.0 } else { 30.0 }) + .exact_size(status_bar_heights(&t).0) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))) .show(ui, |ui| { + let control_h = status_bar_heights(&t).1; ui.horizontal_centered(|ui| { if t.pro { crate::chrome_ui::status_bar_pro(app, ui); @@ -1496,7 +1503,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (w, h, mode, bits, layers) = (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); let mut pct = app.ui.views[i].zoom * 100.0; - if widgets::value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 78.0).changed() { + if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 78.0, control_h).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); app.ui.views[i].fit_pending = false; } @@ -1517,7 +1524,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if app.session.active().is_some() && !t.pro - && widgets::secondary_button(ui, tl!("Fit"), 0.0).clicked() + && widgets::compact_button(ui, tl!("Fit"), control_h).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; diff --git a/crates/ui-egui/src/status_bar_tests.rs b/crates/ui-egui/src/status_bar_tests.rs new file mode 100644 index 0000000000..4e4f4a0db5 --- /dev/null +++ b/crates/ui-egui/src/status_bar_tests.rs @@ -0,0 +1,63 @@ +//! The status bar's zoom field and Fit button sit inside the bar with an even margin above and +//! below, in every theme (#2823). + +use egui::{Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::theme::{ThemeKind, Tokens}; + +const SIZE: egui::Vec2 = vec2(900.0, 200.0); + +fn bottom_id() -> egui::Id { + egui::Id::new("status-bar-test-bottom") +} + +fn harness(kind: ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1080, "height": 1080, "resolution": 72, "background": "white"})).unwrap(); + app.sync_views(); + let mut h = Harness::builder().with_size(SIZE).build_ui_state( + |ui, app: &mut PhotocraftApp| { + // The first frame runs before `setup_context` installs the theme's fonts. + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + // Where the bar goes: the bottom of the harness's (margined) root Ui. + let bottom = ui.max_rect().bottom(); + ui.ctx().data_mut(|d| d.insert_temp(bottom_id(), bottom)); + crate::panels::status_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + h +} + +#[test] +fn status_bar_controls_sit_inside_the_bar_with_even_margins() { + for kind in ThemeKind::ALL { + let h = harness(kind); + let t = Tokens::for_kind(kind); + let (bar_h, control_h) = crate::panels::status_bar_heights(&t); + let margin = (bar_h - control_h) / 2.0; + assert!(margin >= 3.0, "{kind:?}: margin {margin}"); + let bottom = h.ctx.data(|d| d.get_temp::(bottom_id())).unwrap(); + let bar = Rect::from_min_max(pos2(0.0, bottom - bar_h), pos2(SIZE.x, bottom)); + // Half a point of slack for egui's pixel rounding of the centred row. + let inner = bar.shrink2(vec2(margin, margin - 0.5)); + // The zoom number (its box is painted around it, `control_h` tall and centred). + let number = h.get_by_role(egui::accesskit::Role::SpinButton).rect(); + assert!(inner.contains_rect(number), "{kind:?}: bar {bar:?}, number {number:?}"); + // Pro (Photoshop) has no Fit button in the status bar. + if t.pro { + assert!(h.query_by_label("Fit").is_none(), "{kind:?}"); + continue; + } + let fit = h.get_by_label("Fit").rect(); + assert!(inner.contains_rect(fit), "{kind:?}: bar {bar:?}, Fit {fit:?}"); + assert!((fit.center().y - bar.center().y).abs() < 0.51, "{kind:?}: bar {bar:?}, Fit {fit:?}"); + assert!((fit.height() - control_h).abs() < 0.01, "{kind:?}: Fit {fit:?}"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a25140accf..500ff3526e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -240,6 +240,15 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive value_field_in(ui, value, range, suffix, width, 0.0, None).0 } +/// Height of a [`value_field`]'s box. +pub const VALUE_FIELD_H: f32 = 24.0; + +/// A [`value_field`] `height` points tall instead of [`VALUE_FIELD_H`], for slim bars such as +/// the status bar (#2823). +pub fn compact_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32, height: f32) -> Response { + value_field_box(ui, value, range, suffix, vec2(width, height), 0.0, None).0 +} + /// Decimal places for a size dialog's unit key (`px`, `in`, `cm`, `mm`, `pt`, `pica`, `percent`). /// The places themselves live on [`photocraft_engine::prefs::Unit`], so the dialogs, the Info /// panel and the rulers agree (#2434). @@ -273,16 +282,34 @@ fn value_field_in( width: f32, trailing: f32, decimals: Option, +) -> (Response, Rect) { + value_field_box(ui, value, range, suffix, vec2(width, VALUE_FIELD_H), trailing, decimals) +} + +/// [`value_field_in`] with a box of any `size`. +fn value_field_box( + ui: &mut Ui, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + size: Vec2, + trailing: f32, + decimals: Option, ) -> (Response, Rect) { let t = Tokens::get(ui.ctx()); - let (rect, slot) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); + let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); surface(ui, rect, t.field, false); if !t.bevel { ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let suffix_w = if suffix.is_empty() { 0.0 } else { 16.0 }; - // 1 pt higher than centred: digits have no descenders, so centred text looks low. - let field = Rect::from_min_max(rect.min + vec2(4.0, 1.0), rect.max - vec2(4.0 + suffix_w + trailing, 3.0)); + // 1 pt higher than centred: digits have no descenders, so centred text looks low. The slot + // is at least one text row tall, so a slim box's number isn't pushed out of it (#2823). + let font = theme::mono(12.0); + let row_h = ui.painter().layout_no_wrap("0".into(), font.clone(), t.text).size().y; + let slot_h = (rect.height() - 4.0).max(row_h).min(rect.height() - 2.0); + let cy = rect.center().y - 1.0; + let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. let fine = range.end() - range.start() <= 10.0; let (lo, hi) = (*range.start(), *range.end()); @@ -297,7 +324,12 @@ fn value_field_in( ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().override_font_id = Some(theme::mono(12.0)); + ui.style_mut().override_font_id = Some(font); + // The theme's interact height (24) is taller than the number's slot: the DragValue + // would grow past the slot's bottom and sit low, or spill out of a slim box (#2823). + // Its horizontal padding would push a long number out of the box's left edge. + ui.spacing_mut().interact_size.y = field.height(); + ui.spacing_mut().button_padding = Vec2::ZERO; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let layout = egui::Layout::centered_and_justified(ui.layout().main_dir()); ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals)).inner @@ -745,6 +777,12 @@ pub fn secondary_button(ui: &mut Ui, label: &str, min_width: f32) -> Response { button_impl(ui, label, min_width, t.field, t.text, false) } +/// A [`secondary_button`] `height` points tall, for slim bars such as the status bar (#2823). +pub fn compact_button(ui: &mut Ui, label: &str, height: f32) -> Response { + let t = Tokens::get(ui.ctx()); + button_impl_h(ui, label, 0.0, Some(height), t.field, t.text, false) +} + /// What a dialog button does, which decides where the platform's button order puts it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ButtonRole { @@ -823,19 +861,24 @@ pub fn dialog_buttons(ui: &mut Ui, buttons: &[DialogButton]) -> Option Vec2 { - button_layout(ui, label, min_width, Tokens::get(ui.ctx()).text).1 + button_layout(ui, label, min_width, None, Tokens::get(ui.ctx()).text).1 } -fn button_layout(ui: &Ui, label: &str, min_width: f32, fg: Color32) -> (std::sync::Arc, Vec2) { +fn button_layout(ui: &Ui, label: &str, min_width: f32, height: Option, fg: Color32) -> (std::sync::Arc, Vec2) { let galley = ui.painter().layout_no_wrap(tl!(label).to_owned(), theme::medium(13.0), fg); - let h = if Tokens::get(ui.ctx()).pro { 28.0 } else { 30.0 }; + let h = height.unwrap_or(if Tokens::get(ui.ctx()).pro { 28.0 } else { 30.0 }); let size = vec2((galley.size().x + 28.0).max(min_width), h); (galley, size) } fn button_impl(ui: &mut Ui, label: &str, min_width: f32, bg: Color32, fg: Color32, primary: bool) -> Response { + button_impl_h(ui, label, min_width, None, bg, fg, primary) +} + +/// [`button_impl`] with an explicit `height` (`None`: the theme's button height). +fn button_impl_h(ui: &mut Ui, label: &str, min_width: f32, height: Option, bg: Color32, fg: Color32, primary: bool) -> Response { let t = Tokens::get(ui.ctx()); - let (galley, size) = button_layout(ui, label, min_width, fg); + let (galley, size) = button_layout(ui, label, min_width, height, fg); let h = size.y; let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); // Painted text: name the button for accessibility (and so tests and agents can find it). @@ -1322,6 +1365,32 @@ mod tests { assert!((field.center().y - number.center().y - 1.0).abs() < 0.01, "field {field:?}, number {number:?}"); } + /// With the app's theme (its 24 pt interact height and 4 pt button padding), in a full-size + /// box and a slim status-bar box alike, the number stays inside the box, 1 pt above its centre, + /// and a long number doesn't spill out of the box's left edge (#2823). + #[test] + fn themed_value_field_number_sits_inside_its_box_in_every_size() { + for kind in crate::theme::ThemeKind::ALL { + for (w, h, v) in [(80.0, super::VALUE_FIELD_H, 50.0), (64.0, 18.0, 1600.5), (78.0, 22.0, 100.6)] { + let mut harness = Harness::new_ui_state( + move |ui, s: &mut (f32, egui::Rect)| { + let before = ui.cursor().min; + super::compact_value_field(ui, &mut s.0, 0.0..=3200.0, "%", w, h); + s.1 = egui::Rect::from_min_size(before, egui::vec2(w, h)); + }, + (v, egui::Rect::NOTHING), + ); + crate::PhotocraftApp::setup_context(&harness.ctx, kind); + harness.run(); + let number = harness.get_by_role(egui::accesskit::Role::SpinButton).rect(); + let field = harness.state().1; + let what = format!("{kind:?} {w}x{h}: field {field:?}, number {number:?}"); + assert!(field.contains_rect(number), "{what}"); + assert!((field.center().y - number.center().y - 1.0).abs() < 0.51, "{what}"); + } + } + } + #[test] fn arrow_keys_step_a_focused_field_by_one_and_shift_by_ten() { let mut h = focused(100.0, 0.0..=1000.0);