Menus stay above the taskbar; oversized windows open maximized (#315) (#343) (#343)

This commit is contained in:
Brandon Thomas 2026-10-06 11:30:10 -04:00 committed by GitHub
parent f8563856a3
commit a0d49de047
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 306 additions and 5 deletions

View File

@ -8,6 +8,9 @@
//!
//! `--safe-gpu` draws the canvas on the CPU path, like the app's `--safe-gpu` launch.
//!
//! `--monitor 1366x768 --window-top 31` simulates the display the window is on (in points) and
//! where its content starts on it, e.g. a window running under a Windows taskbar.
//!
//! `--script` is a JSON array of `[method, params]` control-protocol calls (see
//! docs/control-protocol.md), applied in order with a few frames between them.
@ -63,6 +66,13 @@ fn main() {
}
app
});
if let Some((mw, mh)) = arg(&args, "--monitor").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse::<f32>().ok()?, b.parse::<f32>().ok()?)))) {
let top: f32 = arg(&args, "--window-top").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let v = harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default();
v.monitor_size = Some(egui::vec2(mw, mh));
v.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, top), egui::vec2(w, h)));
v.maximized = Some(false);
}
harness.run_steps(4);
let ctx = harness.ctx.clone();
let timing = std::env::var_os("SNAPSHOT_TIMING").is_some();

View File

