Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
src/server/min_sizes.rs (5K)
1 //! Minimum sizes X11 apps have revealed, kept across restarts.
2 //!
3 //! Wine carries no minimum into WM_NORMAL_HINTS for a resizable window, so
4 //! the compositor learns one from a refusal: during an interactive resize,
5 //! an app asking for more than it was just given on an axis would not go
6 //! that small (`xwayland_window::learned_min`). Learned once per launch,
7 //! every first drag past the minimum snapped back once, so the minimum is
8 //! stored in `$XDG_STATE_HOME/cce/min-sizes.json` and handed to the window
9 //! again when it maps.
10 //!
11 //! An entry is keyed by app_id, program (argv[0] — every Proton window is
12 //! `steam_proton`, its program is the exe) and title, since one program's
13 //! windows have their own minimums. Sizes are X11 pixels, what the app
14 //! itself measured, so a change of output scale still converts correctly.
15 //! A width or height of 0 is "not learned".
16
17 #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
18 pub struct MinSize {
19 pub app_id: String,
20 pub program: String,
21 pub title: String,
22 pub width: u32,
23 pub height: u32,
24 }
25
26 #[derive(Default)]
27 pub struct MinSizes {
28 entries: Vec<MinSize>,
29 loaded: bool,
30 }
31
32 fn file_path() -> Option<std::path::PathBuf> {
33 crate::config::default_state_path().map(|p| std::path::Path::new(&p).with_file_name("min-sizes.json"))
34 }
35
36 impl MinSizes {
37 fn ensure_loaded(&mut self) {
38 if self.loaded {
39 return;
40 }
41 self.loaded = true;
42 let Some(path) = file_path() else { return };
43 let Ok(json) = std::fs::read_to_string(&path) else { return };
44 match serde_json::from_str(&json) {
45 Ok(entries) => self.entries = entries,
46 Err(e) => log::warn!("min-sizes: ignoring unreadable {}: {}", path.display(), e),
47 }
48 }
49
50 /// The stored minimum for this window, X11 pixels.
51 pub fn get(&mut self, app_id: &str, program: &str, title: &str) -> Option<(u32, u32)> {
52 self.ensure_loaded();
53 self.entries
54 .iter()
55 .find(|e| e.app_id == app_id && e.program == program && e.title == title)
56 .map(|e| (e.width, e.height))
57 }
58
59 /// Every stored entry, in file order (`ccectl min-size list`).
60 pub fn entries(&mut self) -> &[MinSize] {
61 self.ensure_loaded();
62 &self.entries
63 }
64
65 /// Store this window's minimum, X11 pixels; written to disk at once
66 /// (it changes only on a refusal, a handful of times per app, ever).
67 /// 0x0 forgets the entry.
68 pub fn set(&mut self, app_id: &str, program: &str, title: &str, width: u32, height: u32) {
69 self.ensure_loaded();
70 if !update(&mut self.entries, app_id, program, title, width, height) {
71 return;
72 }
73 let Some(path) = file_path() else { return };
74 if let Some(parent) = path.parent() {
75 let _ = std::fs::create_dir_all(parent);
76 }
77 match serde_json::to_string_pretty(&self.entries) {
78 Ok(json) => {
79 if let Err(e) = std::fs::write(&path, json) {
80 log::error!("min-sizes: failed to write {}: {}", path.display(), e);
81 }
82 }
83 Err(e) => log::error!("min-sizes: failed to serialize: {}", e),
84 }
85 }
86 }
87
88 /// Apply one entry's new size to the table; whether anything changed.
89 fn update(entries: &mut Vec<MinSize>, app_id: &str, program: &str, title: &str, width: u32, height: u32) -> bool {
90 let pos = entries.iter().position(|e| e.app_id == app_id && e.program == program && e.title == title);
91 match (pos, width == 0 && height == 0) {
92 (Some(i), true) => {
93 entries.remove(i);
94 true
95 }
96 (Some(i), false) => {
97 let e = &mut entries[i];
98 let changed = (e.width, e.height) != (width, height);
99 e.width = width;
100 e.height = height;
101 changed
102 }
103 (None, true) => false,
104 (None, false) => {
105 entries.push(MinSize {
106 app_id: app_id.to_string(),
107 program: program.to_string(),
108 title: title.to_string(),
109 width,
110 height,
111 });
112 true
113 }
114 }
115 }
116
117 #[cfg(test)]
118 mod tests {
119 use super::*;
120
121 const UPC: &str = r"C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\upc.exe";
122
123 #[test]
124 fn entries_are_added_changed_and_forgotten() {
125 let mut t = Vec::new();
126 assert!(update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 2428, 0));
127 assert!(!update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 2428, 0));
128 assert!(update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 2428, 1608));
129 // Another window of the same program is its own entry.
130 assert!(update(&mut t, "steam_proton", UPC, "Settings", 800, 600));
131 assert_eq!(t.len(), 2);
132 assert_eq!((t[0].width, t[0].height), (2428, 1608));
133 assert!(update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 0, 0));
134 assert!(!update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 0, 0));
135 assert_eq!(t.len(), 1);
136 }
137 }