git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit9a712b136a94371d4d0d1d0eb015cddb3324a14e
parentcb8d26a84c
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 21:49
A title mode_rule can float a settings window over its app

Obsidian opens Settings as a parentless toplevel with the main window's
app_id, so it borrowed the main window's saved entry by app_id alone:
Tiled, mode-locked, at the main window's size, then pushed to a free cell
by the overlap rule. A `mode_rule` on the title could not help, since the
lock beats the rules and Electron sets the app_id before the title.

Now an untitled window of an app a title rule names waits for its title
before restoring; a matching title rule outranks an entry that is not the
window's own; and a rule with `over_sibling=(bool)true` makes the window a
satellite while a sibling is mapped: never restored or saved, self-sized,
centred over the sibling and slid into view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md                    |  27 +++++++
 src/server/config.rs         |  10 ++-
 src/server/window.rs         | 172 +++++++++++++++++++++++++++++++++++++++++++
 src/server/window_manager.rs |  18 ++++-
 4 files changed, 225 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2509fea7..5b741b33 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -792,6 +792,33 @@ what the user pans along (cce-data-editor parked left of the first column
 came back mid-view every login before 2026-09-14). The recall is for a
 window with no tiled neighbour within a screen.
 
+**A settings window opens over its app** with a `mode_rule` that names it
+by title and says `over_sibling`:
+
+```kdl
+mode_rule mode="floating" app_id="md.obsidian.Obsidian" title="Settings" over_sibling=(bool)true
+```
+
+Obsidian (and Electron apps generally) open Settings as a PARENTLESS
+toplevel, so nothing marks it a dialog; until 2026-09-29 it borrowed the
+main window's saved entry by app_id — Tiled, latched, at the main window's
+size — and the overlap rule pushed it to a free cell. Three things in
+`try_restore` make the rule work. An untitled window of an app some title
+rule names **waits for its title** before restoring at all (Electron sets
+the app_id first, and a rule on the title cannot be judged without one).
+A matching title rule then **outranks a borrowed entry** — never the
+window's own (`rule_skips_restore`), so a plain title rule still honours a
+geometry the user gave that window. And with `over_sibling`, while a mapped
+window of the same app_id is up (`find_sibling`: the focused one when it
+qualifies), the window is a **satellite** (`Window::satellite`): no saved
+state applies, it sizes itself, `try_center_on_sibling` centres it over the
+sibling (`centered_over`, slid into the view on any axis it fits) with the
+same commit-time redo the view-centred modals use, and `save_state` neither
+saves it nor lets it keep the app's `last_window_states` slot. Reproduce
+with `verify/clients` `float-pair --dialog-honours-configure`: tile the
+main window, and the "Authorize" window maps Tiled at 1404x1076 without a
+rule, Floating at its own 400x370 over the main window with one.
+
 **A client reconnecting maps unfocused**: a window that vanishes without
 the compositor asking it to close (`Window::unmap` → `note_vanished`) lets
 the next window of the same app_id AND the same program (`proc_args`
diff --git a/src/server/config.rs b/src/server/config.rs
index 770face0..bb181e7c 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -303,6 +303,11 @@ pub struct ModeRule {
     pub tag: i32,
     pub circular: bool,
     pub ssd: Option<bool>,
+    /// The window is a satellite of its app's main window (a settings
+    /// window opened as a parentless toplevel): it is never restored from
+    /// saved state, sizes itself, and maps centred over a mapped sibling of
+    /// the same app_id. See `Window::try_center_on_sibling`.
+    pub over_sibling: bool,
 }
 
 pub use cce_window_manager::api::Action;
@@ -1033,6 +1038,7 @@ pub struct ModeRuleConfig {
     pub tag: Option<i64>,
     pub circular: Option<bool>,
     pub ssd: Option<bool>,
+    pub over_sibling: Option<bool>,
 }
 
 #[derive(Debug, Deserialize)]
@@ -1787,7 +1793,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                 let tag = get_prop_i64_opt(node, "tag");
                 let circular = get_prop_bool_opt(node, "circular");
                 let ssd = get_prop_bool_opt(node, "ssd");
-                mode_rule.push(ModeRuleConfig { mode, app_id, title, single, tag, circular, ssd });
+                let over_sibling = get_prop_bool_opt(node, "over_sibling");
+                mode_rule.push(ModeRuleConfig { mode, app_id, title, single, tag, circular, ssd, over_sibling });
             }
             "tag_layout" => {
                 let tag = get_prop_i64(node, "tag", 0);
@@ -2973,6 +2980,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             tag: rule.tag.unwrap_or(-1) as i32,
             circular: rule.circular.unwrap_or(false),
             ssd: rule.ssd,
+            over_sibling: rule.over_sibling.unwrap_or(false),
         });
     }
 
diff --git a/src/server/window.rs b/src/server/window.rs
index a3d5fb1e..6130abb7 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -112,6 +112,28 @@ impl Border {
     }
 }
 
+/// Whether a window a title rule matches skips the saved-state restore: it
+/// does when it opens over a sibling, and when the only entry on offer is
+/// one the app_id-only pass would lend it from another window.
+pub fn rule_skips_restore(has_sibling: bool, own_entry: bool) -> bool {
+    has_sibling || !own_entry
+}
+
+/// Origin that centres a `size` window over `sibling` (x, y, w, h), then
+/// slides it into `view` (x, y, w, h) on each axis it fits on — a sibling
+/// lying half off screen must not take its settings window with it. All in
+/// virtual units.
+pub fn centered_over(sibling: (f64, f64, f64, f64), size: (f64, f64), view: (f64, f64, f64, f64)) -> (f64, f64) {
+    let axis = |s0: f64, s_len: f64, len: f64, v0: f64, v_len: f64| {
+        let c = s0 + (s_len - len) / 2.0;
+        if len <= v_len { c.clamp(v0, v0 + v_len - len) } else { c }
+    };
+    (
+        axis(sibling.0, sibling.2, size.0, view.0, view.2).round(),
+        axis(sibling.1, sibling.3, size.1, view.1, view.3).round(),
+    )
+}
+
 /// A window-scale corner radius as scenefx should consume it: the configured
 /// nominal (circle-equivalent) radius widened by the curvature-match span
 /// factor, capped at half the smaller content extent so opposite corners
