mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
window: cascade new windows on the real monitor
egui reports a monitor's size but not its place, so Viewports guessed that monitors tile the desktop. A maximized window's corner is a few points outside its monitor on Windows, which took a monitor up and to the left that doesn't exist, and every window cascaded from it opened off screen. The guess is also wrong whenever monitors differ in size or sit at other offsets. Add Viewports::set_monitors: the app lists the monitors in physical pixels and the driver picks the one showing most of the previous window, at that window's scale. For apps that don't list them yet, the guess now allows for a maximized window's frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c39e435cc3
commit
1702fe5782
@@ -99,6 +99,7 @@ window bookkeeping. The core has no egui dependency.
|
||||
| `WindowList<T>` | Open windows with the app's per-window data `T`. Opening order (stable, for menus) and most-recent order; the first in most-recent order is the active window, which menu commands go to. `close` returns the data and, when the active window closed, the window to focus next. `find` returns the most recent window whose data matches, e.g. the window already showing a file. |
|
||||
| `WindowId` | A window's id: `Copy`, `Hash`, `Ord`, from one process-wide counter, so never reused while the app runs. Use it as the key for per-window state (UI state, pending dialogs). |
|
||||
| `cascade` | Places a new window one step (about a title bar, `CASCADE_STEP`) right of and below the most recent window. Each axis that would run off the bounds (monitor or work area) starts over at the bounds' edge. |
|
||||
| `monitor_of` | The monitor a window is on: the one showing most of it or, when none shows it, the nearest. |
|
||||
| `WindowMenu` | The Window menu (macOS) or View › Switch Windows list: (id, title, dirty, active) per window, in opening order. Windows on the same document are titled `Name:1`, `Name:2`, numbered by opening order; a document with one window keeps its plain name. The app supplies each window's document key, name and dirty flag. |
|
||||
|
||||
With the `egui` feature, `Viewports` shows the windows as egui immediate viewports:
|
||||
@@ -109,6 +110,11 @@ With the `egui` feature, `Viewports` shows the windows as egui immediate viewpor
|
||||
- New windows get `decorate` (the frame decision above) and a cascade position from the most
|
||||
recently focused window. On Wayland, where windows have no known position, the compositor places
|
||||
them.
|
||||
- Give the driver the monitors with `set_monitors`, in physical pixels from `available_monitors()`:
|
||||
egui reports only a monitor's size, so without them the driver guesses that monitors tile the
|
||||
desktop, which is wrong when monitors differ in size or sit at other offsets. It asks only when
|
||||
it places a new window. A window keeps to the monitor of the window before it, at any size,
|
||||
place or scale; `set_bounds` overrides that with an area the app chooses.
|
||||
- The root viewport stays the app's. `set_root(Some(id))` draws window `id` in it. When the root's
|
||||
window closes while others stay open, `adopt` moves another window into the root viewport (its
|
||||
position, size and focus); that window's id and data stay the same.
|
||||
|
||||
@@ -138,7 +138,7 @@ mod viewports;
|
||||
|
||||
pub use list::{Closed, WindowId, WindowList};
|
||||
pub use menu::{Document, MenuEntry, WindowMenu};
|
||||
pub use place::{CASCADE_STEP, Rect, cascade};
|
||||
pub use place::{CASCADE_STEP, Rect, cascade, monitor_of};
|
||||
#[cfg(feature = "egui")]
|
||||
pub use viewports::{Viewports, WindowEvent, child_viewport_id};
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ impl Rect {
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.y + self.height
|
||||
}
|
||||
|
||||
/// The rectangle with its position and size times `factor`: from points to physical pixels
|
||||
/// and back.
|
||||
pub fn scaled(&self, factor: f32) -> Self {
|
||||
Self::new(self.x * factor, self.y * factor, self.width * factor, self.height * factor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "egui")]
|
||||
@@ -54,12 +60,62 @@ pub fn cascade(previous: Rect, size: [f32; 2], bounds: Rect, step: f32) -> Rect
|
||||
Rect::new(axis(previous.x, size[0], bounds.x, bounds.right()), axis(previous.y, size[1], bounds.y, bounds.bottom()), size[0], size[1])
|
||||
}
|
||||
|
||||
/// The monitor `window` is on: the one showing most of it or, when none shows it, the nearest.
|
||||
/// By area, not by a corner: on Windows a maximized window's corner is outside its monitor. `None`
|
||||
/// only when there are no monitors.
|
||||
pub fn monitor_of(window: Rect, monitors: &[Rect]) -> Option<Rect> {
|
||||
let shown = |m: &Rect| {
|
||||
let width = window.right().min(m.right()) - window.x.max(m.x);
|
||||
let height = window.bottom().min(m.bottom()) - window.y.max(m.y);
|
||||
width.max(0.0) * height.max(0.0)
|
||||
};
|
||||
let gap = |m: &Rect| {
|
||||
let across = (m.x - window.right()).max(window.x - m.right()).max(0.0);
|
||||
let down = (m.y - window.bottom()).max(window.y - m.bottom()).max(0.0);
|
||||
across * across + down * down
|
||||
};
|
||||
monitors.iter().copied().max_by(|a, b| shown(a).total_cmp(&shown(b)).then_with(|| gap(b).total_cmp(&gap(a))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SCREEN: Rect = Rect::new(0.0, 0.0, 1000.0, 800.0);
|
||||
|
||||
#[test]
|
||||
fn a_window_is_on_the_monitor_showing_most_of_it() {
|
||||
let left = Rect::new(-1920.0, 200.0, 1920.0, 1080.0);
|
||||
let main = Rect::new(0.0, 0.0, 3840.0, 1600.0);
|
||||
let below = Rect::new(718.0, 1600.0, 1280.0, 720.0);
|
||||
let desktop = [left, main, below];
|
||||
assert_eq!(monitor_of(Rect::new(-8.0, -8.0, 3856.0, 1576.0), &desktop), Some(main), "maximized, its corner outside");
|
||||
assert_eq!(monitor_of(Rect::new(-1928.0, 192.0, 1936.0, 1056.0), &desktop), Some(left), "maximized, on the left");
|
||||
assert_eq!(monitor_of(Rect::new(800.0, 1700.0, 400.0, 300.0), &desktop), Some(below), "offset and smaller");
|
||||
assert_eq!(monitor_of(Rect::new(-100.0, 300.0, 1000.0, 500.0), &desktop), Some(main), "across two, mostly on one");
|
||||
assert_eq!(monitor_of(Rect::new(3400.0, 100.0, 800.0, 600.0), &[main]), Some(main), "past the right edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_no_monitor_shows_goes_to_the_nearest() {
|
||||
let main = Rect::new(0.0, 0.0, 1920.0, 1080.0);
|
||||
let right = Rect::new(1920.0, 0.0, 1920.0, 1080.0);
|
||||
assert_eq!(monitor_of(Rect::new(-3840.0, -1600.0, 1456.0, 959.0), &[right, main]), Some(main));
|
||||
assert_eq!(monitor_of(Rect::new(5000.0, 100.0, 800.0, 600.0), &[main, right]), Some(right));
|
||||
assert_eq!(monitor_of(Rect::new(0.0, 0.0, 800.0, 600.0), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_numbers_still_give_a_monitor() {
|
||||
let main = Rect::new(0.0, 0.0, 1920.0, 1080.0);
|
||||
assert_eq!(monitor_of(Rect::new(f32::NAN, f32::INFINITY, f32::NAN, 1.0), &[main]), Some(main));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_changes_position_and_size() {
|
||||
assert_eq!(Rect::new(10.0, 20.0, 30.0, 40.0).scaled(2.0), Rect::new(20.0, 40.0, 60.0, 80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_down_and_right() {
|
||||
let prev = Rect::new(100.0, 50.0, 400.0, 300.0);
|
||||
|
||||
+145
-12
@@ -5,7 +5,7 @@ use std::collections::HashMap;
|
||||
use egui::{Context, Ui, ViewportBuilder, ViewportCommand, ViewportId};
|
||||
|
||||
use crate::list::{WindowId, WindowList};
|
||||
use crate::place::{CASCADE_STEP, Rect, cascade};
|
||||
use crate::place::{CASCADE_STEP, Rect, cascade, monitor_of};
|
||||
use crate::{Frame, decorate};
|
||||
|
||||
/// Something that happened to a window this frame. The app decides what follows.
|
||||
@@ -28,6 +28,8 @@ struct Seen {
|
||||
focused: bool,
|
||||
inner_size: Option<egui::Vec2>,
|
||||
monitor_size: Option<egui::Vec2>,
|
||||
/// Physical pixels per point: the window reports its rectangle in points of its own scale.
|
||||
scale: Option<f32>,
|
||||
/// The cascade position chosen when the window was created; repeated every frame so egui sees
|
||||
/// an unchanged builder.
|
||||
position: Option<egui::Pos2>,
|
||||
@@ -46,6 +48,7 @@ pub struct Viewports {
|
||||
root: Option<WindowId>,
|
||||
step: f32,
|
||||
bounds: Option<Rect>,
|
||||
monitors: Option<Box<dyn Fn() -> Vec<Rect>>>,
|
||||
seen: HashMap<WindowId, Seen>,
|
||||
}
|
||||
|
||||
@@ -53,7 +56,7 @@ impl Viewports {
|
||||
/// `frame` is the app's frame decision ([`crate::Choice::probe`]), applied to every new
|
||||
/// window's builder.
|
||||
pub fn new(frame: Frame) -> Self {
|
||||
Self { frame, root: None, step: CASCADE_STEP, bounds: None, seen: HashMap::new() }
|
||||
Self { frame, root: None, step: CASCADE_STEP, bounds: None, monitors: None, seen: HashMap::new() }
|
||||
}
|
||||
|
||||
/// The cascade offset for new windows, in points. Defaults to [`CASCADE_STEP`].
|
||||
@@ -63,11 +66,21 @@ impl Viewports {
|
||||
}
|
||||
|
||||
/// The area new windows cascade within, when the app knows the work area. Without it the
|
||||
/// driver uses the size of the monitor the previous window is on, as egui reports it.
|
||||
/// driver uses the monitor the previous window is on: from [`Viewports::set_monitors`], or
|
||||
/// else guessed from the size egui reports.
|
||||
pub fn set_bounds(&mut self, bounds: Option<Rect>) {
|
||||
self.bounds = bounds;
|
||||
}
|
||||
|
||||
/// How to list the desktop's monitors, in physical pixels: `eframe`'s
|
||||
/// `CreationContext::winit_window()` gives them through `available_monitors()`. New windows
|
||||
/// then cascade on the real monitor of the window before them, whatever the monitors' sizes,
|
||||
/// places and scales; egui reports only a monitor's size, which can't tell any of that.
|
||||
/// Asked only when a new window is placed.
|
||||
pub fn set_monitors(&mut self, monitors: impl Fn() -> Vec<Rect> + 'static) {
|
||||
self.monitors = Some(Box::new(monitors));
|
||||
}
|
||||
|
||||
/// The window drawn in the root viewport.
|
||||
pub fn root(&self) -> Option<WindowId> {
|
||||
self.root
|
||||
@@ -177,11 +190,12 @@ impl Viewports {
|
||||
|
||||
/// Take in window `id`'s viewport state for this frame.
|
||||
fn record<T>(&mut self, snapshot: Snapshot, windows: &mut WindowList<T>, id: WindowId, events: &mut Vec<WindowEvent>) {
|
||||
let Snapshot { close, focused, outer, inner, monitor } = snapshot;
|
||||
let Snapshot { close, focused, outer, inner, monitor, scale } = snapshot;
|
||||
windows.set_outer_rect(id, outer.map(Rect::from));
|
||||
let seen = self.seen.entry(id).or_default();
|
||||
seen.inner_size = inner.map(|r| r.size()).or(seen.inner_size);
|
||||
seen.monitor_size = monitor.or(seen.monitor_size);
|
||||
seen.scale = Some(scale);
|
||||
if focused && !seen.focused {
|
||||
windows.focus(id);
|
||||
events.push(WindowEvent::Focused(id));
|
||||
@@ -203,13 +217,22 @@ impl Viewports {
|
||||
.map(|inner| egui::vec2(previous.width - inner.x, previous.height - inner.y))
|
||||
.unwrap_or_default();
|
||||
let size = viewport.inner_size.map(|s| s + frame).unwrap_or(egui::vec2(previous.width, previous.height));
|
||||
let bounds = self.bounds.or_else(|| {
|
||||
let monitor = previous_id.and_then(|w| self.seen.get(&w)).and_then(|s| s.monitor_size)?;
|
||||
Some(monitor_bounds(previous, monitor))
|
||||
})?;
|
||||
let seen = previous_id.and_then(|w| self.seen.get(&w));
|
||||
let bounds = self
|
||||
.bounds
|
||||
.or_else(|| self.real_monitor(previous, seen.and_then(|s| s.scale)))
|
||||
.or_else(|| Some(monitor_bounds(previous, seen?.monitor_size?)))?;
|
||||
let placed = cascade(previous, [size.x, size.y], bounds, self.step);
|
||||
Some(egui::pos2(placed.x, placed.y))
|
||||
}
|
||||
|
||||
/// The real monitor of the window at `rect`, in that window's points. A window's rectangle is
|
||||
/// in points of its own scale, so the monitor is picked in physical pixels.
|
||||
fn real_monitor(&self, rect: Rect, scale: Option<f32>) -> Option<Rect> {
|
||||
let monitors = self.monitors.as_ref()?();
|
||||
let scale = scale.filter(|s| s.is_finite() && *s > 0.0).unwrap_or(1.0);
|
||||
Some(monitor_of(rect.scaled(scale), &monitors)?.scaled(1.0 / scale))
|
||||
}
|
||||
}
|
||||
|
||||
/// A viewport's state for this frame, read inside its own pass (egui's input is per viewport).
|
||||
@@ -219,6 +242,7 @@ struct Snapshot {
|
||||
outer: Option<egui::Rect>,
|
||||
inner: Option<egui::Rect>,
|
||||
monitor: Option<egui::Vec2>,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
@@ -231,6 +255,7 @@ impl Snapshot {
|
||||
outer: v.outer_rect,
|
||||
inner: v.inner_rect,
|
||||
monitor: v.monitor_size,
|
||||
scale: i.pixels_per_point(),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -241,11 +266,17 @@ pub fn child_viewport_id(id: WindowId) -> ViewportId {
|
||||
ViewportId::from_hash_of(("craft_window", id.get()))
|
||||
}
|
||||
|
||||
/// egui reports a monitor's size but not its origin. Assume monitors of that size tile the
|
||||
/// desktop and take the tile holding the window's top-left corner, which is right for a single
|
||||
/// monitor and for side-by-side monitors of equal size.
|
||||
/// How far past its monitor's corner a window's frame can reach, in points: on Windows a maximized
|
||||
/// window's corner is about 8 points outside its monitor.
|
||||
const FRAME_OVERSHOOT: f32 = 16.0;
|
||||
|
||||
/// The monitor to assume when the app doesn't list them ([`Viewports::set_monitors`]). egui reports
|
||||
/// a monitor's size but not its origin. Assume monitors of that size tile the desktop and take the
|
||||
/// tile holding the window's top-left corner, moved in by [`FRAME_OVERSHOOT`] so a maximized
|
||||
/// window's corner doesn't count as the monitor before its own. That is right for a single monitor
|
||||
/// and for side-by-side monitors of equal size, and only a guess for any other desktop.
|
||||
fn monitor_bounds(window: Rect, monitor: egui::Vec2) -> Rect {
|
||||
let tile = |pos: f32, len: f32| if len > 0.0 { (pos / len).floor() * len } else { 0.0 };
|
||||
let tile = |pos: f32, len: f32| if len > 0.0 { ((pos + FRAME_OVERSHOOT) / len).floor() * len } else { 0.0 };
|
||||
Rect::new(tile(window.x, monitor.x), tile(window.y, monitor.y), monitor.x, monitor.y)
|
||||
}
|
||||
|
||||
@@ -392,6 +423,108 @@ mod tests {
|
||||
assert_eq!(position, None, "the first window is placed by the desktop");
|
||||
}
|
||||
|
||||
/// Opens a second window from one at `outer` on `monitor_size`, in `scale`, and returns where
|
||||
/// the second window goes.
|
||||
fn opened_from(outer: egui::Rect, monitor_size: egui::Vec2, scale: f32, driver: &mut Viewports) -> Option<egui::Pos2> {
|
||||
let ctx = backend();
|
||||
let mut windows = WindowList::new();
|
||||
let a = windows.open("a");
|
||||
let va = child_viewport_id(a);
|
||||
info(va, |v| {
|
||||
v.outer_rect = Some(outer);
|
||||
v.inner_rect = Some(outer);
|
||||
v.monitor_size = Some(monitor_size);
|
||||
v.native_pixels_per_point = Some(scale);
|
||||
});
|
||||
frame(&ctx, driver, &mut windows);
|
||||
let b = windows.open("b");
|
||||
frame(&ctx, driver, &mut windows);
|
||||
BUILDERS.with(|m| m.borrow().get(&child_viewport_id(b)).and_then(|b| b.position))
|
||||
}
|
||||
|
||||
fn rect(x: f32, y: f32, w: f32, h: f32) -> egui::Rect {
|
||||
egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h))
|
||||
}
|
||||
|
||||
/// A monitor whose place isn't a multiple of its size: no guess from the size can find it.
|
||||
#[test]
|
||||
fn a_new_window_stays_on_a_monitor_of_another_size_and_place() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 2560.0, 1440.0), Rect::new(2560.0, 200.0, 1920.0, 1080.0)]);
|
||||
// A guess takes 1920–3840 for the monitor, so the step past 3840 would wrap to the first one.
|
||||
let place = opened_from(rect(3500.0, 300.0, 400.0, 300.0), egui::vec2(1920.0, 1080.0), 1.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(3528.0, 328.0)));
|
||||
}
|
||||
|
||||
/// A monitor below a wider one, offset sideways: the step past its bottom edge wraps to the
|
||||
/// monitor's own top, not to a line of the size's multiples.
|
||||
#[test]
|
||||
fn a_new_window_wraps_at_the_edges_of_the_real_monitor() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 3440.0, 1440.0), Rect::new(718.0, 1440.0, 1920.0, 1080.0)]);
|
||||
let place = opened_from(rect(900.0, 2200.0, 400.0, 300.0), egui::vec2(1920.0, 1080.0), 1.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(928.0, 1440.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_window_beside_a_maximized_one_stays_on_its_monitor() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 3840.0, 1600.0)]);
|
||||
let place = opened_from(rect(-8.0, -8.0, 3856.0, 1576.0), egui::vec2(3840.0, 1600.0), 1.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(20.0, 20.0)));
|
||||
}
|
||||
|
||||
/// Most of the window past the right edge: its centre is off the monitor, its monitor isn't.
|
||||
#[test]
|
||||
fn a_new_window_from_one_hanging_off_the_edge_stays_on_the_monitor() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 1920.0, 1080.0)]);
|
||||
let place = opened_from(rect(1600.0, 100.0, 800.0, 600.0), egui::vec2(1920.0, 1080.0), 1.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(0.0, 128.0)));
|
||||
}
|
||||
|
||||
/// A window off every monitor: the new one still opens on one.
|
||||
#[test]
|
||||
fn a_new_window_from_one_off_screen_opens_on_a_monitor() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 1920.0, 1080.0)]);
|
||||
let place = opened_from(rect(-1920.0, -1080.0, 800.0, 600.0), egui::vec2(1920.0, 1080.0), 1.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(0.0, 0.0)));
|
||||
}
|
||||
|
||||
/// At twice the scale, on the second monitor: physically at (3000, 100), and that monitor is
|
||||
/// 960–2880 in the window's points. Read as if points were pixels the window is on the first
|
||||
/// monitor, and a guess from the size takes 0–1920; either would wrap the new window to 0.
|
||||
#[test]
|
||||
fn a_new_window_stays_on_a_monitor_of_another_scale() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 1920.0, 1080.0), Rect::new(1920.0, 0.0, 3840.0, 2160.0)]);
|
||||
let place = opened_from(rect(1500.0, 50.0, 400.0, 300.0), egui::vec2(1920.0, 1080.0), 2.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(1528.0, 78.0)));
|
||||
}
|
||||
|
||||
/// `set_bounds` is the app's own word: it wins over the monitors.
|
||||
#[test]
|
||||
fn set_bounds_wins_over_the_monitors() {
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
driver.set_monitors(|| vec![Rect::new(0.0, 0.0, 1920.0, 1080.0)]);
|
||||
driver.set_bounds(Some(Rect::new(100.0, 100.0, 1000.0, 800.0)));
|
||||
let place = opened_from(rect(5.0, 5.0, 400.0, 300.0), egui::vec2(1920.0, 1080.0), 1.0, &mut driver);
|
||||
assert_eq!(place, Some(egui::pos2(100.0, 100.0)));
|
||||
}
|
||||
|
||||
/// An app that doesn't list its monitors: a maximized window's frame doesn't move the guess
|
||||
/// to a monitor that isn't there, and a window past the right edge stays on its monitor.
|
||||
#[test]
|
||||
fn without_the_monitors_a_maximized_window_still_finds_its_own() {
|
||||
let monitor = egui::vec2(1920.0, 1080.0);
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
assert_eq!(opened_from(rect(-8.0, -8.0, 1936.0, 1056.0), monitor, 1.0, &mut driver), Some(egui::pos2(20.0, 20.0)));
|
||||
let mut driver = Viewports::new(Frame::Desktop);
|
||||
assert_eq!(opened_from(rect(1600.0, 100.0, 800.0, 600.0), monitor, 1.0, &mut driver), Some(egui::pos2(0.0, 128.0)));
|
||||
assert_eq!(monitor_bounds(Rect::new(-1928.0, -8.0, 1936.0, 1056.0), monitor), Rect::new(-1920.0, 0.0, 1920.0, 1080.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_bounds_follow_the_window() {
|
||||
let monitor = egui::vec2(1920.0, 1080.0);
|
||||
|
||||
Reference in New Issue
Block a user