Menus: keep clear of the taskbar when Wayland hides the window position (#3737) (#3739)

* test(ui): scaffold reported menu viewport coverage (#3737)

* chore: opt FredApps into public profile credit

* Menus keep clear of the taskbar when the window position is unknown (Wayland) (#3737)

On Wayland winit can't report a window's position, so egui has no
inner_rect/outer_rect, only the monitor size. work_area::visible_part
then returned the whole window, and fit_window never maximized an
oversized window, so long menus (Layer, View, Window...) were sized to
the full window and ran under a bottom panel. Without a position, cut
the window to the monitor size less the taskbar (the most that can be
visible wherever the window is), and use the content size as the
outer size for fit_window.

Replaces the scaffold test with one that fails on main.

---------

Signed-off-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
FredApps 2026-10-12 10:33:54 +02:00 committed by GitHub
parent 986f977a01
commit 62f4b9d453
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 101 additions and 7 deletions

View File

@ -114,3 +114,6 @@ sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true

View File

@ -11,6 +11,52 @@ const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.
/// The tallest menus.
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
/// #3737: on Wayland egui knows the monitor's size but never where the window is (no
/// `inner_rect`). A window taller than the monitor less its taskbar or panel runs under it, so
/// Layer, View and Window (and every other long menu) must end above it and scroll to their last
/// row, at 1× and 1.5×.
#[test]
fn reported_menus_stay_above_the_panel_when_the_window_position_is_unknown() {
for scale in [1.0, 1.5] {
for top in ["Layer", "View", "Window", "Filter", "Image", "Edit"] {
let mut h = harness((1366.0 * scale, 740.0 * 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 = None;
v.outer_rect = None;
v.maximized = Some(false);
h.run_steps(3);
let bottom = 768.0 - crate::work_area::TASKBAR;
let what = format!("{scale}x {top}");
open(&mut h, top);
for r in popups(&h) {
assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})");
}
check_bar_clear(&h, &what);
let last = rows(&h, 0).len() - 1;
let in_view = |h: &Harness<'static, PhotocraftApp>| {
let nav = Nav::current(&h.ctx);
nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5
};
let view = Nav::current(&h.ctx).views[0];
h.hover_at(view.center());
for _ in 0..80 {
if in_view(&h) {
break;
}
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(2);
}
assert!(in_view(&h), "{what}: the wheel reaches the last row, above the panel");
}
}
}
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();

View File

@ -10,7 +10,7 @@
//! [`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};
use egui::{Rect, Vec2, pos2, vec2};
/// 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).
@ -22,9 +22,19 @@ const MIN_VISIBLE: f32 = 160.0;
/// `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 {
let Some(m) = monitor else { return content };
if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 {
return content;
}
// #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has
// no `inner_rect` there, only the monitor's size. A window bigger than the monitor less the
// taskbar still runs under it wherever it is: at best its content starts at the monitor's top,
// so at most that much of it can be seen.
let Some(inner) = inner else {
let room = vec2(m.x, m.y - TASKBAR);
return trusted(content, Rect::from_min_size(content.min, room));
};
if !inner.min.is_finite() || !inner.max.is_finite() {
return content;
}
// Only the monitor's size is known, not where it is on the desktop: assume monitors tile the
@ -36,7 +46,12 @@ pub fn visible_part(content: Rect, monitor: Option<Vec2>, inner: Option<Rect>, f
// Desktop → window coordinates.
let shift = content.min - inner.min;
let local = usable.translate(shift);
let visible = content.intersect(local);
trusted(content, local)
}
/// `content` cut to `usable`, unless that leaves too little to be a sensible guess.
fn trusted(content: Rect, usable: Rect) -> Rect {
let visible = content.intersect(usable);
if !visible.is_positive() || visible.height() < MIN_VISIBLE.min(content.height()) || visible.width() < MIN_VISIBLE.min(content.width()) {
return content;
}
@ -68,11 +83,15 @@ pub fn fit_window(ctx: &egui::Context) -> bool {
if ctx.data(|d| d.get_temp::<bool>(fitted_id())).is_some() {
return false;
}
let content = ctx.content_rect();
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 };
// On Wayland the window's position, and so its outer rect, is never known (#3737): its content
// size is the least the window takes up.
let outer = outer.unwrap_or(content);
let Some(m) = monitor else { return false };
// macOS keeps new windows inside the screen's visible frame itself.
if cfg!(target_os = "macos") {
return false;
@ -117,7 +136,6 @@ mod tests {
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);
@ -126,6 +144,20 @@ mod tests {
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content);
}
#[test]
fn an_unknown_window_position_still_keeps_clear_of_the_taskbar() {
// #3737, Wayland: only the monitor's size is known. A window taller than the monitor less
// the taskbar can be seen at most down to there, wherever it is.
let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0));
assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0));
assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
// One that fits, or a maximized one, is all visible.
let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0));
assert_eq!(visible_part(small, Some(MONITOR), None, false), small);
assert_eq!(visible_part(content, Some(MONITOR), None, true), 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) {
@ -158,6 +190,19 @@ mod tests {
);
let ctx = egui::Context::default();
assert!(!frame(&ctx, Some(MONITOR), big, Some(true)).contains(&egui::ViewportCommand::Maximized(true)));
// Wayland: no outer rect, but a content area bigger than the monitor is maximized too (#3737).
let ctx = egui::Context::default();
let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0))), ..Default::default() };
if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) {
v.monitor_size = Some(MONITOR);
v.maximized = Some(false);
}
let mut out = ctx.run_ui(input, |ui| {
fit_window(ui.ctx());
});
out.textures_delta.clear();
let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default();
assert_eq!(cmds.contains(&egui::ViewportCommand::Maximized(true)), !cfg!(target_os = "macos"));
assert!(fits(vec2(1366.0, 720.0), MONITOR) && !fits(vec2(1366.0, 740.0), MONITOR) && !fits(vec2(1440.0, 700.0), MONITOR));
}
}