diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 9a20f91..7a1233d 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -674,6 +674,7 @@ pub struct WindowRule { #[knuffel(children(name = "exclude"))] pub excludes: Vec, + // Rules applied at initial configure. #[knuffel(child)] pub default_column_width: Option, #[knuffel(child, unwrap(argument))] @@ -682,6 +683,16 @@ pub struct WindowRule { pub open_maximized: Option, #[knuffel(child, unwrap(argument))] pub open_fullscreen: Option, + + // Rules applied dynamically. + #[knuffel(child, unwrap(argument))] + pub min_width: Option, + #[knuffel(child, unwrap(argument))] + pub min_height: Option, + #[knuffel(child, unwrap(argument))] + pub max_width: Option, + #[knuffel(child, unwrap(argument))] + pub max_height: Option, } #[derive(knuffel::Decode, Debug, Default, Clone)] diff --git a/resources/default-config.kdl b/resources/default-config.kdl index 6b34e2f..014135f 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -362,6 +362,8 @@ animations { exclude app-id=r#"\.unwanted\."# // Here are the properties that you can set on a window rule. + // These properties apply once, when a window first opens. + // You can override the default column width. default-column-width { proportion 0.75; } @@ -377,6 +379,20 @@ animations { open-fullscreen true // You can also set this to false to prevent a window from opening fullscreen. // open-fullscreen false + + // The following properties apply dynamically while a window is open. + + // You can amend the window's minimum and maximum size in logical pixels. + // Keep in mind that the window itself always has a final say in its size. + // These values instruct niri to never ask the window to be smaller than + // the minimum you set, or to be bigger than the maximum you set. + min-width 100 + max-width 200 + min-height 300 + // Caveat: max-height will only apply to automatically-sized windows + // if it is equal to min-height. Either set this equal to min-height, + // or change the window height manually for this to apply. + max-height 300 } // Here's a useful example. Work around WezTerm's initial configure bug diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index 4d31645..7bd4771 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -1,4 +1,3 @@ -use niri_config::{Match, WindowRule}; use smithay::desktop::{ find_popup_root_surface, get_popup_toplevel_coords, layer_map_for_output, LayerSurface, PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, PopupUngrabStrategy, Window, @@ -20,7 +19,7 @@ use smithay::wayland::shell::wlr_layer::Layer; use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler; use smithay::wayland::shell::xdg::{ PopupSurface, PositionerState, ToplevelSurface, XdgPopupSurfaceData, XdgShellHandler, - XdgShellState, XdgToplevelSurfaceData, XdgToplevelSurfaceRoleAttributes, + XdgShellState, XdgToplevelSurfaceData, }; use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState}; use smithay::{ @@ -31,82 +30,6 @@ use crate::layout::workspace::ColumnWidth; use crate::niri::{PopupGrabState, State}; use crate::window::{InitialConfigureState, ResolvedWindowRules, Unmapped}; -fn window_matches(role: &XdgToplevelSurfaceRoleAttributes, m: &Match) -> bool { - if let Some(app_id_re) = &m.app_id { - let Some(app_id) = &role.app_id else { - return false; - }; - if !app_id_re.is_match(app_id) { - return false; - } - } - - if let Some(title_re) = &m.title { - let Some(title) = &role.title else { - return false; - }; - if !title_re.is_match(title) { - return false; - } - } - - true -} - -pub fn resolve_window_rules( - rules: &[WindowRule], - toplevel: &ToplevelSurface, -) -> ResolvedWindowRules { - let _span = tracy_client::span!("resolve_window_rules"); - - let mut resolved = ResolvedWindowRules::default(); - - with_states(toplevel.wl_surface(), |states| { - let role = states - .data_map - .get::() - .unwrap() - .lock() - .unwrap(); - - let mut open_on_output = None; - - for rule in rules { - if !(rule.matches.is_empty() || rule.matches.iter().any(|m| window_matches(&role, m))) { - continue; - } - - if rule.excludes.iter().any(|m| window_matches(&role, m)) { - continue; - } - - if let Some(x) = rule - .default_column_width - .as_ref() - .map(|d| d.0.map(ColumnWidth::from)) - { - resolved.default_width = Some(x); - } - - if let Some(x) = rule.open_on_output.as_deref() { - open_on_output = Some(x); - } - - if let Some(x) = rule.open_maximized { - resolved.open_maximized = Some(x); - } - - if let Some(x) = rule.open_fullscreen { - resolved.open_fullscreen = Some(x); - } - } - - resolved.open_on_output = open_on_output.map(|x| x.to_owned()); - }); - - resolved -} - impl XdgShellHandler for State { fn xdg_shell_state(&mut self) -> &mut XdgShellState { &mut self.niri.xdg_shell_state @@ -574,7 +497,7 @@ impl State { }; let config = self.niri.config.borrow(); - let rules = resolve_window_rules(&config.window_rules, toplevel); + let rules = ResolvedWindowRules::compute(&config.window_rules, toplevel); // Pick the target monitor. First, check if we had an output set in the window rules. let mon = rules @@ -807,14 +730,30 @@ impl State { } pub fn update_window_rules(&mut self, toplevel: &ToplevelSurface) { - let resolve = || resolve_window_rules(&self.niri.config.borrow().window_rules, toplevel); + let resolve = + || ResolvedWindowRules::compute(&self.niri.config.borrow().window_rules, toplevel); if let Some(unmapped) = self.niri.unmapped_windows.get_mut(toplevel.wl_surface()) { if let InitialConfigureState::Configured { rules, .. } = &mut unmapped.state { *rules = resolve(); } - } else if let Some(mapped) = self.niri.layout.find_window_mut(toplevel.wl_surface()) { - mapped.rules = resolve(); + } else if let Some((mapped, output)) = self + .niri + .layout + .find_window_and_output_mut(toplevel.wl_surface()) + { + let new_rules = resolve(); + if mapped.rules != new_rules { + mapped.rules = new_rules; + + let output = output.cloned(); + let window = mapped.window.clone(); + self.niri.layout.update_window(&window); + + if let Some(output) = output { + self.niri.queue_redraw(output); + } + } } } } diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 86ce5c8..c2c4bf4 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -643,21 +643,12 @@ impl Layout { } } - pub fn find_window_mut(&mut self, wl_surface: &WlSurface) -> Option<&mut W> { - match &mut self.monitor_set { - MonitorSet::Normal { monitors, .. } => { - for mon in monitors { - for ws in &mut mon.workspaces { - if let Some(window) = ws.find_wl_surface_mut(wl_surface) { - return Some(window); - } - } - } - } - MonitorSet::NoOutputs { workspaces } => { - for ws in workspaces { - if let Some(window) = ws.find_wl_surface_mut(wl_surface) { - return Some(window); + pub fn find_window_and_output(&self, wl_surface: &WlSurface) -> Option<(&W, &Output)> { + if let MonitorSet::Normal { monitors, .. } = &self.monitor_set { + for mon in monitors { + for ws in &mon.workspaces { + if let Some(window) = ws.find_wl_surface(wl_surface) { + return Some((window, &mon.output)); } } } @@ -666,12 +657,24 @@ impl Layout { None } - pub fn find_window_and_output(&self, wl_surface: &WlSurface) -> Option<(&W, &Output)> { - if let MonitorSet::Normal { monitors, .. } = &self.monitor_set { - for mon in monitors { - for ws in &mon.workspaces { - if let Some(window) = ws.find_wl_surface(wl_surface) { - return Some((window, &mon.output)); + pub fn find_window_and_output_mut( + &mut self, + wl_surface: &WlSurface, + ) -> Option<(&mut W, Option<&Output>)> { + match &mut self.monitor_set { + MonitorSet::Normal { monitors, .. } => { + for mon in monitors { + for ws in &mut mon.workspaces { + if let Some(window) = ws.find_wl_surface_mut(wl_surface) { + return Some((window, Some(&mon.output))); + } + } + } + } + MonitorSet::NoOutputs { workspaces } => { + for ws in workspaces { + if let Some(window) = ws.find_wl_surface_mut(wl_surface) { + return Some((window, None)); } } } @@ -851,6 +854,27 @@ impl Layout { } } + pub fn with_windows_mut(&mut self, mut f: impl FnMut(&mut W, Option<&Output>)) { + match &mut self.monitor_set { + MonitorSet::Normal { monitors, .. } => { + for mon in monitors { + for ws in &mut mon.workspaces { + for win in ws.windows_mut() { + f(win, Some(&mon.output)); + } + } + } + } + MonitorSet::NoOutputs { workspaces } => { + for ws in workspaces { + for win in ws.windows_mut() { + f(win, None); + } + } + } + } + } + fn active_monitor(&mut self) -> Option<&mut Monitor> { let MonitorSet::Normal { monitors, diff --git a/src/niri.rs b/src/niri.rs index 13aa640..08f195f 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -114,7 +114,7 @@ use crate::utils::spawning::CHILD_ENV; use crate::utils::{ center, center_f64, get_monotonic_time, make_screenshot_path, output_size, write_png_rgba8, }; -use crate::window::{Mapped, Unmapped}; +use crate::window::{InitialConfigureState, Mapped, ResolvedWindowRules, Unmapped}; use crate::{animation, niri_render_elements}; const CLEAR_COLOR: [f32; 4] = [0.2, 0.2, 0.2, 1.]; @@ -761,6 +761,7 @@ impl State { let mut reload_xkb = None; let mut libinput_config_changed = false; let mut output_config_changed = false; + let mut window_rules_changed = false; let mut old_config = self.niri.config.borrow_mut(); // Reload the cursor. @@ -802,6 +803,10 @@ impl State { self.niri.hotkey_overlay.on_hotkey_config_updated(); } + if config.window_rules != old_config.window_rules { + window_rules_changed = true; + } + *old_config = config; // Release the borrow. @@ -865,6 +870,30 @@ impl State { } } + if window_rules_changed { + let _span = tracy_client::span!("recompute window rules"); + + let window_rules = &self.niri.config.borrow().window_rules; + + for unmapped in self.niri.unmapped_windows.values_mut() { + if let InitialConfigureState::Configured { rules, .. } = &mut unmapped.state { + *rules = ResolvedWindowRules::compute( + window_rules, + unmapped.window.toplevel().expect("no X11 support"), + ); + } + } + + let mut windows = vec![]; + self.niri.layout.with_windows_mut(|mapped, _| { + mapped.rules = ResolvedWindowRules::compute(window_rules, mapped.toplevel()); + windows.push(mapped.window.clone()); + }); + for win in windows { + self.niri.layout.update_window(&win); + } + } + // Can't really update xdg-decoration settings since we have to hide the globals for CSD // due to the SDL2 bug... I don't imagine clients are prepared for the xdg-decoration // global suddenly appearing? Either way, right now it's live-reloaded in a sense that new diff --git a/src/window/mapped.rs b/src/window/mapped.rs index 4476ded..6afd995 100644 --- a/src/window/mapped.rs +++ b/src/window/mapped.rs @@ -1,3 +1,5 @@ +use std::cmp::{max, min}; + use smithay::backend::renderer::element::{AsRenderElements as _, Id}; use smithay::desktop::space::SpaceElement as _; use smithay::desktop::Window; @@ -82,17 +84,43 @@ impl LayoutElement for Mapped { } fn min_size(&self) -> Size { - with_states(self.toplevel().wl_surface(), |state| { + let mut size = with_states(self.toplevel().wl_surface(), |state| { let curr = state.cached_state.current::(); curr.min_size - }) + }); + + if let Some(x) = self.rules.min_width { + size.w = max(size.w, i32::from(x)); + } + if let Some(x) = self.rules.min_height { + size.h = max(size.h, i32::from(x)); + } + + size } fn max_size(&self) -> Size { - with_states(self.toplevel().wl_surface(), |state| { + let mut size = with_states(self.toplevel().wl_surface(), |state| { let curr = state.cached_state.current::(); curr.max_size - }) + }); + + if let Some(x) = self.rules.max_width { + if size.w == 0 { + size.w = i32::from(x); + } else if x > 0 { + size.w = min(size.w, i32::from(x)); + } + } + if let Some(x) = self.rules.max_height { + if size.h == 0 { + size.h = i32::from(x); + } else if x > 0 { + size.h = min(size.h, i32::from(x)); + } + } + + size } fn is_wl_surface(&self, wl_surface: &WlSurface) -> bool { diff --git a/src/window/mod.rs b/src/window/mod.rs index 124a62f..25b3531 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -1,3 +1,9 @@ +use niri_config::{Match, WindowRule}; +use smithay::wayland::compositor::with_states; +use smithay::wayland::shell::xdg::{ + ToplevelSurface, XdgToplevelSurfaceData, XdgToplevelSurfaceRoleAttributes, +}; + use crate::layout::workspace::ColumnWidth; pub mod mapped; @@ -7,7 +13,7 @@ pub mod unmapped; pub use unmapped::{InitialConfigureState, Unmapped}; /// Rules fully resolved for a window. -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq)] pub struct ResolvedWindowRules { /// Default width for this window. /// @@ -24,4 +30,103 @@ pub struct ResolvedWindowRules { /// Whether the window should open fullscreen. pub open_fullscreen: Option, + + /// Extra bound on the minimum window width. + pub min_width: Option, + /// Extra bound on the minimum window height. + pub min_height: Option, + /// Extra bound on the maximum window width. + pub max_width: Option, + /// Extra bound on the maximum window height. + pub max_height: Option, +} + +impl ResolvedWindowRules { + pub fn compute(rules: &[WindowRule], toplevel: &ToplevelSurface) -> Self { + let _span = tracy_client::span!("ResolvedWindowRules::compute"); + + let mut resolved = ResolvedWindowRules::default(); + + with_states(toplevel.wl_surface(), |states| { + let role = states + .data_map + .get::() + .unwrap() + .lock() + .unwrap(); + + let mut open_on_output = None; + + for rule in rules { + if !(rule.matches.is_empty() + || rule.matches.iter().any(|m| window_matches(&role, m))) + { + continue; + } + + if rule.excludes.iter().any(|m| window_matches(&role, m)) { + continue; + } + + if let Some(x) = rule + .default_column_width + .as_ref() + .map(|d| d.0.map(ColumnWidth::from)) + { + resolved.default_width = Some(x); + } + + if let Some(x) = rule.open_on_output.as_deref() { + open_on_output = Some(x); + } + + if let Some(x) = rule.open_maximized { + resolved.open_maximized = Some(x); + } + + if let Some(x) = rule.open_fullscreen { + resolved.open_fullscreen = Some(x); + } + + if let Some(x) = rule.min_width { + resolved.min_width = Some(x); + } + if let Some(x) = rule.min_height { + resolved.min_height = Some(x); + } + if let Some(x) = rule.max_width { + resolved.max_width = Some(x); + } + if let Some(x) = rule.max_height { + resolved.max_height = Some(x); + } + } + + resolved.open_on_output = open_on_output.map(|x| x.to_owned()); + }); + + resolved + } +} + +fn window_matches(role: &XdgToplevelSurfaceRoleAttributes, m: &Match) -> bool { + if let Some(app_id_re) = &m.app_id { + let Some(app_id) = &role.app_id else { + return false; + }; + if !app_id_re.is_match(app_id) { + return false; + } + } + + if let Some(title_re) = &m.title { + let Some(title) = &role.title else { + return false; + }; + if !title_re.is_match(title) { + return false; + } + } + + true }