The Fit button was a full-size 30 pt button in the 30 pt Studio bar and the zoom field a 24 pt box in the 24 pt Pro bar, so both touched or overflowed the bar's edges. Inside every value field the theme's 24 pt interact height and 4 pt button padding made the number taller than its slot, so it sat low and, in a narrow box, spilled out of the left edge. The status bar's controls now take a compact height (22 pt in Studio, 18 pt in Pro) from status_bar_heights, leaving even margins, through new compact_value_field and compact_button widgets. A value field's number slot is at least one text row tall and the number widget is sized to it, so the digits sit 1 pt above the box's centre as intended. With the bar at its true height the canvas shifted by 1 pt, which showed that the live lasso ants were not on pixel centres; they are now snapped like the marquee's. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e90c81e3c
commit
167ea87ef1
|
|
@ -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<Pos2> = pts.into_iter().map(|p| p.round() + vec2(0.5, 0.5)).collect();
|
||||
crate::tool_feedback::draw_ants(painter, &pts, false);
|
||||
}
|
||||
Tool::Gradient => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::<f32>(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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f32>, 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<u32>,
|
||||
) -> (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<f32>,
|
||||
suffix: &str,
|
||||
size: Vec2,
|
||||
trailing: f32,
|
||||
decimals: Option<u32>,
|
||||
) -> (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<ButtonRol
|
|||
|
||||
/// The size of a primary or secondary button: its label plus padding, at least `min_width` wide.
|
||||
fn button_size(ui: &Ui, label: &str, min_width: f32) -> 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<egui::Galley>, Vec2) {
|
||||
fn button_layout(ui: &Ui, label: &str, min_width: f32, height: Option<f32>, fg: Color32) -> (std::sync::Arc<egui::Galley>, 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<f32>, 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue