Add automatic idle inhibition for fullscreen playback and selected apps

This commit is contained in:
Samuel Onoja committed 2026-10-03 11:20:55 +01:00
1 parent 453ec16583
commit 03a265b97f
17 files changed
+842 -22

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ ferese-core = { path = "../crates/ferese-core" }
ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
futures-lite = "2.6.1"
libc.workspace = true
jiff.workspace = true
notify = "8.2.0"
+3
View File
@@ -34,6 +34,8 @@ pub struct Config {
#[serde(default)]
lock_screen: crate::session_lock::IdleSettings,
#[serde(default)]
idle_inhibit: crate::idle_inhibition::Settings,
#[serde(default)]
desktop_widgets: ferese_config::desktop::DesktopWidgets,
#[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>,
@@ -115,6 +117,7 @@ impl Config {
let bindings = self.bindings(&input_settings)?;
Ok(crate::RuntimeConfig {
lock_idle: self.lock_screen.validate()?,
idle_inhibit: self.idle_inhibit,
autostart: self.autostart.clone(),
layout_mode: self.layout_mode(),
workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth,
+3
View File
@@ -36,6 +36,7 @@ impl XdgShellHandler for Ferese {
fn title_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) {
self.refresh_idle_inhibition();
self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output);
@@ -46,6 +47,7 @@ impl XdgShellHandler for Ferese {
fn app_id_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) {
self.refresh_idle_inhibition();
self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output);
@@ -55,6 +57,7 @@ impl XdgShellHandler for Ferese {
fn parent_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy();
self.refresh_idle_inhibition();
let Some(parent) = surface.parent() else {
return;
};
+268
View File
@@ -0,0 +1,268 @@
//! Automatic policy feeds the same idle notifier as Wayland and portal requests.
pub(crate) mod media;
use std::collections::HashSet;
use ferese_layout::WindowId;
use serde::Deserialize;
use smithay::reexports::wayland_server::Resource;
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::Ferese;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Settings {
pub fullscreen_playback: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
fullscreen_playback: true,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Mode {
None,
Visible,
Fullscreen,
Playing,
FullscreenPlaying,
}
impl Mode {
fn inhibits(self, visible: bool, fullscreen: bool, playing: bool) -> bool {
visible
&& match self {
Self::None => false,
Self::Visible => true,
Self::Fullscreen => fullscreen,
Self::Playing => playing,
Self::FullscreenPlaying => fullscreen && playing,
}
}
}
#[derive(Clone, Copy)]
struct AppWindow<'a> {
id: WindowId,
pid: Option<u32>,
app_id: &'a str,
}
/// MPRIS reports application playback, not which of its windows owns a video.
/// Prefer process credentials; use DesktopEntry only when no process matches.
/// Multiple matching windows are ambiguous, including hidden windows.
fn player_window(player: &media::Player, windows: &[AppWindow<'_>]) -> Option<WindowId> {
let mut matches = windows
.iter()
.filter(|window| player.pid.is_some() && window.pid == player.pid);
if let Some(window) = matches.next() {
return matches.next().is_none().then_some(window.id);
}
let desktop = player.desktop_entry.as_deref()?;
let mut matches = windows
.iter()
.filter(|window| crate::window_rules::normalize_app_id(window.app_id) == desktop);
let window = matches.next()?;
matches.next().is_none().then_some(window.id)
}
impl Ferese {
pub(crate) fn refresh_idle_inhibition(&mut self) {
let playing = self.media_players.iter().any(|player| player.playing);
let has_rules = self.window_rules.iter().any(|rule| rule.idle_policy().is_some());
let selected = self
.window_rules
.iter()
.any(|rule| rule.idle_policy().is_some_and(|mode| mode != Mode::None));
let playback_policy = self.idle_inhibit.fullscreen_playback
|| self
.window_rules
.iter()
.any(|rule| matches!(rule.idle_policy(), Some(Mode::Playing | Mode::FullscreenPlaying)));
let automatic = (playback_policy && playing) || selected;
let eligible = if self.idle_inhibitors.is_empty() && !automatic {
Default::default()
} else {
self.callback_outputs()
};
let application = if playback_policy && playing {
self.windows
.records()
.map(|(&id, record)| {
let pid = record
.surface
.as_ref()
.and_then(|surface| surface.client())
.and_then(|client| client.get_credentials(&self.display_handle).ok())
.and_then(|credentials| u32::try_from(credentials.pid).ok());
AppWindow {
id,
pid,
app_id: &record.app_id,
}
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let playing_windows = self
.media_players
.iter()
.filter(|player| player.playing)
.filter_map(|player| player_window(player, &application))
.collect::<HashSet<_>>();
let automatic = automatic
&& !self.session_lock.active()
&& self.windows.records().any(|(&id, record)| {
let Some(surface) = &record.surface else {
return false;
};
if !eligible.contains_key(&surface.into()) {
return false;
}
let explicit = has_rules
.then(|| {
let transient = with_states(surface, |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.is_some_and(|data| data.lock().unwrap().parent.is_some())
});
crate::window_rules::resolve(
&self.window_rules,
Some(&record.app_id),
Some(&record.title),
transient,
)
.idle_inhibit
})
.flatten();
let mode = explicit.unwrap_or(if self.idle_inhibit.fullscreen_playback {
Mode::FullscreenPlaying
} else {
Mode::None
});
let fullscreen = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(id));
mode.inhibits(true, fullscreen, playing_windows.contains(&id))
});
let requested = self
.idle_inhibitors
.keys()
.any(|surface| eligible.contains_key(&surface.into()));
self.automatic_idle_inhibited = automatic;
self.idle_notifier_state
.set_is_inhibited(automatic || requested || self.portal_session.idle_inhibited());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policy_releases_on_pause_hidden_window_and_fullscreen_exit() {
assert!(Mode::FullscreenPlaying.inhibits(true, true, true));
assert!(!Mode::FullscreenPlaying.inhibits(true, true, false));
assert!(!Mode::FullscreenPlaying.inhibits(false, true, true));
assert!(!Mode::FullscreenPlaying.inhibits(true, false, true));
assert!(Mode::Visible.inhibits(true, false, false));
assert!(!Mode::Visible.inhibits(false, true, true));
assert!(Mode::Playing.inhibits(true, false, true));
assert!(!Mode::Playing.inhibits(true, false, false));
assert!(Mode::Fullscreen.inhibits(true, true, false));
assert!(!Mode::None.inhibits(true, true, true));
}
#[test]
fn configuration_validates_modes_and_keeps_idle_rules_out_of_placement() {
let config = crate::config::Config::parse_source(
"idle-inhibit { fullscreen-playback #false; }\nwindow-rule app-id=\"player\" idle-inhibit=\"visible\"\nwindow-rule app-id=\"player\" title=\"Private\" idle-inhibit=\"none\"\n",
).unwrap().runtime_config().unwrap();
assert!(!config.idle_inhibit.fullscreen_playback);
let visible = crate::window_rules::resolve(&config.window_rules, Some("player"), None, false);
assert_eq!(visible.idle_inhibit, Some(Mode::Visible));
let none = crate::window_rules::resolve(&config.window_rules, Some("player"), Some("Private"), false);
assert_eq!(none.idle_inhibit, Some(Mode::None));
assert_eq!(crate::window_rules::live_result(visible, none, false), None);
for mode in ["playing", "fullscreen", "fullscreen-playing", "none"] {
assert!(
crate::config::Config::parse_source(&format!(
"window-rule app-id=\"player\" idle-inhibit=\"{mode}\"\n"
))
.unwrap()
.runtime_config()
.is_ok()
);
}
assert!(
crate::config::Config::parse_source("window-rule app-id=\"player\" idle-inhibit=\"alwayss\"\n").is_err()
);
assert!(crate::config::Config::parse_source("idle-inhibit { fullscreen-playback \"yes\"; }\n").is_err());
}
#[test]
fn player_identity_prefers_pid_and_rejects_ambiguous_or_closed_windows() {
let player = media::Player {
name: "org.mpris.MediaPlayer2.test".into(),
owner: ":1.5".into(),
pid: Some(42),
desktop_entry: Some("player".into()),
playing: true,
};
let window = AppWindow {
id: WindowId(1),
pid: Some(42),
app_id: "other",
};
let unrelated = AppWindow {
id: WindowId(2),
pid: Some(99),
app_id: "Player.desktop",
};
assert_eq!(player_window(&player, &[window, unrelated]), Some(window.id));
assert_eq!(player_window(&player, &[unrelated]), Some(unrelated.id));
assert_eq!(player_window(&player, &[]), None);
assert_eq!(
player_window(
&player,
&[
window,
AppWindow {
id: WindowId(3),
..window
}
]
),
None
);
assert_eq!(
player_window(
&player,
&[
unrelated,
AppWindow {
id: WindowId(4),
..unrelated
}
]
),
None
);
}
}
+245
View File
@@ -0,0 +1,245 @@
//! MPRIS reads and signal handling run off the compositor thread, without polling.
use std::collections::HashMap;
use std::time::Duration;
use calloop::EventLoop;
use calloop::channel::{Event, channel};
use futures_lite::{StreamExt, future, stream};
use zbus::zvariant::OwnedValue;
use zbus::{Connection, MatchRule, MessageStream};
use crate::Ferese;
const PREFIX: &str = "org.mpris.MediaPlayer2";
const PATH: &str = "/org/mpris/MediaPlayer2";
const PLAYER: &str = "org.mpris.MediaPlayer2.Player";
const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
const MAX_PLAYERS: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Player {
pub name: String,
pub owner: String,
pub pid: Option<u32>,
pub desktop_entry: Option<String>,
pub playing: bool,
}
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
connection: &Connection,
member: &str,
name: &str,
) -> zbus::Result<T> {
connection
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
member,
&(name,),
)
.await?
.body()
.deserialize()
}
async fn property(connection: &Connection, owner: &str, interface: &str, name: &str) -> zbus::Result<OwnedValue> {
connection
.call_method(Some(owner), PATH, Some(PROPERTIES), "Get", &(interface, name))
.await?
.body()
.deserialize()
}
async fn refresh_player(connection: &Connection, player: &mut Player, identity: bool) {
// Bind reads to the unique owner, never a name that can change mid-query.
player.playing = property(connection, &player.owner, PLAYER, "PlaybackStatus")
.await
.ok()
.and_then(|value| String::try_from(value).ok())
.is_some_and(|status| status == "Playing");
if identity {
player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner)
.await
.ok();
player.desktop_entry = property(connection, &player.owner, PREFIX, "DesktopEntry")
.await
.ok()
.and_then(|value| String::try_from(value).ok())
.map(|value| crate::window_rules::normalize_app_id(&value))
.filter(|value| !value.is_empty());
}
}
fn publish(
players: &HashMap<String, Player>,
previous: &mut Vec<Player>,
send: &mut impl FnMut(Vec<Player>) -> bool,
) -> bool {
let mut snapshot: Vec<_> = players.values().cloned().collect();
snapshot.sort_unstable_by(|a, b| a.name.cmp(&b.name));
if snapshot == *previous {
return true;
}
*previous = snapshot.clone();
send(snapshot)
}
async fn monitor(connection: Connection, mut send: impl FnMut(Vec<Player>) -> bool) -> zbus::Result<()> {
// Subscribe before discovery so startup and owner replacement cannot lose
// a pause/stop event. Ignore unrelated property traffic such as position.
let properties = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.path(PATH)?
.interface(PROPERTIES)?
.member("PropertiesChanged")?
.build();
let owners = MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")?
.interface("org.freedesktop.DBus")?
.member("NameOwnerChanged")?
.arg0ns(PREFIX)?
.build();
let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?;
let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?;
let mut signals = stream::or(properties, owners);
let mut players = HashMap::new();
let mut previous = Vec::new();
let names: Vec<String> = connection
.call_method(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"ListNames",
&(),
)
.await?
.body()
.deserialize()?;
let mut names: Vec<_> = names
.into_iter()
.filter(|name| name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.')))
.collect();
names.sort_unstable();
for name in names.into_iter().take(MAX_PLAYERS) {
if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await {
let mut player = Player {
name: name.clone(),
owner,
pid: None,
desktop_entry: None,
playing: false,
};
refresh_player(&connection, &mut player, true).await;
players.insert(name, player);
}
}
if !publish(&players, &mut previous, &mut send) {
return Ok(());
}
while let Some(message) = signals.next().await {
let message = message?;
let header = message.header();
if header
.member()
.is_some_and(|member| member.as_str() == "NameOwnerChanged")
{
let (name, _, owner): (String, String, String) = message.body().deserialize()?;
// Remove the old owner's state before looking up the replacement.
players.remove(&name);
if !publish(&players, &mut previous, &mut send) {
return Ok(());
}
if !owner.is_empty() && players.len() < MAX_PLAYERS {
let mut player = Player {
name: name.clone(),
owner,
pid: None,
desktop_entry: None,
playing: false,
};
refresh_player(&connection, &mut player, true).await;
players.insert(name, player);
}
} else {
let Ok((interface, changed, invalidated)) =
message
.body()
.deserialize::<(String, HashMap<String, OwnedValue>, Vec<String>)>()
else {
continue;
};
let identity = interface == PREFIX
&& (changed.contains_key("DesktopEntry") || invalidated.iter().any(|field| field == "DesktopEntry"));
let status = interface == PLAYER
&& (changed.contains_key("PlaybackStatus")
|| invalidated.iter().any(|field| field == "PlaybackStatus"));
if !identity && !status {
continue;
}
let Some(owner) = header.sender() else {
continue;
};
for player in players.values_mut().filter(|player| player.owner == owner.as_str()) {
// Re-read current state: a signal queued during discovery may
// describe older playback than the startup snapshot.
refresh_player(&connection, player, identity).await;
}
}
if !publish(&players, &mut previous, &mut send) {
return Ok(());
}
}
Err(zbus::Error::Failure("MPRIS signal connection ended".into()))
}
pub(crate) fn init(event_loop: &mut EventLoop<Ferese>) -> Result<(), Box<dyn std::error::Error>> {
let (sender, receiver) = channel();
event_loop.handle().insert_source(receiver, |event, _, state| {
if let Event::Msg(players) = event {
state.media_players = players;
state.refresh_idle_inhibition();
}
})?;
std::thread::Builder::new()
.name("ferese-playback".into())
.spawn(move || {
let mut had_players = false;
loop {
let result = future::block_on(async {
let connection = zbus::connection::Builder::session()?
.method_timeout(Duration::from_millis(500))
.build()
.await?;
monitor(connection, |players| {
had_players = !players.is_empty();
sender.send(players).is_ok()
})
.await
});
match result {
Ok(()) => break,
Err(error) => {
tracing::debug!(%error, "playback monitor disconnected; releasing automatic inhibition")
}
}
if had_players && sender.send(Vec::new()).is_err() {
break;
}
had_players = false;
// Reconnect only after failure; healthy operation sleeps on signals.
std::thread::sleep(Duration::from_secs(5));
}
})?;
Ok(())
}
+11
View File
@@ -692,6 +692,17 @@ impl Ferese {
"close" => self.close_focused_window(),
"get-focused-window" => return Ok(self.focused_window_json()),
"get-windows" => return Ok(self.windows_json()),
"get-idle-inhibition" => {
return Ok(json!({
"inhibited": self.idle_notifier_state.is_inhibited(),
"automatic": self.automatic_idle_inhibited,
"fullscreen-playback": self.idle_inhibit.fullscreen_playback,
"portal": self.portal_session.idle_inhibited(),
"players": self.media_players.iter().map(|player| json!({
"name": player.name, "desktop-entry": player.desktop_entry, "playing": player.playing,
})).collect::<Vec<_>>(),
}));
}
"get-keybindings" => {
let map = crate::config::physical_keymap(&self.input_settings).ok();
return Ok(json!(
+2
View File
@@ -12,6 +12,7 @@ mod frame_scheduler;
mod gestures;
mod grabs;
mod handlers;
mod idle_inhibition;
mod input;
mod input_capture;
mod ipc;
@@ -109,6 +110,7 @@ fn main() -> Result<(), Box<dyn Error>> {
});
theme::init(&mut event_loop, &mut state, candidate)?;
resume::init(&mut event_loop)?;
idle_inhibition::media::init(&mut event_loop)?;
overview::init_font_loader(&mut event_loop, &mut state)?;
backends::init(launch.backend, &mut event_loop, &mut state)?;
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
+1 -16
View File
@@ -116,7 +116,7 @@ impl PortalSession {
}
}
fn idle_inhibited(&self) -> bool {
pub(crate) fn idle_inhibited(&self) -> bool {
self.inhibitors.values().any(|inhibitor| inhibitor.flags & 8 != 0)
}
@@ -234,21 +234,6 @@ impl PortalSession {
}
impl crate::Ferese {
pub(crate) fn refresh_idle_inhibition(&mut self) {
let eligible = if self.idle_inhibitors.is_empty() {
Default::default()
} else {
self.callback_outputs()
};
self.idle_notifier_state.set_is_inhibited(
self.idle_inhibitors
.keys()
.any(|surface| eligible.contains_key(&surface.into()))
|| self.portal_session.idle_inhibited(),
);
}
pub(crate) fn end_portal_session(&mut self) {
let token = self.portal_session.query.as_ref().map(|query| query.token);
let result =
+14 -1
View File
@@ -443,7 +443,7 @@ pub struct Ferese {
pub(crate) input_settings: InputSettings,
pub(crate) bindings: BindingSet,
workspace_auto_back_and_forth: bool,
window_rules: Vec<WindowRule>,
pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings,
pub(crate) inactive_dim: crate::config::InactiveDimSettings,
animations_enabled: bool,
@@ -464,6 +464,9 @@ pub struct Ferese {
pub intercepted_keys: HashSet<Keycode>,
pub(crate) swipe: Swipe,
pub idle_inhibitors: HashMap<WlSurface, usize>,
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
pub(crate) automatic_idle_inhibited: bool,
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<DirectBackendState>,
_ipc_socket: Option<IpcSocketGuard>,
@@ -517,6 +520,7 @@ pub struct Ferese {
#[derive(Clone)]
pub struct RuntimeConfig {
pub(crate) lock_idle: IdleSettings,
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) autostart: Vec<DaemonConfig>,
pub(crate) overview_font_family: String,
pub(crate) wallpaper: WallpaperConfig,
@@ -701,6 +705,9 @@ impl Ferese {
intercepted_keys: HashSet::new(),
swipe: Swipe::default(),
idle_inhibitors: HashMap::new(),
idle_inhibit: config.idle_inhibit,
media_players: Vec::new(),
automatic_idle_inhibited: false,
active_shortcuts_inhibitor: None,
direct_backend: None,
_ipc_socket: None,
@@ -852,6 +859,8 @@ impl Ferese {
self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings);
}
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
let idle_policy_changed = self.idle_inhibit != config.idle_inhibit || old_rules != self.window_rules;
self.idle_inhibit = config.idle_inhibit;
self.theme_settings = config.theme_settings;
self.inactive_dim = config.inactive_dim;
self.column_width_presets = config.column_width_presets;
@@ -885,6 +894,10 @@ impl Ferese {
self.reapply_window_rules(&old_rules);
}
if idle_policy_changed {
self.refresh_idle_inhibition();
}
self.overview.set_font_family(config.overview_font_family);
if self.wallpaper.configuration() != &config.wallpaper {
self.wallpaper.reload(config.wallpaper);
+17 -2
View File
@@ -11,6 +11,7 @@ pub(crate) struct WindowRuleConfig {
pub height: Option<f64>,
pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>,
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -24,6 +25,13 @@ pub struct WindowRule {
height: Option<f64>,
fullscreen: Option<bool>,
block_out_from_screencasts: Option<bool>,
idle_inhibit: Option<crate::idle_inhibition::Mode>,
}
impl WindowRule {
pub(crate) fn idle_policy(&self) -> Option<crate::idle_inhibition::Mode> {
self.idle_inhibit
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -34,6 +42,7 @@ pub struct WindowRuleResult {
pub height: Option<f64>,
pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>,
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
}
/// Only changed matches should override an existing window's manual state.
@@ -42,9 +51,11 @@ pub(crate) fn live_result(
mut new: WindowRuleResult,
transient: bool,
) -> Option<WindowRuleResult> {
// Capture policy is resolved independently of placement at capture time.
// Capture and idle policies are reevaluated without changing placement.
old.block_out_from_screencasts = None;
new.block_out_from_screencasts = None;
old.idle_inhibit = None;
new.idle_inhibit = None;
if old == new {
return None;
}
@@ -97,6 +108,7 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>,
result.height = rule.height.or(result.height);
result.fullscreen = rule.fullscreen.or(result.fullscreen);
result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts);
result.idle_inhibit = rule.idle_inhibit.or(result.idle_inhibit);
}
result
@@ -137,6 +149,7 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
height,
fullscreen: rule.fullscreen,
block_out_from_screencasts: rule.block_out_from_screencasts,
idle_inhibit: rule.idle_inhibit,
})
}
@@ -171,7 +184,7 @@ pub(crate) fn is_native_dialog(app_id: Option<&str>) -> bool {
)
}
fn normalize_app_id(value: &str) -> String {
pub(crate) fn normalize_app_id(value: &str) -> String {
value
.trim()
.strip_suffix(".desktop")
@@ -215,6 +228,7 @@ mod tests {
height: None,
fullscreen: None,
block_out_from_screencasts: None,
idle_inhibit: None,
}
}
@@ -346,6 +360,7 @@ mod tests {
height: None,
fullscreen: None,
block_out_from_screencasts: None,
idle_inhibit: None,
};
let invalid_size = WindowRuleConfig {
width: Some(f64::NAN),