git.lucas.co / cce-compositor
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 }