This commit is contained in:
parent
f8563856a3
commit
a0d49de047
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>)> =
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue