Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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()