Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
mode_rule: a window with no title matches title=""
get_rule_for_window tested title rules only against a title the client
had set, so no rule could name a window that never sets one. The Claude
app's quick-entry popup is such a window: it borrowed the main window's
Tiled entry, drawing the border at the main window's size and panning the
camera to it on focus. An unset title now counts as "", which only a
title="" rule matches. float-pair gains --untitled-dialog to reproduce it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 10 +++++++
src/server/window_manager.rs | 54 +++++++++++++++++++++++++++---------
verify/clients/src/bin/float_pair.rs | 11 ++++++--
3 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 19aa4dc1..1c6ebf80 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -929,6 +929,16 @@ title reaches the second. Reproduce with `float-pair --app-id X
center` rules: pan the camera, respawn, and `ctl windows` shows the
dialog at the screen's centre while the main window sits where it was.
+**A window with no title matches `title=""`** (since 2026-10-05,
+`mode_rule_matches`): an unset title counts as the empty string, which
+every `title=` substring rule is tested against, so only `title=""` can
+reach it. The Claude app's quick-entry popup is an untitled parentless
+toplevel under the main window's app_id; with no rule able to name it, it
+borrowed the main window's Tiled entry (the border drawn at the main
+window's size around a far smaller popup, and focus panning the camera to
+it). The live config names the main window `title="Claude"` first and then
+`title="" center`. `float-pair --untitled-dialog` reproduces it.
+
**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/window_manager.rs b/src/server/window_manager.rs
index d47668f1..f025d1fa 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -3229,6 +3229,17 @@ pub struct WindowManagerSentCompat {
pub outputs: ffi::wl_list,
pub output_config: *mut ffi::wlr_output_configuration_v1,
}
+/// Whether `rule` names a window with this app_id and title. Both match
+/// as substrings. A window that never set a title matches as the empty
+/// string, so `title=""` (which every title contains) reaches an untitled
+/// popup too — the Claude app's quick-entry window sets none, and without
+/// this it fell through to its main window's saved entry.
+pub fn mode_rule_matches(rule: &crate::config::ModeRule, app_id: Option<&str>, title: Option<&str>) -> bool {
+ let match_app = rule.app_id_pattern == "*" || app_id.map_or(false, |aid| aid.contains(&rule.app_id_pattern));
+ let match_title = rule.title_pattern.as_deref().map_or(true, |tp| title.unwrap_or("").contains(tp));
+ match_app && match_title
+}
+
impl WindowManager {
// Add legacy fields so structural offsets are preserved if layout-based code is compiled
pub fn sent_outputs_compat(&self) {}
@@ -3236,19 +3247,9 @@ impl WindowManager {
pub unsafe fn get_rule_for_window(&self, win: *mut Window) -> Option<&crate::config::ModeRule> {
let app_id = (*win).get_app_id_string();
let title = (*win).get_title_string();
-
- for rule in &self.mode_rules {
- let match_app = rule.app_id_pattern == "*"
- || app_id.as_ref().map_or(false, |aid| aid.contains(&rule.app_id_pattern));
- let match_title = rule.title_pattern.as_ref().map_or(true, |tp| {
- title.as_ref().map_or(false, |t| t.contains(tp))
- });
-
- if match_app && match_title {
- return Some(rule);
- }
- }
- None
+ self.mode_rules
+ .iter()
+ .find(|rule| mode_rule_matches(rule, app_id.as_deref(), title.as_deref()))
}
pub unsafe fn get_mode_for_window(&self, win: *mut Window) -> crate::tiling::TilingMode {
@@ -7941,6 +7942,33 @@ unsafe extern "C" fn handle_border_fade_tick(data: *mut std::ffi::c_void) -> std
mod tests {
use super::*;
+ fn rule(app_id: &str, title: Option<&str>) -> crate::config::ModeRule {
+ crate::config::ModeRule {
+ mode: crate::tiling::TilingMode::Floating,
+ app_id_pattern: app_id.to_string(),
+ title_pattern: title.map(str::to_string),
+ single_instance: false,
+ tag: -1,
+ circular: false,
+ ssd: None,
+ over_sibling: false,
+ center: true,
+ }
+ }
+
+ #[test]
+ fn an_empty_title_rule_reaches_an_untitled_window() {
+ let any = rule("com.anthropic.Claude", Some(""));
+ assert!(mode_rule_matches(&any, Some("com.anthropic.Claude"), None), "never set a title");
+ assert!(mode_rule_matches(&any, Some("com.anthropic.Claude"), Some("")));
+ assert!(mode_rule_matches(&any, Some("com.anthropic.Claude"), Some("Claude")), "substring: every title");
+ let named = rule("com.anthropic.Claude", Some("Claude"));
+ assert!(!mode_rule_matches(&named, Some("com.anthropic.Claude"), None), "a named title needs a title");
+ assert!(mode_rule_matches(&named, Some("com.anthropic.Claude"), Some("Claude")));
+ assert!(!mode_rule_matches(&named, Some("other"), Some("Claude")));
+ assert!(mode_rule_matches(&rule("*", None), None, None));
+ }
+
#[test]
fn point_in_view_regions_is_half_open() {
let r = [[10.0, 20.0, 100.0, 50.0], [500.0, 0.0, 10.0, 10.0]];
diff --git a/verify/clients/src/bin/float_pair.rs b/verify/clients/src/bin/float_pair.rs
index 2b0b4b6b..e87f5d47 100644
--- a/verify/clients/src/bin/float_pair.rs
+++ b/verify/clients/src/bin/float_pair.rs
@@ -11,6 +11,8 @@
// Args: --app-id ID --delay SECS --main WxH --dialog WxH
// --dialog-honours-configure --no-activate --no-decoration
// --bare-token (no seat/serial/surface on the token)
+// --untitled-dialog never set the dialog's title (the Claude app's
+// quick-entry popup sets none)
// --reactivate SECS that long after the dialog, request activation of
// the (by then unfocused) MAIN window — an activation
// for an already-mapped window
@@ -217,6 +219,7 @@ fn main() {
let mut activate = true;
let mut decoration = true;
let mut bare_token = false;
+ let mut untitled_dialog = false;
let mut reactivate: Option<f64> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
@@ -229,6 +232,7 @@ fn main() {
"--no-activate" => activate = false,
"--no-decoration" => decoration = false,
"--bare-token" => bare_token = true,
+ "--untitled-dialog" => untitled_dialog = true,
"--reactivate" => reactivate = Some(args.next().unwrap().parse().unwrap()),
other => panic!("unknown arg {other}"),
}
@@ -252,7 +256,9 @@ fn main() {
let xdg = wm_base.get_xdg_surface(&surface, &qh, ());
let toplevel = xdg.get_toplevel(&qh, ());
toplevel.set_app_id(app_id.clone());
- toplevel.set_title(title.into());
+ if !title.is_empty() {
+ toplevel.set_title(title.into());
+ }
if fixed {
toplevel.set_min_size(want.0, want.1);
toplevel.set_max_size(want.0, want.1);
@@ -339,7 +345,8 @@ fn main() {
}
if !dialog_created && start.elapsed() >= Duration::from_secs_f64(delay) {
dialog_created = true;
- make_win(&mut state, "Authorize", dialog_size, dialog_honours, 0xffd9_8c2b, true, "dialog");
+ let dialog_title = if untitled_dialog { "" } else { "Authorize" };
+ make_win(&mut state, dialog_title, dialog_size, dialog_honours, 0xffd9_8c2b, true, "dialog");
println!("dialog created");
if let Some(t) = state.token.clone() {
activated = true;