@@ -481,6 +503,10 @@ pub struct Window {
     /// map sees `mapped_size_hint`'s fallback and misses by half the
     /// difference between that and the truth.
     pub pending_view_center: bool,
+    /// Matched a `mode_rule` with `over_sibling` while a sibling was up: a
+    /// settings-style window of a running app. Never restored from saved
+    /// state, never saved, and centred over that sibling at map.
+    pub satellite: bool,
     /// True only when the restored geometry came out of the startup restore queue
     /// (`state.json`'s window list). A window reopened later in the session matches
     /// `last_window_states` instead and leaves this false, so it still counts as a
@@ -803,6 +829,7 @@ impl Window {
             restored: false,
             hint_placed: false,
             pending_view_center: false,
+            satellite: false,
             session_restored: false,
             restored_focused: false,
             closed: false,
@@ -1210,6 +1237,49 @@ impl Window {
             return;
         }
         let title_str = self.get_title_string().unwrap_or_default();
+        // A `mode_rule` with `title=` names one window of an app, and it can
+        // only be judged once the title is in. Chromium/Electron set the
+        // app_id first, and restoring on that notify handed Obsidian's
+        // Settings window the MAIN window's entry by app_id alone — Tiled,
+        // latched, at the main window's size — before the rule that floats
+        // it could match. So an untitled window of an app some title rule
+        // names waits for its title; `map` calls back in here regardless.
+        if title_str.is_empty() && self.state != WindowState::Mapped {
+            let wm = &(*self.server).wm;
+            if wm.mode_rules.iter().any(|r| {
+                r.title_pattern.is_some()
+                    && (r.app_id_pattern == "*" || app_id_str.contains(&r.app_id_pattern))
+            }) {
+                return;
+            }
+        }
+        // With the title in, a title rule outranks an entry that is not this
+        // window's own: the rule is about this window, the entry about
+        // another one of the same app. An `over_sibling` rule outranks its
+        // own entry too while a sibling is up — the window goes where the
+        // sibling is, at the size it asks for.
+        {
+            let wm = &(*self.server).wm;
+            let rule = wm
+                .get_rule_for_window(self as *mut Window)
+                .filter(|r| r.title_pattern.is_some())
+                .map(|r| r.over_sibling);
+            if let Some(over_sibling) = rule {
+                let has_sibling = over_sibling && !self.find_sibling(&app_id_str).is_null();
+                let own_entry = wm.has_titled_saved_entry(&app_id_str, &title_str);
+                if rule_skips_restore(has_sibling, own_entry) {
+                    log::info!(
+                        "Not restoring saved state for {:?} ({}): a title rule matches it{}",
+                        title_str,
+                        app_id_str,
+                        if has_sibling { ", and it opens over its sibling" } else { "" }
+                    );
+                    self.satellite = has_sibling;
+                    self.restored = true;
+                    return;
+                }
+            }
+        }
         // Which program this window belongs to, so an entry matched by
         // app_id alone is only borrowed from a run of the same one — see
         // `window_manager::same_program`.
@@ -1393,6 +1463,70 @@ impl Window {
         }
     }
 
+    /// The window a satellite opens over: a mapped, visible window of the
+    /// same app_id that is not itself a satellite — the focused one when it
+    /// qualifies, since that is where the user asked for the settings.
+    unsafe fn find_sibling(&self, app_id: &str) -> *mut Window {
+        let me = self as *const Window;
+        let wm = &(*self.server).wm;
+        let qualifies = |w: *mut Window| {
+            !w.is_null()
+                && w as *const Window != me
+                && !(*w).closed
+                && !(*w).minimized
+                && !(*w).satellite
+                && matches!((*w).state, WindowState::Mapped)
+                && (*w).get_app_id_string().as_deref() == Some(app_id)
+        };
+        let focused = wm.focused_window();
+        if qualifies(focused) {
+            return focused;
+        }
+        wm.windows.iter().copied().find(|&w| qualifies(w)).unwrap_or(std::ptr::null_mut())
+    }
+
+    /// Centre a satellite over its sibling. Like `try_center_on_view` this
+    /// owns the POSITION only, and latches a redo for the commit that
+    /// brings the window's real size (`pending_view_center`).
+    unsafe fn try_center_on_sibling(&mut self) {
+        if !self.satellite {
+            return;
+        }
+        self.minimized = false;
+        self.pending_view_center = self.box_geom.width <= 0 || self.box_geom.height <= 0;
+        self.apply_sibling_centering();
+    }
+
+    unsafe fn apply_sibling_centering(&mut self) {
+        let app_id = self.get_app_id_string().unwrap_or_default();
+        let sibling = self.find_sibling(&app_id);
+        if sibling.is_null() {
+            return;
+        }
+        let (_, _, vp_w, vp_h) = self.first_enabled_output_box();
+        let wm = &(*self.server).wm;
+        let zoom = wm.desk_zoom.max(0.01);
+        let (w, h) = self.mapped_size_hint();
+        let (sw, sh) = (*sibling).mapped_size_hint();
+        let (x, y) = centered_over(
+            ((*sibling).virtual_x, (*sibling).virtual_y, sw, sh),
+            (w, h),
+            (wm.desk_pan_x, wm.desk_pan_y, vp_w / zoom, vp_h / zoom),
+        );
+        self.virtual_x = x;
+        self.virtual_y = y;
+        self.hint_placed = true;
+        log::info!(
+            "satellite centred over sibling: app_id={} size=({:.0}x{:.0}) sibling={:?} virtual=({:.1},{:.1})",
+            app_id,
+            w,
+            h,
+            (*sibling).get_title_string().unwrap_or_default(),
+            x,
+            y
+        );
+    }
+
     /// Step an origin diagonally until no mapped sibling of `app_id` (any
     /// window but this one) has its top-left within a few pixels of it.
     /// Bounded, so a pathological pile of siblings cannot walk a window off
@@ -1817,6 +1951,10 @@ impl Window {
     /// The centering itself, split out so the self-sizing commit path can redo
     /// it once the client's real size lands.
     unsafe fn apply_view_centering(&mut self) {
+        if self.satellite {
+            self.apply_sibling_centering();
+            return;
+        }
         let (_, _, vp_w, vp_h) = self.first_enabled_output_box();
         let wm = &(*self.server).wm;
         let zoom = wm.desk_zoom.max(0.01);
@@ -1860,6 +1998,7 @@ impl Window {
         // Last: a session modal's placement is not negotiable, so it wins
         // over both the remembered geometry and any stale place-next hint.
         self.try_center_on_view();
+        self.try_center_on_sibling();
         // After every placement decision, including the invocation-square one:
         // whichever chose this spot, a tiled window must not open stacked on
         // another. The anchor rule already avoids that when any corner is
@@ -5840,3 +5979,36 @@ mod handle_disc_tests {
         assert!((d + r - 40.0).abs() < 1e-9);
     }
 }
+
+#[cfg(test)]
+mod satellite_tests {
+    use super::*;
+
+    const VIEW: (f64, f64, f64, f64) = (0.0, 0.0, 1920.0, 1200.0);
+
+    #[test]
+    fn centred_on_a_sibling_in_view() {
+        let at = centered_over((200.0, 100.0, 1400.0, 1000.0), (800.0, 600.0), VIEW);
+        assert_eq!(at, (500.0, 300.0));
+    }
+
+    #[test]
+    fn slides_into_view_when_the_sibling_hangs_off_it() {
+        let at = centered_over((-1000.0, 900.0, 1400.0, 1000.0), (800.0, 600.0), VIEW);
+        assert_eq!(at, (0.0, 600.0));
+    }
+
+    #[test]
+    fn larger_than_the_view_stays_centred_on_the_sibling() {
+        let at = centered_over((0.0, 0.0, 1000.0, 1000.0), (2000.0, 600.0), VIEW);
+        assert_eq!(at, (-500.0, 200.0));
+    }
+
+    #[test]
+    fn a_title_rule_beats_a_borrowed_entry_only() {
+        assert!(rule_skips_restore(false, false));
+        assert!(!rule_skips_restore(false, true));
+        assert!(rule_skips_restore(true, true));
+        assert!(rule_skips_restore(true, false));
+    }
+}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index b2cf7530..8602d35c 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1268,7 +1268,12 @@ impl WindowManager {
             // only `steam_proton` entry. Skip it, and scrub what it saved
             // before this rule (or before its no-activate state arrived,
             // which can be after map) so a poisoned state file heals.
-            if (*w).is_shy() {
+            // A satellite (`mode_rule over_sibling`) is skipped and scrubbed
+            // the same way: it is placed over its sibling, never restored,
+            // and saved it would take the app's one `last_window_states`
+            // slot — the main window then reopens at a settings window's
+            // size.
+            if (*w).is_shy() || (*w).satellite {
                 if let Some(program) = args.first() {
                     shy.push((app_id.clone(), title.clone(), program.clone()));
                 }
@@ -1584,6 +1589,16 @@ impl WindowManager {
         None
     }
 
+    /// Whether a saved entry describes THIS window — same app_id and a
+    /// title the first two matcher passes would accept — as opposed to one
+    /// the app_id-only pass would merely lend it.
+    pub fn has_titled_saved_entry(&self, app_id: &str, title: &str) -> bool {
+        self.restore_queue
+            .iter()
+            .chain(self.last_window_states.iter())
+            .any(|w| w.app_id == app_id && (titles_match(title, &w.title) || titles_resemble(title, &w.title)))
+    }
+
     /// `program` as for `match_and_remove_restore_state`.
     pub unsafe fn match_last_window_state(
         &self,
@@ -6179,6 +6194,7 @@ impl WindowManager {
                     tag: -1,
                     circular: false,
                     ssd: None,
+                    over_sibling: false,
                 });
                 self.dirty_windowing();
                 "ok\n".to_string()