@ -114,6 +114,7 @@ pub mod vector_ui;
pub mod view_cmds;
pub mod wide_angle_ui;
pub mod widgets;
pub mod work_area;
pub mod workspace_ui;
pub mod zoom_tool;
@ -801,6 +802,9 @@ impl eframe::App for PhotocraftApp {
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
prefs_ui::tick(self, ctx);
// A window bigger than its display (1440 × 900 on 1366 × 768) runs under the taskbar:
// maximize it into the work area once (#315).
work_area::fit_window(ctx);
discard_ui::guard_window_close(self, ctx);
shortcuts::handle(self, ctx);
let arrived: Vec<(String, Vec<u8>)> =

View File

@ -271,12 +271,27 @@ fn pointer_on(ui: &Ui, rect: Rect) -> bool {
/// scrolls instead of egui sliding it up over the menu bar, where it hid the menu titles.
/// `anchor` is the submenu's row (unknown on its first frame); `frame` the popup's margins.
pub fn level_room(screen: Rect, bar_bottom: Option<f32>, depth: usize, anchor: Option<Rect>, frame: f32) -> f32 {
let top = bar_bottom.unwrap_or(screen.top()).max(screen.top()) + EDGE;
let bottom = screen.bottom() - EDGE;
level_room_in(screen, screen, bar_bottom, depth, anchor, None, frame)
}
/// [`level_room`] when only `visible`, a part of the window's content rect `screen`, can be seen
/// (#315: a window taller than its display runs under the taskbar). egui still places popups
/// within the whole window, so a submenu opens upward only when downward doesn't fit the window.
/// `rows` is the level's rows' height when known (the popup shrinks to it).
pub fn level_room_in(screen: Rect, visible: Rect, bar_bottom: Option<f32>, depth: usize, anchor: Option<Rect>, rows: Option<f32>, frame: f32) -> f32 {
let top = bar_bottom.unwrap_or(visible.top()).max(visible.top()) + EDGE;
let bottom = visible.bottom().min(screen.bottom()) - EDGE;
let below_bar = bottom - top;
let room = match anchor.filter(|_| depth > 1) {
// Downward from the row, or upward from it, whichever leaves more room.
Some(row) => (bottom - row.top()).max(row.bottom() - top).min(below_bar),
Some(row) => {
// Downward from the row, or upward from it, whichever leaves more room. egui opens a
// submenu upward only when the downward one doesn't fit the window (it hangs from the
// row's top less half the frame margin).
let (down, up) = (bottom - row.top(), row.bottom() - top);
let opens_up = |h: f32| row.top() - (frame - 2.0) / 2.0 + h + frame > screen.bottom();
let want = rows.unwrap_or(f32::INFINITY);
if want > down && up > down && opens_up(want.min(up)) { up } else { down }.min(below_bar)
}
None => below_bar,
} - frame;
if room.is_finite() { room.max(4.0 * ARROW) } else { 4.0 * ARROW }
@ -289,13 +304,15 @@ pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui
let level = depth.saturating_sub(1);
nav.begin_level(level, find_menu_root(ui).id);
let screen = ctx.content_rect();
// Only the part of the window on its monitor, clear of the taskbar, can be seen (#315).
let visible = crate::work_area::visible_rect(&ctx);
// The menu frame's margin and stroke around the rows.
let frame = ui.spacing().menu_margin.sum().y + 2.0;
let anchor = nav.anchors.get(level).copied().flatten();
let room = level_room(screen, nav.bar_bottom, depth, anchor, frame);
let key = ui.id().with(("pc-menu-level", depth));
// Rows' height from the last frame: does this level overflow?
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
let room = level_room_in(screen, visible, nav.bar_bottom, depth, anchor, content, frame);
let over = content.is_some_and(|h| h > room + 0.5);
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
let height = if over { room - 2.0 * ARROW } else { room };

View File

@ -410,3 +410,110 @@ fn level_room_keeps_every_level_below_the_bar() {
assert_eq!(level_room(screen, Some(f32::NAN), 1, None, frame), level_room(screen, None, 1, None, frame));
assert_eq!(level_room(egui::Rect::NOTHING, bar, 2, Some(row), frame), 4.0 * ARROW);
}
/// #315: a 1366 × 768 pt display with a Windows taskbar. `window` is the window's content size and
/// `top` where its content starts on the desktop (below the title bar), in points; the taskbar
/// covers the monitor's bottom [`crate::work_area::TASKBAR`] points. `scale` is the UI scale.
fn taskbar_harness(window: (f32, f32), top: f32, scale: f32) -> Harness<'static, PhotocraftApp> {
let mut h = harness((window.0 * scale, window.1 * scale, scale));
let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default();
v.monitor_size = Some(egui::vec2(1366.0, 768.0));
v.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, top), egui::vec2(window.0, window.1)));
v.maximized = Some(false);
h.run_steps(3);
h
}
/// The visible bottom of a taskbar harness: the monitor's bottom less the taskbar, in the window.
fn taskbar_bottom(h: &Harness<'static, PhotocraftApp>) -> f32 {
crate::work_area::visible_rect(&h.ctx).bottom()
}
#[test]
fn menus_stay_above_the_taskbar_and_scroll_to_their_last_row() {
// The default 1440 × 900 window on a 1366 × 768 display runs past the screen; a 1366 × 737
// window below a 31 pt title bar reaches under the taskbar. At 1× and 2× (a 2732 × 1536 px
// display at 200%).
let mut scrolled = 0;
for (window, top, scale) in [((1440.0, 900.0), 0.0, 1.0), ((1366.0, 737.0), 31.0, 1.0), ((1366.0, 737.0), 31.0, 2.0)] {
for menu in LONGEST {
let mut h = taskbar_harness(window, top, scale);
let bottom = taskbar_bottom(&h);
let what = format!("{window:?}@{scale}x {menu}");
assert!((bottom - (768.0 - crate::work_area::TASKBAR - top)).abs() < 1.0, "{what}: visible bottom {bottom}");
open(&mut h, menu);
for r in popups(&h) {
assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the taskbar (from {bottom})");
}
let last = rows(&h, 0).len() - 1;
let in_view = |h: &Harness<'static, PhotocraftApp>, i: usize| {
let nav = Nav::current(&h.ctx);
nav.views[0].expand(0.5).contains_rect(nav.rows[0][i].rect) && nav.views[0].bottom() <= bottom + 0.5
};
// A menu that fits above the taskbar needs no scrolling.
let Some(down) = h.query_by_label("Scroll menu down").map(|n| n.rect()) else {
assert!(in_view(&h, last), "{what}: fits, so its last row is in view");
continue;
};
scrolled += 1;
// The ▼ arrow is above the taskbar, and resting on it reaches the last row.
assert!(down.bottom() <= bottom, "{what}: ▼ at {down:?}");
assert!(!in_view(&h, last));
h.hover_at(down.center());
for _ in 0..400 {
h.run_steps(1);
if in_view(&h, last) {
break;
}
}
assert!(in_view(&h, last), "{what}: ▼ scrolls to the last row, above the taskbar");
// And the wheel scrolls back up.
let view = Nav::current(&h.ctx).views[0];
h.hover_at(view.center());
for _ in 0..60 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(1);
if in_view(&h, 0) {
break;
}
}
assert!(in_view(&h, 0), "{what}: the wheel scrolls back to the first row");
}
}
assert!(scrolled >= 6, "the long menus overflow above the taskbar: {scrolled}");
}
#[test]
fn submenus_stay_above_the_taskbar() {
for menu in ["Image", "Layer", "Filter"] {
let mut h = taskbar_harness((1440.0, 900.0), 0.0, 1.0);
let bottom = taskbar_bottom(&h);
open(&mut h, menu);
let view = Nav::current(&h.ctx).views.first().copied().unwrap_or(egui::Rect::NOTHING);
let subs: Vec<egui::Rect> = rows(&h, 0).iter().filter(|r| r.enabled && r.command.is_none() && view.contains_rect(r.rect)).map(|r| r.rect).collect();
assert!(!subs.is_empty());
for (i, row) in subs.iter().enumerate() {
h.hover_at(row.center() + egui::vec2(-20.0, 0.0));
h.run_steps(6);
for r in popups(&h) {
assert!(r.bottom() <= bottom + 0.5, "{menu} submenu {i}: popup {r:?} runs under the taskbar (from {bottom})");
}
check_bar_clear(&h, &format!("{menu} submenu {i}"));
}
}
}
#[test]
fn a_maximized_window_or_an_unknown_monitor_uses_the_whole_window() {
let mut h = taskbar_harness((1366.0, 728.0), 0.0, 1.0);
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true);
h.run_steps(2);
assert_eq!(crate::work_area::visible_rect(&h.ctx), h.ctx.content_rect());
let h = harness((1366.0, 768.0, 1.0));
assert_eq!(crate::work_area::visible_rect(&h.ctx), h.ctx.content_rect());
}

