mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-window: restore store: window.json beside config.toml and a pure place()
* craft-window: restore store: window.json beside config.toml and a pure place() * craft-window: restore: review fixes (bar strip, mixed DPI, pending writes, work area) --------- Co-authored-by: aiongg <aiongtaigi@gmail.com>
This commit is contained in:
1 parent
1329b6bf04
commit
bcf1e69cf4
9 files changed
+1305
No files matched your search
@@ -46,3 +46,5 @@ jobs:
|
||||
- run: cargo clippy -p craft-window --all-targets --locked --features egui -- -D warnings
|
||||
- run: cargo test -p craft-window --locked -- --nocapture
|
||||
- run: cargo test -p craft-window --locked --features egui -- --nocapture
|
||||
- run: cargo clippy -p craft-window --all-targets --locked --features restore -- -D warnings
|
||||
- run: cargo test -p craft-window --locked --features restore,egui -- --nocapture
|
||||
Generated
+5
@@ -2386,9 +2386,14 @@ dependencies = [
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"craft-settings",
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@ repository.workspace = true
|
||||
# `decorate`: set the decision on an `egui::ViewportBuilder`. `title_bar`: an app-drawn strip as
|
||||
# the title bar (on macOS with AppKit's `movable` flag). `Viewports`: one immediate viewport per window.
|
||||
egui = ["dep:egui", "dep:objc2", "dep:objc2-app-kit"]
|
||||
# `restore`: the window-restore store (`window.json` beside `config.toml`) and the pure `place`. No egui.
|
||||
restore = ["dep:craft-atomic", "dep:craft-settings", "dep:serde", "dep:serde_json"]
|
||||
|
||||
[dependencies]
|
||||
egui = { workspace = true, optional = true }
|
||||
craft-atomic = { workspace = true, optional = true }
|
||||
craft-settings = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
|
||||
# Only Linux and the BSDs probe a Wayland compositor; everywhere else the frame is the OS's.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies]
|
||||
@@ -24,5 +30,8 @@ wayland-client = "0.31"
|
||||
objc2 = { version = "0.6.4", default-features = false, features = ["std"], optional = true }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -143,6 +143,24 @@ for event in viewports.show(
|
||||
|
||||
Immediate viewports repaint every window when one repaints, so cache expensive per-window drawing.
|
||||
|
||||
## Window restore
|
||||
|
||||
Feature `restore` (no egui): `craft_window::restore`.
|
||||
|
||||
- `place(saved, monitors, home, &Defaults) -> Placement` is pure. First launch: the default size fitted
|
||||
to the home monitor, maximized when it does not fit. Saved sizes are points at interface zoom 1.
|
||||
A window with less than 64×64 pt of its top strip on any one monitor moves to its saved monitor,
|
||||
else the nearest, else the primary; sizes clamp to the monitor's usable area and `min`. Positions
|
||||
are physical pixels (negative left of or above the primary). `Defaults::no_position` (Wayland)
|
||||
neither reads nor returns a position. Full screen returns only with `Defaults::restore_fullscreen`.
|
||||
- `Store::open(app_id)` keeps `window.json` beside `config.toml` (same folder rules as
|
||||
`craft-settings`), `Store::in_memory()` keeps nothing. `get`, `set`, `save`.
|
||||
Format: `{ "schema_version": 1, "windows": { "main": Placement, "<key>": Placement } }`.
|
||||
Writes are atomic and private; a damaged file is moved aside as `window.broken-<ts>.json`; a newer
|
||||
`schema_version` is read-only; unknown keys are written back unchanged.
|
||||
- `restore::legacy` reads eframe's `"window"` entry of `app.ron` (without a RON crate) and has small
|
||||
JSON helpers for an app's own old format.
|
||||
|
||||
## Notes
|
||||
|
||||
- The Wayland probe opens a short-lived connection and reads the global registry once, on its own
|
||||
|
||||
@@ -127,6 +127,11 @@
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Window restore
|
||||
//!
|
||||
//! The `restore` feature (no egui) adds [`restore`]: where a window opens, from the last session's
|
||||
//! geometry and the connected monitors, and the `window.json` store beside `config.toml`.
|
||||
|
||||
mod list;
|
||||
mod menu;
|
||||
@@ -136,6 +141,9 @@ pub mod title_bar;
|
||||
#[cfg(feature = "egui")]
|
||||
mod viewports;
|
||||
|
||||
#[cfg(feature = "restore")]
|
||||
pub mod restore;
|
||||
|
||||
pub use list::{Closed, WindowId, WindowList};
|
||||
pub use menu::{Document, MenuEntry, WindowMenu};
|
||||
pub use place::{CASCADE_STEP, Rect, cascade, monitor_of};
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Readers for window geometry that apps stored before the `window.json` store.
|
||||
//!
|
||||
//! eframe keeps a RON map of strings in `app.ron` (`ui.ron`); its `"window"` entry is the RON of
|
||||
//! egui-winit's `WindowSettings`, and `"egui"` holds the interface zoom. No RON crate is
|
||||
//! involved: the readers look up the few named fields they need, in both layouts eframe has
|
||||
//! used (a flat struct, and one with a nested `normal_geometry`). Nothing is written back; old
|
||||
//! builds keep working from the old file.
|
||||
//!
|
||||
//! An old layout has no scale factor, so imports assume 1; [`place`](super::place) takes the
|
||||
//! real one from the monitor the window lands on.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::place::{NormalRect, Placement};
|
||||
|
||||
/// The placement in an eframe `app.ron` text: its `"window"` entry, with the size converted to
|
||||
/// points at zoom 1 using the `"egui"` entry's `zoom_factor` (1 when absent).
|
||||
pub fn eframe_app_ron(text: &str) -> Option<Placement> {
|
||||
let window = ron_string_entry(text, "window")?;
|
||||
let zoom = ron_string_entry(text, "egui").and_then(|e| number(field(&e, "zoom_factor")?)).unwrap_or(1.0);
|
||||
eframe_window_settings(&window, zoom)
|
||||
}
|
||||
|
||||
/// [`eframe_app_ron`] for a file; `None` when it is missing or unreadable.
|
||||
pub fn eframe_app_ron_file(path: &Path) -> Option<Placement> {
|
||||
eframe_app_ron(&std::fs::read_to_string(path).ok()?)
|
||||
}
|
||||
|
||||
/// The placement in the RON of one eframe `WindowSettings`. `zoom` is the interface zoom factor
|
||||
/// the size was saved under (eframe saves points at that zoom).
|
||||
pub fn eframe_window_settings(ron: &str, zoom: f32) -> Option<Placement> {
|
||||
let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };
|
||||
// The nested layout also names the scale; the flat one does not.
|
||||
let geometry = field(ron, "normal_geometry").and_then(group).unwrap_or(ron);
|
||||
let scale = field(geometry, "scale_factor").and_then(number).filter(|s| *s > 0.0).unwrap_or(1.0);
|
||||
let size = field(geometry, "inner_size_points").and_then(vec2)?;
|
||||
let pos = field(geometry, "outer_position_pixels").and_then(vec2).or_else(|| field(geometry, "inner_position_pixels").and_then(vec2));
|
||||
// The window's pixel size divided by zoom × scale (eframe >= 0.36), so × zoom is points at
|
||||
// zoom 1. Older eframe saved points at the then-current zoom, which this reads the same way.
|
||||
let size = size.map(|v| v * zoom);
|
||||
// Windows parks a minimized window near (-32000, -32000).
|
||||
let pos = pos.filter(|p| p.iter().all(|v| *v > -30000.0));
|
||||
Some(Placement {
|
||||
normal: NormalRect { pos: pos.map_or([0.0; 2], |p| p.map(|v| v / scale)), size },
|
||||
position_px: pos.map(|p| p.map(|v| v.round() as i32)),
|
||||
scale,
|
||||
zoom,
|
||||
maximized: field(ron, "maximized").is_some_and(truth),
|
||||
fullscreen: field(ron, "fullscreen").is_some_and(truth),
|
||||
monitor: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value at `path` of a JSON object (`json_at(&v, &["window", "size"])`).
|
||||
pub fn json_at<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Value> {
|
||||
path.iter().try_fold(v, |v, k| v.get(*k))
|
||||
}
|
||||
|
||||
/// A finite number at `path`.
|
||||
pub fn json_f32(v: &Value, path: &[&str]) -> Option<f32> {
|
||||
json_at(v, path)?.as_f64().filter(|n| n.is_finite()).map(|n| n as f32)
|
||||
}
|
||||
|
||||
/// A bool at `path`.
|
||||
pub fn json_bool(v: &Value, path: &[&str]) -> Option<bool> {
|
||||
json_at(v, path)?.as_bool()
|
||||
}
|
||||
|
||||
/// A pair of finite numbers at `path`, written as `[a, b]` or as an object with the two given keys.
|
||||
pub fn json_pair(v: &Value, path: &[&str], keys: [&str; 2]) -> Option<[f32; 2]> {
|
||||
let at = json_at(v, path)?;
|
||||
let num = |x: Option<&Value>| x?.as_f64().filter(|n| n.is_finite()).map(|n| n as f32);
|
||||
match at {
|
||||
Value::Array(a) if a.len() == 2 => Some([num(a.first())?, num(a.get(1))?]),
|
||||
Value::Object(_) => Some([num(at.get(keys[0]))?, num(at.get(keys[1]))?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`json_at`] on the text of a JSON file; `None` when missing or not JSON.
|
||||
pub fn read_json_file(path: &Path) -> Option<Value> {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
|
||||
// ---- RON scanning --------------------------------------------------------------------------
|
||||
|
||||
/// The text after `name:` for the first `name` that is a whole identifier.
|
||||
fn field<'a>(text: &'a str, name: &str) -> Option<&'a str> {
|
||||
let mut from = 0;
|
||||
while let Some(i) = text[from..].find(name) {
|
||||
let start = from + i;
|
||||
let end = start + name.len();
|
||||
let before_ok = text[..start].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
|
||||
let rest = text[end..].trim_start();
|
||||
if before_ok && let Some(value) = rest.strip_prefix(':') {
|
||||
return Some(value.trim_start());
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The text inside the parentheses that `s` starts with, as `(…)` or `Some(…)`.
|
||||
fn group(s: &str) -> Option<&str> {
|
||||
let s = s.strip_prefix("Some").map_or(s, str::trim_start);
|
||||
let s = s.strip_prefix('(')?;
|
||||
let mut depth = 1usize;
|
||||
for (i, c) in s.char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(&s[..i]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn number(s: &str) -> Option<f32> {
|
||||
let end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E'))).unwrap_or(s.len());
|
||||
s[..end].parse::<f32>().ok().filter(|n| n.is_finite())
|
||||
}
|
||||
|
||||
fn truth(s: &str) -> bool {
|
||||
s.starts_with("true")
|
||||
}
|
||||
|
||||
/// `Some((x: 1.0, y: 2.0))`, `(x: 1.0, y: 2.0)` or `(1.0, 2.0)`; `None` for `None`.
|
||||
fn vec2(s: &str) -> Option<[f32; 2]> {
|
||||
let inner = group(s)?;
|
||||
// `Some((…))` wraps the pair in a second group.
|
||||
let inner = if inner.trim_start().starts_with('(') { group(inner.trim_start())? } else { inner };
|
||||
if let (Some(x), Some(y)) = (field(inner, "x").and_then(number), field(inner, "y").and_then(number)) {
|
||||
return Some([x, y]);
|
||||
}
|
||||
let (a, b) = inner.split_once(',')?;
|
||||
Some([number(a.trim())?, number(b.trim())?])
|
||||
}
|
||||
|
||||
/// The string value for `key` in a RON map of strings (`{"key": "value", …}`), unescaped.
|
||||
fn ron_string_entry(text: &str, key: &str) -> Option<String> {
|
||||
let mut literals = string_literals(text).into_iter();
|
||||
while let Some(k) = literals.next() {
|
||||
let v = literals.next()?;
|
||||
if k == key {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every string literal of `text` in order: `"…"` with escapes, and raw `r#"…"#`.
|
||||
fn string_literals(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let b = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'r' && matches!(b.get(i + 1), Some(b'"' | b'#')) {
|
||||
let hashes = b[i + 1..].iter().take_while(|c| **c == b'#').count();
|
||||
if b.get(i + 1 + hashes) == Some(&b'"') {
|
||||
let body = i + 2 + hashes;
|
||||
let close = format!("\"{}", "#".repeat(hashes));
|
||||
let Some(len) = text[body..].find(&close) else { break };
|
||||
out.push(text[body..body + len].to_owned());
|
||||
i = body + len + close.len();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if b[i] == b'"' {
|
||||
let (s, next) = escaped(text, i + 1);
|
||||
out.push(s);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads an escaped literal body starting at byte `from`; returns it and the index after the closing quote.
|
||||
fn escaped(text: &str, from: usize) -> (String, usize) {
|
||||
let mut s = String::new();
|
||||
let mut chars = text[from..].char_indices();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => return (s, from + i + 1),
|
||||
'\\' => match chars.next().map(|(_, e)| e) {
|
||||
Some('n') => s.push('\n'),
|
||||
Some('t') => s.push('\t'),
|
||||
Some('r') => s.push('\r'),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for (_, h) in chars.by_ref() {
|
||||
if h == '}' {
|
||||
break;
|
||||
}
|
||||
if h != '{' {
|
||||
hex.push(h);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
s.push(ch);
|
||||
}
|
||||
}
|
||||
Some(other) => s.push(other),
|
||||
None => break,
|
||||
},
|
||||
c => s.push(c),
|
||||
}
|
||||
}
|
||||
(s, text.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::restore::Store;
|
||||
|
||||
const FLAT: &str = r#"{
|
||||
"egui": "(options:(zoom_factor:1.5,other:1))",
|
||||
"window": "(outer_position_pixels:Some((x:120.0,y:-30.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:800.0,y:600.0)))",
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn imports_the_flat_eframe_layout_with_zoom() {
|
||||
let p = eframe_app_ron(FLAT).unwrap();
|
||||
assert_eq!(p.position_px, Some([120, -30]));
|
||||
assert_eq!(p.normal.size, [1200.0, 900.0]);
|
||||
assert_eq!(p.zoom, 1.5);
|
||||
assert!(p.maximized && !p.fullscreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_the_nested_layout_with_its_scale() {
|
||||
let w = "(normal_geometry:(outer_position_pixels:Some((x:200.0,y:100.0)),scale_factor:2.0,inner_size_points:(x:640.0,y:480.0)),fullscreen:true,maximized:false)";
|
||||
let text = format!("{{\"window\": r#\"{w}\"#}}");
|
||||
let p = eframe_app_ron(&text).unwrap();
|
||||
assert_eq!((p.scale, p.position_px, p.normal.size), (2.0, Some([200, 100]), [640.0, 480.0]));
|
||||
assert_eq!(p.normal.pos, [100.0, 50.0]);
|
||||
assert!(p.fullscreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_position_and_garbage_are_handled() {
|
||||
let w = "(outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:300.0,y:200.0)))";
|
||||
assert_eq!(eframe_window_settings(w, 1.0).unwrap().position_px, None);
|
||||
assert!(eframe_window_settings("(", 1.0).is_none());
|
||||
assert!(eframe_app_ron("not ron").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimized_windows_parked_off_screen_have_no_position() {
|
||||
let w = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:300.0,y:200.0)))";
|
||||
let p = eframe_window_settings(w, 1.0).unwrap();
|
||||
assert_eq!((p.position_px, p.normal.size), (None, [300.0, 200.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_placement_lands_in_the_store() {
|
||||
let mut store = Store::in_memory();
|
||||
store.set("main", &eframe_app_ron(FLAT).unwrap());
|
||||
assert_eq!(store.get("main").unwrap().position_px, Some([120, -30]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_helpers_read_nested_values() {
|
||||
let v: Value = serde_json::from_str(r#"{"window":{"size":[800,600],"pos":{"x":1,"y":2},"max":true,"w":3.5}}"#).unwrap();
|
||||
assert_eq!(json_pair(&v, &["window", "size"], ["w", "h"]), Some([800.0, 600.0]));
|
||||
assert_eq!(json_pair(&v, &["window", "pos"], ["x", "y"]), Some([1.0, 2.0]));
|
||||
assert_eq!(json_bool(&v, &["window", "max"]), Some(true));
|
||||
assert_eq!(json_f32(&v, &["window", "w"]), Some(3.5));
|
||||
assert_eq!(json_f32(&v, &["window", "nope"]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Window restore: where a window opens and the file that remembers it. No egui.
|
||||
//!
|
||||
//! - [`place`]: saved [`Placement`] + connected [`Monitor`]s + app [`Defaults`] → the geometry to
|
||||
//! open with. Pure; the rules are on its documentation.
|
||||
//! - [`Store`]: `window.json` beside the app's `config.toml`, one [`Placement`] per window key
|
||||
//! (`"main"` for the first window).
|
||||
//! - [`legacy`]: readers for what apps stored before, such as eframe's `"window"` entry in
|
||||
//! `app.ron`.
|
||||
//!
|
||||
//! Keep one [`Store`] per app, shared by every window (for example behind an `Arc<Mutex<_>>`):
|
||||
//! each write rewrites the whole file from that store's copy, so two stores in one process
|
||||
//! overwrite each other.
|
||||
//!
|
||||
//! ```
|
||||
//! use craft_window::restore::{Defaults, Monitor, MonitorKey, Store, place};
|
||||
//!
|
||||
//! let mut store = Store::in_memory();
|
||||
//! let monitors = [Monitor::new(MonitorKey::new("DP-1", [1920, 1080]), [0, 0], [1920, 1080], 1.0)];
|
||||
//! let defaults = Defaults::new([1200.0, 800.0], [480.0, 320.0]);
|
||||
//! let opened = place(store.get("main").as_ref(), &monitors, Some(0), &defaults);
|
||||
//! store.set("main", &opened);
|
||||
//! assert_eq!(store.get("main"), Some(opened));
|
||||
//! ```
|
||||
|
||||
pub mod legacy;
|
||||
mod place;
|
||||
mod store;
|
||||
|
||||
pub use place::{Defaults, Monitor, MonitorKey, NormalRect, Placement, place};
|
||||
pub use store::{FILE_NAME, MAIN, Pending, ReadOnly, SCHEMA_VERSION, Store, write_pending};
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The model and the pure placement rules: saved geometry plus the connected monitors in, the
|
||||
//! geometry to open with out.
|
||||
//!
|
||||
//! Units. Sizes and `NormalRect`s are logical points at interface zoom 1, so a window saved while the
|
||||
//! user ran the interface at 150 % does not open smaller each launch. Positions are physical
|
||||
//! pixels in the desktop's coordinate space (negative for a monitor left of or above the primary).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How much of the window's top strip must lie on one monitor, in points on each axis, for the
|
||||
/// user to still be able to grab it.
|
||||
const BAR: f32 = 64.0;
|
||||
const MAX_POINTS: f32 = 100_000.0;
|
||||
|
||||
/// A rectangle in logical points at zoom 1: top-left corner and size. (Not `craft_window::Rect`,
|
||||
/// which is the cascade module's.)
|
||||
///
|
||||
/// `pos` is `Placement::position_px` divided by `Placement::scale`; it is informational, and
|
||||
/// `[0, 0]` when the position is unknown.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct NormalRect {
|
||||
pub pos: [f32; 2],
|
||||
pub size: [f32; 2],
|
||||
}
|
||||
|
||||
/// Identifies a monitor across launches: the name the platform reports and its size in pixels.
|
||||
///
|
||||
/// The size is part of the key because names repeat (`"Built-in Display"`) or change (`DP-1`
|
||||
/// after a re-plug), and a monitor switched to another resolution is a different place to put a
|
||||
/// window. A key that no longer matches falls back to the nearest connected monitor.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MonitorKey {
|
||||
pub name: String,
|
||||
pub size_px: [u32; 2],
|
||||
}
|
||||
|
||||
impl MonitorKey {
|
||||
pub fn new(name: impl Into<String>, size_px: [u32; 2]) -> Self {
|
||||
Self { name: name.into(), size_px }
|
||||
}
|
||||
}
|
||||
|
||||
/// What to remember about a window, and what [`place`] returns for the app to open it with.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Placement {
|
||||
/// The last window state that was neither maximized nor full screen.
|
||||
pub normal: NormalRect,
|
||||
/// Outer top-left corner in physical pixels; `None` where the platform does not tell (Wayland).
|
||||
#[serde(default)]
|
||||
pub position_px: Option<[i32; 2]>,
|
||||
/// Physical pixels per point of the monitor the window was on, without interface zoom.
|
||||
#[serde(default = "one")]
|
||||
pub scale: f32,
|
||||
/// The interface zoom factor while saving. Informational: `normal` is already at zoom 1.
|
||||
#[serde(default = "one")]
|
||||
pub zoom: f32,
|
||||
#[serde(default)]
|
||||
pub maximized: bool,
|
||||
#[serde(default)]
|
||||
pub fullscreen: bool,
|
||||
#[serde(default)]
|
||||
pub monitor: Option<MonitorKey>,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// A window of `size` points, position unknown, on a monitor of `scale`, not maximized.
|
||||
pub fn new(size: [f32; 2], scale: f32) -> Self {
|
||||
make(size, None, scale, 1.0, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// A connected monitor.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct Monitor {
|
||||
pub key: MonitorKey,
|
||||
/// Top-left corner in the desktop's pixel space.
|
||||
pub pos_px: [i32; 2],
|
||||
pub size_px: [u32; 2],
|
||||
/// Physical pixels per point.
|
||||
pub scale: f32,
|
||||
/// The part not taken by the taskbar, Dock or menu bar: top-left corner and size in pixels.
|
||||
/// When set, it replaces [`Defaults::screen_room`] for this monitor.
|
||||
pub work_px: Option<([i32; 2], [u32; 2])>,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(key: MonitorKey, pos_px: [i32; 2], size_px: [u32; 2], scale: f32) -> Self {
|
||||
Self { key, pos_px, size_px, scale, work_px: None }
|
||||
}
|
||||
|
||||
/// Sets the usable area (see [`work_px`](Self::work_px)).
|
||||
pub fn with_work_px(mut self, pos_px: [i32; 2], size_px: [u32; 2]) -> Self {
|
||||
self.work_px = Some((pos_px, size_px));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app wants when nothing (usable) is saved, and how the platform behaves.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct Defaults {
|
||||
/// Size of a first window, in points.
|
||||
pub size: [f32; 2],
|
||||
/// The smallest window, in points. Yields to a monitor smaller than this.
|
||||
pub min: [f32; 2],
|
||||
/// Points a monitor loses to the taskbar, Dock or menu bar, per axis.
|
||||
pub screen_room: [f32; 2],
|
||||
/// Restore full screen. Off by default: most apps open as a window.
|
||||
pub restore_fullscreen: bool,
|
||||
/// The platform hides window positions (Wayland): none is applied or returned.
|
||||
pub no_position: bool,
|
||||
}
|
||||
|
||||
impl Defaults {
|
||||
pub fn new(size: [f32; 2], min: [f32; 2]) -> Self {
|
||||
Self { size, min, screen_room: [0.0, 48.0], restore_fullscreen: false, no_position: false }
|
||||
}
|
||||
|
||||
pub fn with_screen_room(mut self, room: [f32; 2]) -> Self {
|
||||
self.screen_room = room;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_restore_fullscreen(mut self, on: bool) -> Self {
|
||||
self.restore_fullscreen = on;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_no_position(mut self, on: bool) -> Self {
|
||||
self.no_position = on;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
fn scale(&self) -> f32 {
|
||||
if self.scale.is_finite() && self.scale > 0.0 { self.scale } else { 1.0 }
|
||||
}
|
||||
|
||||
fn points(&self) -> [f32; 2] {
|
||||
self.size_px.map(|s| s as f32 / self.scale())
|
||||
}
|
||||
|
||||
/// Position and size in pixels of the area windows may use.
|
||||
fn bounds(&self) -> ([i32; 2], [u32; 2]) {
|
||||
self.work_px.unwrap_or((self.pos_px, self.size_px))
|
||||
}
|
||||
|
||||
/// Size in points of the area windows may use: the work area, else the monitor less `room`.
|
||||
fn usable(&self, room: [f32; 2]) -> [f32; 2] {
|
||||
let s = self.scale();
|
||||
match self.work_px {
|
||||
Some((_, size)) => size.map(|v| (v as f32 / s).max(1.0)),
|
||||
None => {
|
||||
let p = self.points();
|
||||
std::array::from_fn(|i| (p[i] - room[i].max(0.0)).max(1.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn centre_px(&self) -> [f64; 2] {
|
||||
std::array::from_fn(|i| f64::from(self.pos_px[i]) + f64::from(self.size_px[i]) / 2.0)
|
||||
}
|
||||
|
||||
/// `pos` moved the least that puts a window of `size` points fully in the usable area.
|
||||
fn keep_on(&self, pos: [i32; 2], size: [f32; 2]) -> [i32; 2] {
|
||||
let (bpos, bsize) = self.bounds();
|
||||
std::array::from_fn(|i| {
|
||||
let lo = i64::from(bpos[i]);
|
||||
let win = (f64::from(size[i]) * f64::from(self.scale())).round() as i64;
|
||||
let hi = (lo + i64::from(bsize[i]) - win).max(lo);
|
||||
i32::try_from(i64::from(pos[i]).clamp(lo, hi)).unwrap_or(bpos[i])
|
||||
})
|
||||
}
|
||||
|
||||
/// Area in points of the window's top strip (full width, up to 64 pt tall) lying on this
|
||||
/// monitor, if at least min(width, 64) × min(height, 64) pt of it does. A few pixels of slack
|
||||
/// absorb Windows' snapped offset of about -7 px.
|
||||
fn bar_overlap(&self, pos: [i32; 2], size: [f32; 2]) -> Option<f32> {
|
||||
const SLACK_PX: f64 = 8.0;
|
||||
let s = f64::from(self.scale());
|
||||
let strip = [size[0], size[1].min(BAR)];
|
||||
let need = [size[0].min(BAR), size[1].min(BAR)];
|
||||
let mut got = [0.0f64; 2];
|
||||
for i in 0..2 {
|
||||
let a0 = f64::from(pos[i]);
|
||||
let a1 = a0 + f64::from(strip[i]) * s;
|
||||
let b0 = f64::from(self.pos_px[i]);
|
||||
let b1 = b0 + f64::from(self.size_px[i]);
|
||||
got[i] = (a1.min(b1) - a0.max(b0)).max(0.0);
|
||||
}
|
||||
(0..2).all(|i| got[i] + SLACK_PX >= f64::from(need[i]) * s).then(|| (got[0] / s * got[1] / s) as f32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Size no bigger than `room` and no smaller than `min` unless `room` is.
|
||||
fn fit(size: [f32; 2], room: [f32; 2], min: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(min[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
fn in_points(v: f32) -> bool {
|
||||
v.is_finite() && (1.0..=MAX_POINTS).contains(&v)
|
||||
}
|
||||
|
||||
/// The saved record with out-of-range numbers repaired, or `None` when its size is no size.
|
||||
pub(super) fn sane(s: &Placement) -> Option<Placement> {
|
||||
if !s.normal.size.iter().all(|v| in_points(*v)) {
|
||||
return None;
|
||||
}
|
||||
let mut s = s.clone();
|
||||
if !s.normal.pos.iter().all(|v| v.is_finite()) {
|
||||
s.normal.pos = [0.0; 2];
|
||||
}
|
||||
if !(s.scale.is_finite() && s.scale > 0.0 && s.scale <= 16.0) {
|
||||
s.scale = 1.0;
|
||||
}
|
||||
if !(s.zoom.is_finite() && (0.1..=10.0).contains(&s.zoom)) {
|
||||
s.zoom = 1.0;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
fn make(size: [f32; 2], pos_px: Option<[i32; 2]>, scale: f32, zoom: f32, monitor: Option<MonitorKey>) -> Placement {
|
||||
let pos = pos_px.map_or([0.0, 0.0], |p| p.map(|v| v as f32 / scale));
|
||||
Placement { normal: NormalRect { pos, size }, position_px: pos_px, scale, zoom, maximized: false, fullscreen: false, monitor }
|
||||
}
|
||||
|
||||
/// Where a window opens: `saved` kept on a connected monitor, else the default size on `home`
|
||||
/// (index into `monitors` of the monitor the system opens it on; the one at the origin, else the
|
||||
/// first, when `None`).
|
||||
///
|
||||
/// - Nothing saved: [`Defaults::size`] fitted to the usable area of `home`; maximized when the
|
||||
/// monitor is too small for it. The position is left to the system.
|
||||
/// - The saved size is points at zoom 1 and is not scaled by `zoom`.
|
||||
/// - `normal` is kept as saved while `maximized`; only the flag says it was maximized.
|
||||
/// - Off screen: when less than 64×64 pt of the window's top strip lies on any one monitor, the
|
||||
/// window moves to its saved monitor, else the one nearest its centre, else the primary.
|
||||
/// - The size is clamped to the usable area of the monitor it lands on and to `min`.
|
||||
/// - A different `scale` than saved is taken from the monitor; the size stays in points.
|
||||
/// - [`Defaults::no_position`]: no position in, none out.
|
||||
/// - Full screen comes back only with [`Defaults::restore_fullscreen`].
|
||||
/// - Without monitors (the platform lists none) nothing can be checked: the saved geometry is kept,
|
||||
/// with the size raised to `min`.
|
||||
pub fn place(saved: Option<&Placement>, monitors: &[Monitor], home: Option<usize>, d: &Defaults) -> Placement {
|
||||
let saved = saved.and_then(sane);
|
||||
let primary = monitors.iter().find(|m| m.pos_px == [0, 0]).or(monitors.first());
|
||||
let home = home.and_then(|i| monitors.get(i)).or(primary);
|
||||
match (saved, home) {
|
||||
(None, None) => make(fit(d.size, [MAX_POINTS; 2], d.min), None, 1.0, 1.0, None),
|
||||
(None, Some(h)) => first_launch(h, d),
|
||||
(Some(s), None) => {
|
||||
let size = fit(s.normal.size, [MAX_POINTS; 2], d.min);
|
||||
let pos = if d.no_position { None } else { s.position_px };
|
||||
Placement { maximized: s.maximized, fullscreen: s.fullscreen && d.restore_fullscreen, ..make(size, pos, s.scale, s.zoom, s.monitor) }
|
||||
}
|
||||
(Some(s), Some(h)) => restore(&s, monitors, h, primary.unwrap_or(h), d),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_launch(home: &Monitor, d: &Defaults) -> Placement {
|
||||
let usable = home.usable(d.screen_room);
|
||||
let too_small = (0..2).any(|i| d.size[i] > usable[i]);
|
||||
let size = fit(d.size, usable, d.min);
|
||||
Placement { maximized: too_small, ..make(size, None, home.scale(), 1.0, Some(home.key.clone())) }
|
||||
}
|
||||
|
||||
fn restore(s: &Placement, monitors: &[Monitor], home: &Monitor, primary: &Monitor, d: &Defaults) -> Placement {
|
||||
let size = s.normal.size;
|
||||
let saved_pos = if d.no_position { None } else { s.position_px };
|
||||
let by_key = || s.monitor.as_ref().and_then(|k| monitors.iter().find(|m| &m.key == k));
|
||||
|
||||
let mut visible = false;
|
||||
let target = match saved_pos {
|
||||
Some(p) => {
|
||||
// On mixed-DPI desktops pixel ranges overlap; the saved monitor decides first.
|
||||
let shown = monitors
|
||||
.iter()
|
||||
.filter_map(|m| m.bar_overlap(p, size).map(|a| (a, m)))
|
||||
.max_by(|a, b| {
|
||||
let key = |m: &Monitor| s.monitor.as_ref() == Some(&m.key);
|
||||
key(a.1).cmp(&key(b.1)).then(a.0.total_cmp(&b.0))
|
||||
})
|
||||
.map(|(_, m)| m);
|
||||
visible = shown.is_some();
|
||||
shown.or_else(by_key).unwrap_or_else(|| nearest(monitors, p, size, s.scale).unwrap_or(primary))
|
||||
}
|
||||
None => by_key().unwrap_or(home),
|
||||
};
|
||||
|
||||
let fitted = fit(size, target.usable(d.screen_room), d.min);
|
||||
let kept = visible && fitted == size;
|
||||
let pos = saved_pos.map(|p| if kept { p } else { target.keep_on(p, fitted) });
|
||||
// A kept position on the saved monitor keeps its saved scale: the pixel ranges of mixed-DPI
|
||||
// monitors overlap, and the position was measured at that scale.
|
||||
let scale = if kept && s.monitor.as_ref() == Some(&target.key) { s.scale } else { target.scale() };
|
||||
Placement {
|
||||
maximized: s.maximized,
|
||||
fullscreen: s.fullscreen && d.restore_fullscreen,
|
||||
..make(fitted, pos, scale, s.zoom, Some(target.key.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor whose centre is closest to the window's centre.
|
||||
fn nearest(monitors: &[Monitor], pos: [i32; 2], size: [f32; 2], scale: f32) -> Option<&Monitor> {
|
||||
let c: [f64; 2] = std::array::from_fn(|i| f64::from(pos[i]) + f64::from(size[i]) * f64::from(scale) / 2.0);
|
||||
let dist = |m: &Monitor| {
|
||||
let mc = m.centre_px();
|
||||
(mc[0] - c[0]).powi(2) + (mc[1] - c[1]).powi(2)
|
||||
};
|
||||
monitors.iter().min_by(|a, b| dist(a).total_cmp(&dist(b)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mon(name: &str, pos: [i32; 2], size: [u32; 2], scale: f32) -> Monitor {
|
||||
Monitor::new(MonitorKey::new(name, size), pos, size, scale)
|
||||
}
|
||||
|
||||
fn hd() -> Monitor {
|
||||
mon("A", [0, 0], [1920, 1080], 1.0)
|
||||
}
|
||||
|
||||
fn defaults() -> Defaults {
|
||||
let mut d = Defaults::new([1200.0, 800.0], [400.0, 300.0]);
|
||||
d.screen_room = [0.0, 40.0];
|
||||
d
|
||||
}
|
||||
|
||||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], scale: f32, m: &Monitor) -> Placement {
|
||||
let mut p = make(size, pos, scale, 1.0, Some(m.key.clone()));
|
||||
p.maximized = false;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_launch_uses_default_size_and_leaves_position_to_the_system() {
|
||||
let p = place(None, &[hd()], Some(0), &defaults());
|
||||
assert_eq!(p.normal.size, [1200.0, 800.0]);
|
||||
assert_eq!(p.position_px, None);
|
||||
assert!(!p.maximized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_launch_on_a_small_monitor_fits_and_maximizes() {
|
||||
let small = mon("S", [0, 0], [1280, 720], 1.0);
|
||||
let p = place(None, &[small], None, &defaults());
|
||||
assert_eq!(p.normal.size, [1200.0, 680.0]);
|
||||
assert!(p.maximized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_gone_moves_the_window_to_a_connected_one() {
|
||||
let gone = mon("Gone", [1920, 0], [1920, 1080], 1.0);
|
||||
let s = saved(Some([2500, 100]), [800.0, 600.0], 1.0, &gone);
|
||||
let p = place(Some(&s), &[hd()], Some(0), &defaults());
|
||||
assert_eq!(p.monitor, Some(hd().key));
|
||||
let [x, y] = p.position_px.unwrap();
|
||||
assert!((0..=1920 - 800).contains(&x) && (0..=1080 - 600).contains(&y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_dragged_off_screen_comes_back_but_a_visible_bar_stays() {
|
||||
let m = hd();
|
||||
let off = saved(Some([1900, 100]), [800.0, 600.0], 1.0, &m);
|
||||
let p = place(Some(&off), std::slice::from_ref(&m), Some(0), &defaults());
|
||||
assert_eq!(p.position_px, Some([1120, 100]));
|
||||
// 100 pt of the bar still on the monitor: untouched.
|
||||
let ok = saved(Some([1820, 100]), [800.0, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&ok), &[m], Some(0), &defaults()).position_px, Some([1820, 100]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_dpi_prefers_the_saved_monitor_and_keeps_its_scale() {
|
||||
let mac = mon("Mac", [0, 0], [3024, 1964], 2.0);
|
||||
let ext = mon("Ext", [1512, 0], [1920, 1080], 1.0);
|
||||
let s = saved(Some([2000, 100]), [800.0, 600.0], 2.0, &mac);
|
||||
let p = place(Some(&s), &[mac.clone(), ext], Some(0), &defaults());
|
||||
assert_eq!((p.monitor, p.scale, p.position_px), (Some(mac.key), 2.0, Some([2000, 100])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_is_a_strip_not_a_corner() {
|
||||
let m = hd();
|
||||
// Left edge: 100 px of an 800 pt window show on the right of x = -700; the strip is wide enough.
|
||||
let s = saved(Some([-700, 100]), [800.0, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([-700, 100]));
|
||||
// Only 30 px show: moved back.
|
||||
let s = saved(Some([-770, 100]), [800.0, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([0, 100]));
|
||||
// Windows' snapped -7 px offset on the left or top is fine.
|
||||
let s = saved(Some([-7, -7]), [800.0, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([-7, -7]));
|
||||
// Top edge: the strip is 64 pt tall; 40 pt of it visible is not enough.
|
||||
let s = saved(Some([100, -24]), [800.0, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([100, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_area_replaces_screen_room() {
|
||||
let m = hd().with_work_px([0, 0], [1920, 1000]);
|
||||
let p = place(None, &[m], Some(0), &Defaults::new([1200.0, 1100.0], [400.0, 300.0]));
|
||||
assert_eq!(p.normal.size, [1200.0, 1000.0]);
|
||||
assert!(p.maximized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_above_the_top_edge_counts_as_off_screen() {
|
||||
let m = hd();
|
||||
let s = saved(Some([100, -50]), [800.0, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&s), &[m], Some(0), &defaults()).position_px, Some([100, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_change_keeps_points_and_takes_the_new_scale() {
|
||||
let old = hd();
|
||||
let hidpi = mon("B", [0, 0], [3840, 2160], 2.0);
|
||||
let s = saved(Some([200, 100]), [1000.0, 700.0], 1.0, &old);
|
||||
let p = place(Some(&s), std::slice::from_ref(&hidpi), Some(0), &defaults());
|
||||
assert_eq!(p.normal.size, [1000.0, 700.0]);
|
||||
assert_eq!(p.scale, 2.0);
|
||||
assert_eq!(p.monitor, Some(hidpi.key));
|
||||
assert_eq!(p.position_px, Some([200, 100]));
|
||||
assert_eq!(p.normal.pos, [100.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smaller_monitor_clamps_to_usable_area_and_min() {
|
||||
let big = mon("Big", [0, 0], [3840, 2160], 1.0);
|
||||
let s = saved(Some([100, 100]), [3000.0, 1800.0], 1.0, &big);
|
||||
let small = mon("S", [0, 0], [1280, 720], 1.0);
|
||||
let p = place(Some(&s), std::slice::from_ref(&small), Some(0), &defaults());
|
||||
assert_eq!(p.normal.size, [1280.0, 680.0]);
|
||||
assert_eq!(p.position_px, Some([0, 40]));
|
||||
// A monitor below `min`: the monitor wins.
|
||||
let tiny = mon("T", [0, 0], [320, 240], 1.0);
|
||||
let p = place(Some(&s), &[tiny], Some(0), &defaults());
|
||||
assert_eq!(p.normal.size, [320.0, 200.0]);
|
||||
// `min` raises a sliver saved size.
|
||||
let sliver = saved(None, [50.0, 50.0], 1.0, &small);
|
||||
assert_eq!(place(Some(&sliver), &[small], Some(0), &defaults()).normal.size, [400.0, 300.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_origin_monitors_are_valid_places() {
|
||||
let left = mon("L", [-1920, 0], [1920, 1080], 1.0);
|
||||
let above = mon("U", [0, -1080], [1920, 1080], 1.0);
|
||||
let ms = [hd(), left.clone(), above.clone()];
|
||||
let s = saved(Some([-1500, 100]), [800.0, 600.0], 1.0, &left);
|
||||
assert_eq!(place(Some(&s), &ms, Some(0), &defaults()).position_px, Some([-1500, 100]));
|
||||
let s = saved(Some([300, -900]), [800.0, 600.0], 1.0, &above);
|
||||
assert_eq!(place(Some(&s), &ms, Some(0), &defaults()).position_px, Some([300, -900]));
|
||||
// The left monitor is unplugged: back onto the primary, not stuck at a negative x.
|
||||
let s = saved(Some([-1500, 100]), [800.0, 600.0], 1.0, &left);
|
||||
assert_eq!(place(Some(&s), &[hd()], Some(0), &defaults()).position_px, Some([0, 100]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_monitor_wins_when_the_saved_one_is_gone() {
|
||||
let right = mon("R", [1920, 0], [1920, 1080], 1.0);
|
||||
let far = mon("F", [5000, 0], [1920, 1080], 1.0);
|
||||
let gone = mon("Gone", [3840, 0], [1920, 1080], 1.0);
|
||||
let s = saved(Some([6000, 100]), [800.0, 600.0], 1.0, &gone);
|
||||
let p = place(Some(&s), &[hd(), right, far.clone()], Some(0), &defaults());
|
||||
assert_eq!(p.monitor, Some(far.key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_neither_reads_nor_returns_a_position() {
|
||||
let mut d = defaults();
|
||||
d.no_position = true;
|
||||
let m = hd();
|
||||
let s = saved(Some([5000, 5000]), [800.0, 600.0], 1.0, &m);
|
||||
let p = place(Some(&s), std::slice::from_ref(&m), Some(0), &d);
|
||||
assert_eq!(p.position_px, None);
|
||||
assert_eq!(p.normal.size, [800.0, 600.0]);
|
||||
assert_eq!(place(None, &[m], Some(0), &d).position_px, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_saved_at_zoom_one_does_not_shrink_with_zoom() {
|
||||
let m = hd();
|
||||
let mut s = saved(Some([100, 100]), [1000.0, 700.0], 1.0, &m);
|
||||
s.zoom = 1.5;
|
||||
let p = place(Some(&s), &[m], Some(0), &defaults());
|
||||
assert_eq!(p.normal.size, [1000.0, 700.0]);
|
||||
assert_eq!(p.zoom, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximized_keeps_the_normal_rect() {
|
||||
let m = hd();
|
||||
let mut s = saved(Some([100, 100]), [900.0, 600.0], 1.0, &m);
|
||||
s.maximized = true;
|
||||
let p = place(Some(&s), &[m], Some(0), &defaults());
|
||||
assert!(p.maximized);
|
||||
assert_eq!((p.normal.size, p.position_px), ([900.0, 600.0], Some([100, 100])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_needs_the_opt_in() {
|
||||
let m = hd();
|
||||
let mut s = saved(Some([100, 100]), [900.0, 600.0], 1.0, &m);
|
||||
s.fullscreen = true;
|
||||
let mut d = defaults();
|
||||
assert!(!place(Some(&s), std::slice::from_ref(&m), Some(0), &d).fullscreen);
|
||||
d.restore_fullscreen = true;
|
||||
assert!(place(Some(&s), &[m], Some(0), &d).fullscreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_saved_numbers_fall_back_to_a_first_launch() {
|
||||
let m = hd();
|
||||
let mut s = saved(Some([0, 0]), [f32::NAN, 600.0], 1.0, &m);
|
||||
assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).normal.size, [1200.0, 800.0]);
|
||||
s.normal.size = [800.0, 600.0];
|
||||
s.scale = 0.0;
|
||||
s.zoom = 99.0;
|
||||
let p = place(Some(&s), &[m], Some(0), &defaults());
|
||||
assert_eq!((p.scale, p.zoom), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_monitors_keeps_the_saved_geometry() {
|
||||
let m = hd();
|
||||
let s = saved(Some([10, 20]), [800.0, 600.0], 1.0, &m);
|
||||
let p = place(Some(&s), &[], None, &defaults());
|
||||
assert_eq!((p.normal.size, p.position_px), ([800.0, 600.0], Some([10, 20])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
//! `window.json`: the saved [`Placement`] of each window, beside the app's `config.toml`.
|
||||
//!
|
||||
//! ```json
|
||||
//! { "schema_version": 1, "windows": { "main": { ... }, "<key>": { ... } } }
|
||||
//! ```
|
||||
//!
|
||||
//! The folder is chosen by `craft_settings::paths`, so portable installs keep the file in the
|
||||
//! portable folder and `<APP>_CONFIG_DIR` moves it with `config.toml`. Writes are atomic and
|
||||
//! private. A file that does not parse is moved aside (`window.broken-<unix-ts>.json`, the
|
||||
//! `craft_settings::recovery` convention) and the store starts empty. A file with a newer
|
||||
//! `schema_version` is read but never written. Keys this version does not know are written back
|
||||
//! unchanged. Two running instances of one app overwrite each other; the last writer wins.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use craft_atomic::{NewFilePermissions, WriteOptions};
|
||||
use craft_settings::paths::{self, Location, Portable, Probe};
|
||||
use craft_settings::recovery;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::place::{Placement, sane};
|
||||
|
||||
/// The file name beside `config.toml`.
|
||||
pub const FILE_NAME: &str = "window.json";
|
||||
/// The `schema_version` this code writes.
|
||||
pub const SCHEMA_VERSION: u64 = 1;
|
||||
/// The key of the app's first window.
|
||||
pub const MAIN: &str = "main";
|
||||
|
||||
/// Why a store does not write.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReadOnly {
|
||||
/// The file has a newer `schema_version` than this code knows.
|
||||
NewerSchema(u64),
|
||||
/// The file exists but could not be read (permissions, say); it is left alone.
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// A write [`Store::take_pending`] prepared.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pending {
|
||||
pub path: PathBuf,
|
||||
pub bytes: Vec<u8>,
|
||||
/// Pass to [`Store::mark_saved`] after the write succeeded.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Writes `pending` atomically and privately, creating the folder. Needs no access to the store.
|
||||
pub fn write_pending(pending: &Pending) -> io::Result<()> {
|
||||
if let Some(dir) = pending.path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private).atomic_write(&pending.path, &pending.bytes)
|
||||
}
|
||||
|
||||
/// The saved placements of an app's windows.
|
||||
#[derive(Debug)]
|
||||
pub struct Store {
|
||||
path: Option<PathBuf>,
|
||||
/// Entries stay raw so one that fails to parse is neither lost nor fatal to the others.
|
||||
windows: BTreeMap<String, Value>,
|
||||
/// Top-level keys other than `schema_version` and `windows`.
|
||||
extra: Map<String, Value>,
|
||||
read_only: Option<ReadOnly>,
|
||||
/// Counts changes; the store is dirty while it is ahead of `saved`.
|
||||
generation: u64,
|
||||
saved: u64,
|
||||
moved_aside: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// The store for `app_id` in the folder `craft_settings` picks for its `config.toml`, with
|
||||
/// the suite's default portable marker. In an isolated or in-memory configuration
|
||||
/// (`<APP>_CONFIG_DIR` empty) nothing is read or written.
|
||||
pub fn open(app_id: &str) -> Self {
|
||||
Self::open_with(app_id, &Portable::DEFAULT, &Location::Auto, &Probe::default())
|
||||
}
|
||||
|
||||
/// [`open`](Self::open) with the app's own portable names, location override and probe: the
|
||||
/// same arguments its `craft_settings` options use.
|
||||
pub fn open_with(app_id: &str, portable: &Portable, location: &Location, probe: &Probe) -> Self {
|
||||
match paths::resolve(app_id, portable, location, probe).dir {
|
||||
Some(dir) => Self::at(&dir.join(FILE_NAME)),
|
||||
None => Self::in_memory(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A store backed by the file at `path`. A missing file is an empty store.
|
||||
pub fn at(path: &Path) -> Self {
|
||||
let mut store = Self { path: Some(path.to_path_buf()), ..Self::in_memory() };
|
||||
store.load();
|
||||
store
|
||||
}
|
||||
|
||||
/// A store that reads and writes nothing: tests, agents, the web.
|
||||
pub fn in_memory() -> Self {
|
||||
Self { path: None, windows: BTreeMap::new(), extra: Map::new(), read_only: None, generation: 0, saved: 0, moved_aside: None }
|
||||
}
|
||||
|
||||
/// The file this store writes, if it has one.
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
|
||||
/// Where a damaged file was moved to while opening, if one was.
|
||||
pub fn moved_aside(&self) -> Option<&Path> {
|
||||
self.moved_aside.as_deref()
|
||||
}
|
||||
|
||||
/// Why [`save`](Self::save) writes nothing, if so.
|
||||
pub fn read_only(&self) -> Option<&ReadOnly> {
|
||||
self.read_only.as_ref()
|
||||
}
|
||||
|
||||
/// The placement saved under `key`; `None` when absent or not a placement.
|
||||
pub fn get(&self, key: &str) -> Option<Placement> {
|
||||
serde_json::from_value(self.windows.get(key)?.clone()).ok()
|
||||
}
|
||||
|
||||
/// Replaces the placement under `key`. Fields of the old entry that this version does not know
|
||||
/// stay. A placement with a non-finite size is ignored. Nothing is written until
|
||||
/// [`save`](Self::save) or [`take_pending`](Self::take_pending).
|
||||
pub fn set(&mut self, key: &str, placement: &Placement) {
|
||||
let Some(placement) = sane(placement) else { return };
|
||||
let Ok(Value::Object(new)) = serde_json::to_value(&placement) else { return };
|
||||
let merged = match self.windows.get(key) {
|
||||
Some(Value::Object(old)) => {
|
||||
let mut m = old.clone();
|
||||
m.extend(new);
|
||||
m
|
||||
}
|
||||
_ => new,
|
||||
};
|
||||
let merged = Value::Object(merged);
|
||||
if self.windows.get(key) != Some(&merged) {
|
||||
self.windows.insert(key.to_owned(), merged);
|
||||
self.generation += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets `key`.
|
||||
pub fn remove(&mut self, key: &str) {
|
||||
if self.windows.remove(key).is_some() {
|
||||
self.generation += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys with an entry, in order.
|
||||
pub fn keys(&self) -> impl Iterator<Item = &str> {
|
||||
self.windows.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Whether something changed since the last successful write.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.generation != self.saved
|
||||
}
|
||||
|
||||
/// The write that would bring the file up to date, or `None` when there is nothing to write
|
||||
/// (in memory, read-only, or unchanged). Write it with [`write_pending`], without holding any
|
||||
/// lock on the store, then report success with [`mark_saved`](Self::mark_saved).
|
||||
pub fn take_pending(&self) -> Option<Pending> {
|
||||
let path = self.path.clone().filter(|_| self.read_only.is_none() && self.is_dirty())?;
|
||||
let mut doc = self.extra.clone();
|
||||
doc.insert("schema_version".into(), json!(SCHEMA_VERSION));
|
||||
doc.insert("windows".into(), Value::Object(self.windows.clone().into_iter().collect()));
|
||||
let mut bytes = serde_json::to_vec_pretty(&Value::Object(doc)).ok()?;
|
||||
bytes.push(b'\n');
|
||||
Some(Pending { path, bytes, generation: self.generation })
|
||||
}
|
||||
|
||||
/// Records that [`Pending::generation`] reached the disk. The store stays dirty if it changed
|
||||
/// again meanwhile, and after a failed write (do not call this then), so the next
|
||||
/// [`take_pending`](Self::take_pending) retries.
|
||||
pub fn mark_saved(&mut self, generation: u64) {
|
||||
self.saved = self.saved.max(generation.min(self.generation));
|
||||
}
|
||||
|
||||
/// [`take_pending`](Self::take_pending), [`write_pending`] and [`mark_saved`](Self::mark_saved)
|
||||
/// in one call. `Ok(true)` when written; `Ok(false)` when there was nothing to write.
|
||||
pub fn save(&mut self) -> io::Result<bool> {
|
||||
let Some(pending) = self.take_pending() else { return Ok(false) };
|
||||
write_pending(&pending)?;
|
||||
self.mark_saved(pending.generation);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn load(&mut self) {
|
||||
let Some(path) = self.path.clone() else { return };
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return,
|
||||
// Invalid UTF-8 is damage; anything else may be transient, so leave the file be.
|
||||
Err(e) if e.kind() == io::ErrorKind::InvalidData => return self.start_over(&path),
|
||||
Err(_) => {
|
||||
self.read_only = Some(ReadOnly::Unreadable);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(mut doc) = serde_json::from_str::<Value>(&text).ok().and_then(|v| match v {
|
||||
Value::Object(m) => Some(m),
|
||||
_ => None,
|
||||
}) else {
|
||||
return self.start_over(&path);
|
||||
};
|
||||
let Some(version) = doc.remove("schema_version").and_then(|v| v.as_u64()) else {
|
||||
return self.start_over(&path);
|
||||
};
|
||||
// A newer file is never judged by this version's shape rules, so never moved aside.
|
||||
if version > SCHEMA_VERSION {
|
||||
self.read_only = Some(ReadOnly::NewerSchema(version));
|
||||
if let Some(Value::Object(m)) = doc.remove("windows") {
|
||||
self.windows = m.into_iter().collect();
|
||||
}
|
||||
self.extra = doc;
|
||||
return;
|
||||
}
|
||||
let Some(Value::Object(windows)) = doc.remove("windows") else {
|
||||
return self.start_over(&path);
|
||||
};
|
||||
self.windows = windows.into_iter().collect();
|
||||
self.extra = doc;
|
||||
}
|
||||
|
||||
fn start_over(&mut self, path: &Path) {
|
||||
match recovery::move_aside(path) {
|
||||
Ok(to) => self.moved_aside = Some(to),
|
||||
// Cannot rename it away: do not overwrite what we could not set aside.
|
||||
Err(_) => self.read_only = Some(ReadOnly::Unreadable),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::restore::{Monitor, MonitorKey};
|
||||
|
||||
fn sample(x: i32) -> Placement {
|
||||
let m = Monitor::new(MonitorKey::new("DP-1", [1920, 1080]), [0, 0], [1920, 1080], 1.0);
|
||||
let mut p = crate::restore::place(None, &[m], Some(0), &crate::restore::Defaults::new([800.0, 600.0], [300.0, 200.0]));
|
||||
p.position_px = Some([x, 10]);
|
||||
p
|
||||
}
|
||||
|
||||
fn siblings(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_through_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested").join(FILE_NAME);
|
||||
let mut s = Store::at(&path);
|
||||
assert!(s.get(MAIN).is_none());
|
||||
s.set(MAIN, &sample(5));
|
||||
s.set("doc-2", &sample(6));
|
||||
assert!(s.save().unwrap());
|
||||
assert!(!s.save().unwrap(), "nothing changed");
|
||||
let again = Store::at(&path);
|
||||
assert_eq!(again.get(MAIN), Some(sample(5)));
|
||||
assert_eq!(again.get("doc-2"), Some(sample(6)));
|
||||
assert_eq!(again.keys().collect::<Vec<_>>(), ["doc-2", MAIN]);
|
||||
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(v["schema_version"], 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_file_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
let mut s = Store::at(&path);
|
||||
s.set(MAIN, &sample(1));
|
||||
s.save().unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_moved_aside_and_the_store_starts_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
let mut s = Store::at(&path);
|
||||
assert!(s.get(MAIN).is_none() && s.read_only().is_none());
|
||||
let aside = s.moved_aside().unwrap().to_path_buf();
|
||||
assert_eq!(std::fs::read_to_string(aside).unwrap(), "{ not json");
|
||||
assert!(!path.exists());
|
||||
s.set(MAIN, &sample(1));
|
||||
s.save().unwrap();
|
||||
assert!(Store::at(&path).get(MAIN).is_some());
|
||||
assert_eq!(siblings(dir.path()).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_without_a_schema_version_counts_as_corrupt() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
std::fs::write(&path, r#"{"windows":{}}"#).unwrap();
|
||||
assert!(Store::at(&path).moved_aside().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_schema_is_read_but_never_written() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
let mut doc = json!({ "schema_version": 2, "windows": {}, "future": [1] });
|
||||
doc["windows"][MAIN] = serde_json::to_value(sample(7)).unwrap();
|
||||
let text = serde_json::to_string(&doc).unwrap();
|
||||
std::fs::write(&path, &text).unwrap();
|
||||
let mut s = Store::at(&path);
|
||||
assert_eq!(s.read_only(), Some(&ReadOnly::NewerSchema(2)));
|
||||
assert_eq!(s.get(MAIN), Some(sample(7)));
|
||||
s.set(MAIN, &sample(8));
|
||||
assert!(!s.save().unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
|
||||
assert_eq!(siblings(dir.path()), [FILE_NAME]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_survive_a_write() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
let mut doc = json!({ "schema_version": 1, "note": {"a": 1}, "windows": { "odd": {"unexpected": true} } });
|
||||
doc["windows"][MAIN] = serde_json::to_value(sample(1)).unwrap();
|
||||
std::fs::write(&path, doc.to_string()).unwrap();
|
||||
let mut s = Store::at(&path);
|
||||
assert!(s.get("odd").is_none());
|
||||
s.set(MAIN, &sample(2));
|
||||
s.save().unwrap();
|
||||
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(v["note"], json!({"a": 1}));
|
||||
assert_eq!(v["windows"]["odd"], json!({"unexpected": true}));
|
||||
assert_eq!(Store::at(&path).get(MAIN), Some(sample(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_schema_with_an_unexpected_shape_is_not_moved_aside() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
std::fs::write(&path, r#"{"schema_version":3,"windows":[1,2]}"#).unwrap();
|
||||
let s = Store::at(&path);
|
||||
assert_eq!(s.read_only(), Some(&ReadOnly::NewerSchema(3)));
|
||||
assert!(s.moved_aside().is_none() && path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_placements_are_never_stored() {
|
||||
let mut s = Store::in_memory();
|
||||
let mut p = sample(1);
|
||||
p.normal.size[0] = f32::NAN;
|
||||
s.set(MAIN, &p);
|
||||
assert!(s.get(MAIN).is_none() && !s.is_dirty());
|
||||
let mut p = sample(1);
|
||||
p.normal.pos[1] = f32::INFINITY;
|
||||
s.set(MAIN, &p);
|
||||
assert_eq!(s.get(MAIN).unwrap().normal.pos, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_keeps_unknown_fields_of_the_entry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_NAME);
|
||||
let mut entry = serde_json::to_value(sample(1)).unwrap();
|
||||
entry["later"] = json!("x");
|
||||
std::fs::write(&path, json!({"schema_version": 1, "windows": {MAIN: entry}}).to_string()).unwrap();
|
||||
let mut s = Store::at(&path);
|
||||
s.set(MAIN, &sample(2));
|
||||
s.save().unwrap();
|
||||
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(v["windows"][MAIN]["later"], "x");
|
||||
assert_eq!(Store::at(&path).get(MAIN), Some(sample(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_clears_only_after_the_write_is_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut s = Store::at(&dir.path().join(FILE_NAME));
|
||||
s.set(MAIN, &sample(1));
|
||||
let first = s.take_pending().unwrap();
|
||||
s.set(MAIN, &sample(2));
|
||||
write_pending(&first).unwrap();
|
||||
s.mark_saved(first.generation);
|
||||
assert!(s.is_dirty(), "changed again while the first write was in flight");
|
||||
let second = s.take_pending().unwrap();
|
||||
assert!(s.is_dirty(), "taking a write does not clear it");
|
||||
write_pending(&second).unwrap();
|
||||
s.mark_saved(second.generation);
|
||||
assert!(!s.is_dirty() && s.take_pending().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_store_keeps_values_and_writes_nothing() {
|
||||
let mut s = Store::in_memory();
|
||||
s.set(MAIN, &sample(3));
|
||||
assert_eq!(s.get(MAIN), Some(sample(3)));
|
||||
assert!(!s.save().unwrap());
|
||||
assert!(s.path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_puts_the_file_beside_config_toml() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let s = Store::open_with("app", &Portable::DEFAULT, &Location::Dir(dir.path().to_path_buf()), &Probe::default());
|
||||
assert_eq!(s.path(), Some(dir.path().join(FILE_NAME).as_path()));
|
||||
let mem = Store::open_with("app", &Portable::DEFAULT, &Location::Memory, &Probe::default());
|
||||
assert!(mem.path().is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user