View File

@ -0,0 +1,163 @@
//! The part of the window the user can actually see and reach (#315).
//!
//! A window can be larger than its display: PhotoCraft opens at 1440 × 900 pt, which runs past
//! the bottom of a 1366 × 768 screen and under the Windows taskbar. egui sizes and places menus
//! by the window's content rect, so their last items (and their ▼ scroll arrow) were out of
//! reach. egui doesn't expose the monitor's work area (the screen minus the taskbar or Dock), only
//! the monitor's size and the window's position on the desktop: the visible part is the content
//! rect cut to the monitor, keeping a taskbar-sized margin clear at the monitor's bottom.
//!
//! [`fit_window`] also fixes the cause at startup: a window that doesn't fit its monitor is
//! maximized, which puts it in the work area (Photoshop opens maximized on small displays).
use egui::{Rect, Vec2, pos2};
/// Room kept free at the monitor's bottom edge for a taskbar or Dock egui can't see (the Windows
/// taskbar is 40–48 pt tall; a bottom Dock about as much).
pub const TASKBAR: f32 = 48.0;
/// Below this, the guess about where the monitor is went wrong: use the whole window.
const MIN_VISIBLE: f32 = 160.0;
/// Where the window's content can be seen, in egui points. `monitor` is the monitor's size,
/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen`
/// is true for a maximized or full-screen window (already inside the work area).
pub fn visible_part(content: Rect, monitor: Option<Vec2>, inner: Option<Rect>, fills_screen: bool) -> Rect {
let (Some(m), Some(inner)) = (monitor, inner) else { return content };
let finite = m.x.is_finite() && m.y.is_finite() && inner.min.is_finite() && inner.max.is_finite();
if fills_screen || !finite || m.x < 1.0 || m.y < 1.0 {
return content;
}
// Only the monitor's size is known, not where it is on the desktop: assume monitors tile the
// desktop by their size (true for one monitor, and for side-by-side ones of the same size).
let c = inner.center();
let origin = pos2((c.x / m.x).floor() * m.x, (c.y / m.y).floor() * m.y);
let screen = Rect::from_min_size(origin, m);
let usable = Rect::from_min_max(screen.min, pos2(screen.max.x, screen.max.y - TASKBAR));
// Desktop → window coordinates.
let shift = content.min - inner.min;
let local = usable.translate(shift);
let visible = content.intersect(local);
if !visible.is_positive() || visible.height() < MIN_VISIBLE.min(content.height()) || visible.width() < MIN_VISIBLE.min(content.width()) {
return content;
}
visible
}
/// [`visible_part`] for this frame's viewport.
pub fn visible_rect(ctx: &egui::Context) -> Rect {
let content = ctx.content_rect();
ctx.input(|i| {
let v = i.viewport();
let fills = v.maximized == Some(true) || v.fullscreen == Some(true);
visible_part(content, v.monitor_size, v.inner_rect, fills)
})
}
/// Does a window of `outer` size fit on a `monitor` (less the taskbar)?
pub fn fits(outer: Vec2, monitor: Vec2) -> bool {
outer.x <= monitor.x + 0.5 && outer.y <= monitor.y - TASKBAR + 0.5
}
fn fitted_id() -> egui::Id {
egui::Id::new("pc-window-fitted")
}
/// Once, as soon as the monitor is known: maximize a window that is bigger than its monitor
/// (it ran under the taskbar and off-screen). Returns true when it asked to.
pub fn fit_window(ctx: &egui::Context) -> bool {
if ctx.data(|d| d.get_temp::<bool>(fitted_id())).is_some() {
return false;
}
let (monitor, outer, maximized, fullscreen) = ctx.input(|i| {
let v = i.viewport();
(v.monitor_size, v.outer_rect, v.maximized, v.fullscreen)
});
let (Some(m), Some(outer)) = (monitor, outer) else { return false };
// macOS keeps new windows inside the screen's visible frame itself.
if cfg!(target_os = "macos") {
return false;
}
ctx.data_mut(|d| d.insert_temp(fitted_id(), true));
if maximized == Some(true) || fullscreen == Some(true) || !m.is_finite() || !outer.is_finite() || fits(outer.size(), m) {
return false;
}
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
true
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Rect, pos2, vec2};
const MONITOR: Vec2 = vec2(1366.0, 768.0);
fn window(x: f32, y: f32, w: f32, h: f32) -> (Rect, Rect) {
(Rect::from_min_size(pos2(0.0, 0.0), vec2(w, h)), Rect::from_min_size(pos2(x, y), vec2(w, h)))
}
#[test]
fn a_window_taller_than_its_display_is_cut_at_the_taskbar() {
// 1440 × 900 at the desktop origin on 1366 × 768: right and bottom are off-screen.
let (content, inner) = window(0.0, 0.0, 1440.0, 900.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
// Below a 31 pt title bar, a full-height window reaches under the taskbar.
let (content, inner) = window(0.0, 31.0, 1366.0, 737.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR - 31.0);
// A small window clear of the taskbar is all visible.
let (content, inner) = window(100.0, 80.0, 800.0, 500.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content);
// On a second monitor to the right, the same.
let (content, inner) = window(1366.0 + 100.0, 0.0, 1440.0, 900.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR);
}
#[test]
fn maximized_unknown_or_nonsense_input_keeps_the_window() {
let (content, inner) = window(0.0, 0.0, 1440.0, 900.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area");
assert_eq!(visible_part(content, None, Some(inner), false), content);
assert_eq!(visible_part(content, Some(MONITOR), None, false), content);
assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content);
assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content);
assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content);
// A guess that would leave almost nothing visible is not trusted.
let (content, inner) = window(0.0, 600.0, 1000.0, 300.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content);
}
fn frame(ctx: &egui::Context, monitor: Option<Vec2>, outer: Option<Rect>, maximized: Option<bool>) -> Vec<egui::ViewportCommand> {
let mut input = egui::RawInput::default();
if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) {
v.monitor_size = monitor;
v.outer_rect = outer;
v.maximized = maximized;
}
let mut out = ctx.run_ui(input, |ui| {
fit_window(ui.ctx());
});
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
}
#[test]
fn a_window_too_big_for_its_display_is_maximized_once() {
let big = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1456.0, 939.0)));
let ctx = egui::Context::default();
// The monitor isn't known on the first frames: wait.
assert!(!frame(&ctx, None, big, Some(false)).contains(&egui::ViewportCommand::Maximized(true)));
let maximize = frame(&ctx, Some(MONITOR), big, Some(false)).contains(&egui::ViewportCommand::Maximized(true));
assert_eq!(maximize, !cfg!(target_os = "macos"));
// Once only: un-maximizing it later is the user's choice.
assert!(!frame(&ctx, Some(MONITOR), big, Some(false)).contains(&egui::ViewportCommand::Maximized(true)));
// A window that fits, or one already maximized, is left alone.
let ctx = egui::Context::default();
assert!(
!frame(&ctx, Some(MONITOR), Some(Rect::from_min_size(pos2(40.0, 40.0), vec2(1200.0, 700.0))), Some(false))
.contains(&egui::ViewportCommand::Maximized(true))
);
let ctx = egui::Context::default();
assert!(!frame(&ctx, Some(MONITOR), big, Some(true)).contains(&egui::ViewportCommand::Maximized(true)));
assert!(fits(vec2(1366.0, 720.0), MONITOR) && !fits(vec2(1366.0, 740.0), MONITOR) && !fits(vec2(1440.0, 700.0), MONITOR));
}
}