use arc_swap::{ArcSwap, Guard};
use bevy::ecs::resource::Resource;
use objc2_core_foundation::{CFData, CFString};
use regex::Regex;
use serde::{Deserialize, Deserializer, de};
use std::{
collections::HashMap,
env,
ffi::c_void,
fs::{OpenOptions, create_dir_all, read_to_string},
io::{ErrorKind, Write},
path::{Path, PathBuf},
ptr::NonNull,
sync::{Arc, LazyLock},
time::Duration,
};
use stdext::function_name;
use tracing::{error, info, warn};
use self::decorations::{
BorderRadiusOption, DescriptorStyle, IndicatorFormat, IndicatorStyle, MenubarOrientation,
};
use self::swipe::SwipeGestureDirection;
#[cfg(test)]
use crate::commands::{MoveFocus, Operation, ResizeDirection};
use crate::{
commands::Command,
manager::ProcessApi,
platform::{Modifiers, OSStatus, macos_major_version},
};
use crate::{
errors::{Error, Result},
util::MacResult,
};
use crate::{platform::CFStringRef, util::AXUIWrapper};
pub mod decorations;
pub mod padding;
pub mod snippet;
pub mod swipe;
pub static CONFIGURATION_FILE: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
#[cfg(feature = "lua")]
if let Some(script) = discover_lua_file() {
info!(
"{}: {} is in charge; the TOML configuration is ignored",
function_name!(),
script.display()
);
return None;
}
if let Some(path) = discover_configuration_file() {
return Some(path);
}
Some(create_default_configuration_file().unwrap_or_else(|error| {
panic!(
"{}: Unable to create default configuration: {error}",
function_name!()
)
}))
});
const DEFAULT_CONFIGURATION: &str = "# Paneru configuration\n\n[options]\n\n[bindings]\n";
fn default_configuration_file() -> std::io::Result<PathBuf> {
let config_home = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.ok_or_else(|| {
std::io::Error::new(
ErrorKind::NotFound,
"neither XDG_CONFIG_HOME nor HOME is set",
)
})?;
Ok(config_home.join("paneru").join("paneru.toml"))
}
fn create_default_configuration_file() -> std::io::Result<PathBuf> {
let path = default_configuration_file()?;
if create_configuration_file_at(&path)? {
info!("Created default configuration at {}", path.display());
}
Ok(path)
}
fn create_configuration_file_at(path: &Path) -> std::io::Result<bool> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(ErrorKind::InvalidInput, "configuration path has no parent")
})?;
create_dir_all(parent)?;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
file.write_all(DEFAULT_CONFIGURATION.as_bytes())?;
Ok(true)
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => Ok(false),
Err(error) => Err(error),
}
}
pub fn discover_configuration_file() -> Option<PathBuf> {
if let Ok(path_str) = env::var("PANERU_CONFIG") {
let path = PathBuf::from(path_str);
if path.exists() {
return Some(path);
}
warn!(
"{}: $PANERU_CONFIG is set to {}, but the file does not exist. Falling back to default locations.",
function_name!(),
path.display()
);
}
let standard_paths = [
env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".paneru")),
env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".paneru.toml")),
];
let xdg_dirs = xdg::BaseDirectories::with_prefix("paneru");
let xdg_config_paths = xdg_dirs.find_config_files("paneru.toml");
standard_paths
.into_iter()
.flatten()
.chain(xdg_config_paths)
.find(|path| path.exists())
}
#[cfg(feature = "lua")]
const DEFAULT_LUA_SCRIPT: &str = "\
-- Paneru Lua configuration (hot-reloaded on save).
--
-- Hook into window-manager events:
-- paneru.on(\"window_focused\", function(e) paneru.log(\"focused \" .. e.window_id) end)
--
-- Bind keys to commands (chord syntax matches [bindings]):
-- paneru.bind(\"alt - b\", \"window balance\")
--
-- ...or to a function. Handlers are given the whole layout as a value they can
-- transform; nothing moves until you return one, so computing a layout and
-- discarding it costs nothing. See CONFIGURATION.md.
-- paneru.bind(\"alt - j\", function(ws)
-- return ws:focus(ws:east(ws:focused()))
-- end)
";
#[cfg(feature = "lua")]
fn default_lua_file() -> std::io::Result<PathBuf> {
let config_home = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.ok_or_else(|| {
std::io::Error::new(
ErrorKind::NotFound,
"neither XDG_CONFIG_HOME nor HOME is set",
)
})?;
Ok(config_home.join("paneru").join("init.lua"))
}
#[cfg(feature = "lua")]
pub fn discover_lua_file() -> Option<PathBuf> {
if let Ok(path_str) = env::var("PANERU_LUA") {
let path = PathBuf::from(path_str);
if path.exists() {
return Some(path);
}
warn!(
"{}: $PANERU_LUA is set to {}, but the file does not exist. Falling back to default locations.",
function_name!(),
path.display()
);
}
let standard_paths = [env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".paneru.lua"))];
let xdg_dirs = xdg::BaseDirectories::with_prefix("paneru");
let xdg_paths = xdg_dirs.find_config_files("init.lua");
standard_paths
.into_iter()
.flatten()
.chain(xdg_paths)
.find(|path| path.exists())
}
#[cfg(feature = "lua")]
pub fn ensure_lua_file() -> std::io::Result<Option<PathBuf>> {
if let Some(path) = discover_lua_file() {
return Ok(Some(path));
}
if let Some(toml) = discover_configuration_file() {
info!(
"{}: {} is the active configuration; not creating a default init.lua",
function_name!(),
toml.display()
);
return Ok(None);
}
let path = default_lua_file()?;
if create_lua_file_at(&path)? {
info!("Created default Lua script at {}", path.display());
}
Ok(Some(path))
}
#[cfg(feature = "lua")]
fn create_lua_file_at(path: &Path) -> std::io::Result<bool> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(ErrorKind::InvalidInput, "Lua script path has no parent")
})?;
create_dir_all(parent)?;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut file) => {
file.write_all(DEFAULT_LUA_SCRIPT.as_bytes())?;
Ok(true)
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => Ok(false),
Err(error) => Err(error),
}
}
pub fn deprecated_options_in_input(input: &str) -> Result<Vec<String>> {
const DEPRECATED_KEYS: [&str; 16] = [
"padding_top",
"padding_bottom",
"padding_left",
"padding_right",
"dim_inactive_windows",
"dim_inactive_color",
"border_active_window",
"border_color",
"border_opacity",
"border_width",
"border_radius",
"swipe_gesture_fingers",
"swipe_gesture_direction",
"continuous_swipe",
"swipe_sensitivity",
"swipe_deceleration",
];
let value: toml::Value = toml::from_str(input)?;
let Some(options) = value.get("options").and_then(toml::Value::as_table) else {
return Ok(Vec::new());
};
Ok(DEPRECATED_KEYS
.into_iter()
.filter(|key| options.contains_key(*key))
.map(str::to_string)
.collect())
}
pub fn deprecated_options_in_file(path: &Path) -> Result<Vec<String>> {
let input = read_to_string(path)?;
deprecated_options_in_input(&input)
}
pub fn parse_command(argv: &[&str]) -> Result<Command> {
crate::types::commands::parse_command(argv)
.map_err(|err| Error::InvalidConfig(format!("{}: {err}", function_name!())))
}
#[derive(Clone, Debug, Resource)]
pub struct Config {
inner: Arc<ArcSwap<InnerConfig>>,
}
impl Config {
pub fn new(path: &Path) -> Result<Self> {
let input = read_to_string(path)?;
Ok(Config {
inner: Arc::new(ArcSwap::from_pointee(InnerConfig::new(&input)?)),
})
}
pub fn defaults() -> Result<Self> {
Ok(Config {
inner: Arc::new(ArcSwap::from_pointee(InnerConfig::new(
DEFAULT_CONFIGURATION,
)?)),
})
}
pub fn load(path: Option<&Path>) -> Result<Self> {
match path {
Some(path) => Self::new(path),
None => Self::defaults(),
}
}
pub fn reload_config(&mut self, path: &Path) -> Result<()> {
let input = read_to_string(path)?;
let new = InnerConfig::new(&input)?;
self.inner.store(Arc::new(new));
Ok(())
}
#[cfg(feature = "lua")]
pub(crate) fn replace_inner_from(&self, other: &Config) {
self.inner.store(other.inner.load_full());
}
fn inner(&self) -> Guard<Arc<InnerConfig>> {
self.inner.load()
}
pub fn options(&self) -> MainOptions {
self.inner().options.clone()
}
pub fn animation_speed(&self) -> f64 {
self.options()
.animation_speed
.unwrap_or(1_000_000.0)
.max(0.0)
}
pub fn find_keybind(&self, keycode: u8, mask: Modifiers) -> Option<Command> {
let config = self.inner();
config
.bindings
.values()
.flat_map(|binds| binds.all())
.find_map(|bind| {
(bind.code == keycode && bind.modifiers.matches(mask))
.then_some(bind.command.clone())
})
}
pub fn find_window_properties(&self, title: &str, bundle_id: &str) -> Vec<WindowParams> {
self.inner()
.windows
.as_ref()
.map(|windows| {
windows
.values()
.filter(|params| {
let bundle_match =
params.bundle_id.as_ref().map(|id| id.as_str() == bundle_id);
bundle_match.is_none_or(|m| m) && params.title.is_match(title)
})
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub fn should_force_manage_process(&self, process: &dyn ProcessApi) -> bool {
self.inner().windows.as_ref().is_some_and(|windows| {
let Some(bundle_id) = process
.application()
.as_ref()
.and_then(|app| app.bundleIdentifier())
.map(|id| id.to_string())
else {
return false;
};
windows.values().any(|params| {
params
.bundle_id
.as_ref()
.is_some_and(|id| id.as_str() == bundle_id)
&& params.manage.is_some_and(|manage| manage)
})
})
}
pub fn sliver_height(&self) -> f64 {
self.options().sliver_height.unwrap_or(1.0).clamp(0.1, 1.0)
}
pub fn sliver_width(&self) -> i32 {
i32::from(self.options().sliver_width.unwrap_or(5)).max(1)
}
pub fn edge_padding(&self) -> (i32, i32, i32, i32) {
let config = self.inner();
let o = &config.options;
let p = config.padding.as_ref();
(
i32::from(p.and_then(|p| p.top).or(o.padding_top).unwrap_or(0)),
i32::from(p.and_then(|p| p.right).or(o.padding_right).unwrap_or(0)),
i32::from(p.and_then(|p| p.bottom).or(o.padding_bottom).unwrap_or(0)),
i32::from(p.and_then(|p| p.left).or(o.padding_left).unwrap_or(0)),
)
}
pub fn preset_column_widths(&self) -> Vec<f64> {
self.options().preset_column_widths
}
pub fn preset_stack_heights(&self) -> Vec<f64> {
self.options().preset_stack_heights
}
pub fn swipe_gesture_direction(&self) -> SwipeGestureDirection {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.gesture.as_ref())
.and_then(|gesture| gesture.direction.clone())
.clone()
.or(config.options.swipe_gesture_direction.clone())
.unwrap_or(SwipeGestureDirection::Natural)
}
pub fn swipe_gesture_fingers(&self) -> Option<usize> {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.gesture.as_ref())
.and_then(|gesture| gesture.fingers_count)
.or(config.options.swipe_gesture_fingers)
}
pub fn swipe_vertical(&self) -> bool {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.gesture.as_ref())
.and_then(|gesture| gesture.vertical)
.is_none_or(|vertical| vertical)
}
pub fn has_dim_inactive_color(&self) -> bool {
let config = self.inner();
config
.decorations
.as_ref()
.and_then(|decorations| decorations.inactive.as_ref())
.and_then(|inactive| inactive.dim.as_ref())
.and_then(|dim| dim.color.as_ref())
.is_some()
|| config.options.dim_inactive_color.is_some()
}
pub fn dim_inactive_opacity(&self) -> f32 {
let config = self.inner();
let color = config
.decorations
.as_ref()
.and_then(|decorations| decorations.inactive.as_ref())
.and_then(|inactive| inactive.dim.as_ref())
.and_then(|dim| dim.color.as_ref())
.or(config.options.dim_inactive_color.as_ref());
if color.is_none() {
return 0.0;
}
config
.decorations
.as_ref()
.and_then(|decorations| decorations.inactive.as_ref())
.and_then(|inactive| inactive.dim.as_ref())
.and_then(|dim| dim.opacity)
.or(config.options.dim_inactive_windows)
.unwrap_or(0.0)
.clamp(0.0, 1.0)
}
pub fn dim_inactive_color(&self) -> (f64, f64, f64) {
let config = self.inner();
config
.decorations
.as_ref()
.and_then(|decorations| decorations.inactive.as_ref())
.and_then(|inactive| inactive.dim.as_ref())
.and_then(|dim| dim.color.as_deref())
.or(config.options.dim_inactive_color.as_deref())
.map_or((0.0, 0.0, 0.0), parse_hex_color)
}
pub fn border_active_window(&self) -> bool {
let config = self.inner();
config
.decorations
.as_ref()
.and_then(|decorations| decorations.active.as_ref())
.and_then(|active| active.border.as_ref())
.and_then(|border| border.enabled)
.or(config.options.border_active_window)
.unwrap_or(false)
}
pub fn border_color(&self) -> (f64, f64, f64) {
let config = self.inner();
config
.decorations
.as_ref()
.and_then(|decorations| decorations.active.as_ref())
.and_then(|active| active.border.as_ref())
.and_then(|border| border.color.as_deref())
.or(config.options.border_color.as_deref())
.map_or((1.0, 1.0, 1.0), parse_hex_color)
}
pub fn border_opacity(&self) -> f64 {
let config = self.inner();
config
.decorations
.as_ref()
.and_then(|decorations| decorations.active.as_ref())
.and_then(|active| active.border.as_ref())
.and_then(|border| border.opacity)
.or(config.options.border_opacity)
.unwrap_or(1.0)
.clamp(0.0, 1.0)
}
pub fn border_width(&self) -> f64 {
let config = self.inner();
config
.decorations
.as_ref()
.and_then(|decorations| decorations.active.as_ref())
.and_then(|active| active.border.as_ref())
.and_then(|border| border.width)
.or(config.options.border_width)
.unwrap_or(2.0)
.max(0.0)
}
pub fn border_radius(&self) -> BorderRadiusOption {
let config = self.inner();
match config
.decorations
.as_ref()
.and_then(|decorations| decorations.active.as_ref())
.and_then(|active| active.border.as_ref())
.and_then(|border| border.radius.clone())
.or(config.options.border_radius.clone())
.unwrap_or(BorderRadiusOption::Auto)
{
BorderRadiusOption::Auto if macos_major_version() == 26 => BorderRadiusOption::Auto,
BorderRadiusOption::Value(value) => BorderRadiusOption::Value(value.max(0.0)),
BorderRadiusOption::Auto => BorderRadiusOption::Value(10.0),
}
}
pub fn menubar_height(&self) -> Option<i32> {
self.options().menubar_height.map(i32::from)
}
pub fn swipe_sensitivity(&self) -> f64 {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.sensitivity)
.or(config.options.swipe_sensitivity)
.unwrap_or(0.35)
.clamp(0.1, 2.0)
}
pub fn continuous_swipe(&self) -> bool {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.continuous)
.or(config.options.continuous_swipe)
.unwrap_or(true)
}
pub fn swipe_deceleration(&self) -> f64 {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.deceleration)
.or(config.options.swipe_deceleration)
.unwrap_or(4.0)
.clamp(1.0, 10.0)
}
pub fn mouse_resize_modifier(&self) -> Option<Modifiers> {
self.options().mouse_resize_modifier
}
pub fn restore_enabled(&self) -> bool {
self.inner()
.restore
.as_ref()
.and_then(|restore| restore.enabled)
.unwrap_or(true)
}
pub fn restore_startup_grace(&self) -> Duration {
Duration::from_millis(
self.inner()
.restore
.as_ref()
.and_then(|restore| restore.startup_grace_ms)
.unwrap_or(2000),
)
}
pub fn restore_missing_windows(&self) -> MissingWindowBehavior {
self.inner()
.restore
.as_ref()
.and_then(|restore| restore.missing_windows)
.unwrap_or(MissingWindowBehavior::Ignore)
}
pub fn swipe_scroll_modifier(&self) -> Modifiers {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.scroll.as_ref())
.and_then(|scroll| scroll.modifier)
.unwrap_or(Modifiers::ALT)
}
pub fn swipe_scroll_vertical_modifier(&self) -> Option<Modifiers> {
let config = self.inner();
config
.swipe
.as_ref()
.and_then(|swipe| swipe.scroll.as_ref())
.and_then(|scroll| scroll.vertical_modifier)
}
pub fn swipe_scroll_window_step(&self) -> bool {
self.inner()
.swipe
.as_ref()
.and_then(|swipe| swipe.scroll.as_ref())
.and_then(|scroll| scroll.window_step)
.unwrap_or(false)
}
pub fn window_dim_ratio(&self, is_dark: bool) -> Option<f32> {
let config = self.inner();
if config
.decorations
.as_ref()
.and_then(|decorations| decorations.inactive.as_ref())
.and_then(|inactive| inactive.dim.as_ref())
.and_then(|dim| dim.color.as_ref())
.is_some()
|| config.options.dim_inactive_color.is_some()
{
return None;
}
let dim = config
.decorations
.as_ref()
.and_then(|decorations| decorations.inactive.as_ref())
.and_then(|inactive| inactive.dim.as_ref());
if is_dark {
dim.and_then(|d| d.opacity_night)
.or(dim.and_then(|d| d.opacity))
.or(config.options.dim_inactive_windows)
} else {
dim.and_then(|d| d.opacity)
.or(config.options.dim_inactive_windows)
}
}
pub fn window_hidden_ratio(&self) -> f64 {
self.options()
.window_hidden_ratio
.unwrap_or(0.0)
.clamp(0.0, 1.0)
}
pub fn window_resize_cycle(&self) -> bool {
self.options().window_resize_cycle.unwrap_or(true)
}
pub fn auto_center(&self) -> bool {
self.options().auto_center.is_some_and(|center| center)
}
pub fn horizontal_mouse_warp(&self) -> Option<i16> {
self.options().horizontal_mouse_warp
}
pub fn focus_follows_mouse(&self) -> bool {
self.options().focus_follows_mouse.is_none_or(|ffm| ffm)
}
pub fn mouse_follows_focus(&self) -> bool {
self.options().mouse_follows_focus.is_none_or(|mff| mff)
}
pub fn horizontal_mouse_warp_offset(&self) -> i32 {
self.options().horizontal_mouse_warp_offset.unwrap_or(0)
}
pub fn reap_empty_workspaces(&self) -> bool {
self.options()
.reap_empty_workspaces
.is_some_and(|reap| reap)
}
pub fn native_tabs_enabled(&self) -> bool {
!self
.options()
.disable_native_tabs
.is_some_and(|disabled| disabled)
}
pub fn workspace_menu_status(&self) -> bool {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.workspace_menu_status)
.is_none_or(|enabled| enabled)
}
pub fn workspace_popup_status(&self) -> bool {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.workspace_popup_status)
.is_none_or(|enabled| enabled)
}
pub fn virtual_workspace_animations(&self) -> bool {
self.options()
.virtual_workspace_animations
.is_some_and(|enabled| enabled)
}
pub fn default_workspaces(&self) -> u32 {
self.inner().default_workspaces.unwrap_or(1).max(1)
}
pub fn insert_windows_mid_strip(&self) -> bool {
self.options()
.insert_windows_mid_strip
.is_some_and(|enabled| enabled)
}
pub fn create_workspace_automatically(&self) -> bool {
self.options()
.create_virtual_workspace_automatically
.is_some_and(|enabled| enabled)
}
pub fn menubar_orientation(&self) -> MenubarOrientation {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.orientation)
.unwrap_or(MenubarOrientation::Default)
}
pub fn menubar_gradient(&self) -> Vec<(f64, f64, f64)> {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.colors.as_ref())
.into_iter()
.flatten()
.map(|hex_string| resolve_menubar_color(hex_string))
.collect()
}
pub fn menubar_has_external_gradient(&self) -> bool {
self.menubar_gradient()
.iter()
.any(|(red, _, _)| !red.is_nan())
}
pub fn menubar_gradient_angle(&self) -> f64 {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.angle)
.unwrap_or(90.0)
}
pub fn menubar_descriptor_style(&self) -> DescriptorStyle {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.descriptor.as_ref())
.and_then(|descriptor_options| descriptor_options.style)
.unwrap_or(DescriptorStyle::Symbol)
}
pub fn menubar_descriptor_text(&self) -> String {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.descriptor.as_ref())
.and_then(|descriptor_options| descriptor_options.text.clone())
.unwrap_or(String::from("VW"))
}
pub fn menubar_descriptor_symbol(&self) -> String {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.descriptor.as_ref())
.and_then(|descriptor_options| descriptor_options.symbol.clone())
.unwrap_or(String::from("fish.fill"))
}
pub fn menubar_indicator_style(&self) -> IndicatorStyle {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.indicator.as_ref())
.and_then(|indicator| indicator.style)
.unwrap_or(IndicatorStyle::Mono)
}
pub fn menubar_indicator_format(&self) -> IndicatorFormat {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.indicator.as_ref())
.and_then(|indicator| indicator.format)
.unwrap_or(IndicatorFormat::Default)
}
pub fn menubar_indicator_font_size(&self) -> f64 {
const DEFAULT_FONT_SIZE: f64 = 13.0;
const MIN_FONT_SIZE: f64 = 1.0;
const MAX_FONT_SIZE: f64 = 24.0;
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.indicator.as_ref())
.and_then(|indicator| indicator.font_size)
.filter(|font_size| font_size.is_finite())
.unwrap_or(DEFAULT_FONT_SIZE)
.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE)
}
pub fn menubar_indicator_active_character(&self) -> char {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.indicator.as_ref())
.and_then(|indicator| indicator.active_character)
.unwrap_or('☉')
}
pub fn menubar_indicator_inactive_character(&self) -> char {
self.inner()
.decorations
.as_ref()
.and_then(|decorations| decorations.menu.as_ref())
.and_then(|menubar| menubar.indicator.as_ref())
.and_then(|indicator| indicator.inactive_character)
.unwrap_or('â—‹')
}
}
fn resolve_menubar_color(hex: &str) -> (f64, f64, f64) {
let check_string = hex.is_empty();
if !check_string {
return parse_hex_color(hex);
}
(f64::NAN, f64::NAN, f64::NAN)
}
fn parse_hex_color(hex: &str) -> (f64, f64, f64) {
let hex = hex.strip_prefix('#').unwrap_or(hex);
if hex.len() != 6 {
return (1.0, 1.0, 1.0);
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255);
(
f64::from(r) / 255.0,
f64::from(g) / 255.0,
f64::from(b) / 255.0,
)
}
impl Default for Config {
fn default() -> Self {
Config {
inner: Arc::new(ArcSwap::from_pointee(InnerConfig::default())),
}
}
}
impl TryFrom<&str> for Config {
type Error = crate::errors::Error;
fn try_from(input: &str) -> std::result::Result<Self, Self::Error> {
Ok(Config {
inner: Arc::new(ArcSwap::from_pointee(InnerConfig::new(input)?)),
})
}
}
impl From<(MainOptions, Vec<WindowParams>)> for Config {
fn from((options, params): (MainOptions, Vec<WindowParams>)) -> Self {
Self {
inner: Arc::new(ArcSwap::from_pointee(InnerConfig {
options,
windows: params
.into_iter()
.enumerate()
.map(|(nr, param)| Some((format!("param{nr}"), param)))
.collect(),
..Default::default()
})),
}
}
}
#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum OneOrMore {
Single(Keybinding),
Multiple(Vec<Keybinding>),
}
impl OneOrMore {
fn all(&self) -> Vec<&Keybinding> {
match self {
OneOrMore::Single(one) => vec![one],
OneOrMore::Multiple(many) => many.iter().collect::<Vec<_>>(),
}
}
fn all_mut(&mut self) -> Vec<&mut Keybinding> {
match self {
OneOrMore::Single(one) => vec![one],
OneOrMore::Multiple(many) => many.iter_mut().collect::<Vec<_>>(),
}
}
}
#[derive(Deserialize, Debug, Default)]
struct InnerConfig {
#[serde(default)]
options: MainOptions,
#[serde(default)]
bindings: HashMap<String, OneOrMore>,
windows: Option<HashMap<String, WindowParams>>,
decorations: Option<decorations::DecorationsOptions>,
default_workspaces: Option<u32>,
swipe: Option<swipe::SwipeOptions>,
padding: Option<padding::PaddingOptions>,
restore: Option<RestoreOptions>,
}
impl InnerConfig {
fn new(input: &str) -> Result<InnerConfig> {
InnerConfig::parse_config(input)
}
fn parse_config(input: &str) -> Result<InnerConfig> {
let config: InnerConfig = toml::from_str(input)?;
if !config.needs_virtual_keys() {
return Ok(config);
}
let virtual_keys = generate_virtual_keymap();
Self::parse_config_with_virtual_keys(input, &virtual_keys)
}
fn parse_config_with_virtual_keys(
input: &str,
virtual_keys: &[(String, u8)],
) -> Result<InnerConfig> {
let mut config: InnerConfig = toml::from_str(input)?;
for (command, bindings) in &mut config.bindings {
let argv = command.split('_').collect::<Vec<_>>();
for binding in bindings.all_mut() {
binding.command = parse_command(&argv)?;
if let Some(code) = keycode_for_key_name(&binding.key, virtual_keys) {
binding.code = code;
info!("bind: {binding:?}");
} else {
error!("{}: invalid key '{}'", function_name!(), &binding.key);
}
}
}
if let Some(windows) = &mut config.windows {
for params in windows.values_mut() {
for input in ¶ms.bindings_passthrough {
match resolve_keybinding_str(input, virtual_keys) {
Ok(pair) => params.parsed_passthrough.push(pair),
Err(err) => error!("passthrough: {err}"),
}
}
}
}
Ok(config)
}
fn needs_virtual_keys(&self) -> bool {
!self.bindings.is_empty()
|| self.windows.as_ref().is_some_and(|windows| {
windows
.values()
.any(|params| !params.bindings_passthrough.is_empty())
})
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
#[cfg_attr(test, derive(serde::Serialize))]
#[serde(rename_all = "snake_case")]
pub enum MissingWindowBehavior {
Ignore,
}
#[derive(Clone, Debug, Deserialize, Default)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct RestoreOptions {
pub enabled: Option<bool>,
pub startup_grace_ms: Option<u64>,
pub missing_windows: Option<MissingWindowBehavior>,
}
#[derive(Deserialize, Clone, Debug, Default)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct MainOptions {
pub focus_follows_mouse: Option<bool>,
pub mouse_follows_focus: Option<bool>,
pub horizontal_mouse_warp: Option<i16>,
pub horizontal_mouse_warp_offset: Option<i32>,
#[serde(default = "default_preset_column_widths")]
pub preset_column_widths: Vec<f64>,
#[serde(default = "default_preset_stack_heights")]
pub preset_stack_heights: Vec<f64>,
pub animation_speed: Option<f64>,
pub auto_center: Option<bool>,
pub sliver_height: Option<f64>,
pub sliver_width: Option<u16>,
pub padding_top: Option<u16>,
pub padding_bottom: Option<u16>,
pub padding_left: Option<u16>,
pub padding_right: Option<u16>,
pub dim_inactive_windows: Option<f32>,
pub dim_inactive_color: Option<String>,
pub border_active_window: Option<bool>,
pub border_color: Option<String>,
pub border_opacity: Option<f64>,
pub border_width: Option<f64>,
#[serde(
default,
deserialize_with = "decorations::deserialize_border_radius_option"
)]
pub border_radius: Option<BorderRadiusOption>,
pub swipe_gesture_fingers: Option<usize>,
pub swipe_gesture_direction: Option<SwipeGestureDirection>,
#[allow(dead_code)]
pub continuous_swipe: Option<bool>,
pub swipe_sensitivity: Option<f64>,
pub swipe_deceleration: Option<f64>,
#[serde(default, deserialize_with = "deserialize_modifier")]
#[cfg_attr(test, serde(serialize_with = "serialize_modifier"))]
pub mouse_resize_modifier: Option<Modifiers>,
pub menubar_height: Option<u16>,
pub window_hidden_ratio: Option<f64>,
pub window_resize_cycle: Option<bool>,
pub reap_empty_workspaces: Option<bool>,
pub disable_native_tabs: Option<bool>,
pub virtual_workspace_animations: Option<bool>,
pub insert_windows_mid_strip: Option<bool>,
pub create_virtual_workspace_automatically: Option<bool>,
}
pub fn default_preset_column_widths() -> Vec<f64> {
vec![0.25, 0.33333, 0.50, 0.66667, 0.75, 1.0, 1.5, 2.0]
}
pub fn default_preset_stack_heights() -> Vec<f64> {
vec![0.25, 0.33333, 0.50, 0.66667, 0.75]
}
#[derive(Debug)]
pub struct Keybinding {
pub key: String,
pub code: u8,
pub modifiers: Modifiers,
pub command: Command,
}
impl<'de> Deserialize<'de> for Keybinding {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let input = String::deserialize(deserializer)?;
let mut parts = input.split('-').map(str::trim).collect::<Vec<_>>();
let key = parts.pop();
if parts.len() > 1 || key.is_none() {
return Err(de::Error::custom(format!("Too many dashes: {input:?}")));
}
let modifiers = match parts.pop() {
Some(modifiers) => parse_modifiers(modifiers).map_err(de::Error::custom)?,
None => Modifiers::empty(),
};
Ok(Keybinding {
key: key.unwrap().to_string(),
code: 0,
modifiers,
command: Command::Quit,
})
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(untagged)]
pub enum InitialWindowWidth {
Ratio(f64),
Mode(InitialWindowWidthMode),
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InitialWindowWidthMode {
ComplementFocused,
}
impl InitialWindowWidth {
pub fn ratio(self, focused_ratio: Option<f64>) -> f64 {
match self {
Self::Ratio(ratio) => ratio,
Self::Mode(InitialWindowWidthMode::ComplementFocused) => {
let Some(ratio) = focused_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
else {
return 1.0;
};
if ratio >= 0.95 { 1.0 } else { 1.0 - ratio }
}
}
}
}
#[test]
#[allow(clippy::float_cmp)]
fn test_initial_window_width_config() {
let config = Config::try_from(
r#"
[windows.dynamic]
title = ".*"
width = "complement_focused"
"#,
)
.expect("dynamic width rule parses");
let width = config.find_window_properties("Window 1", "")[0]
.width
.expect("matched width rule");
assert_eq!(width.ratio(Some(0.25)), 0.75);
assert_eq!(width.ratio(Some(0.5)), 0.5);
assert_eq!(width.ratio(Some(0.95)), 1.0);
assert_eq!(width.ratio(Some(1.0)), 1.0);
assert_eq!(width.ratio(None), 1.0);
let config = Config::try_from(
r#"
[windows.fixed]
title = ".*"
width = 0.5
"#,
)
.expect("fixed width rule still parses");
let width = config.find_window_properties("Window 1", "")[0]
.width
.expect("matched width rule");
assert_eq!(width.ratio(Some(0.25)), 0.5);
}
#[derive(Clone, Debug, Deserialize)]
pub struct WindowParams {
#[serde(deserialize_with = "deserialize_title")]
title: Regex,
bundle_id: Option<String>,
pub floating: Option<bool>,
pub manage: Option<bool>,
pub index: Option<usize>,
pub vertical_padding: Option<i32>,
pub horizontal_padding: Option<i32>,
pub dont_focus: Option<bool>,
pub width: Option<InitialWindowWidth>,
pub grid: Option<String>,
pub border_radius: Option<f64>,
#[serde(default)]
bindings_passthrough: Vec<String>,
#[serde(skip)]
parsed_passthrough: Vec<(u8, Modifiers)>,
}
impl WindowParams {
#![allow(unused)]
pub fn new(title: &str, bundle_id: Option<String>) -> Self {
Self {
title: Regex::new(title).unwrap(),
bundle_id,
floating: None,
manage: None,
index: None,
vertical_padding: None,
horizontal_padding: None,
dont_focus: None,
width: None,
grid: None,
border_radius: None,
bindings_passthrough: Vec::new(),
parsed_passthrough: Vec::new(),
}
}
pub fn passthrough_keys(&self) -> &[(u8, Modifiers)] {
&self.parsed_passthrough
}
pub fn grid_ratios(&self) -> Option<(f64, f64, f64, f64)> {
let grid = self.grid.as_ref()?;
let parts: Vec<f64> = grid.split(':').filter_map(|s| s.parse().ok()).collect();
if parts.len() != 6 {
return None;
}
let (cols, rows) = (parts[0], parts[1]);
if cols <= 0.0 || rows <= 0.0 {
return None;
}
Some((
parts[2] / cols,
parts[3] / rows,
parts[4] / cols,
parts[5] / rows,
))
}
}
fn deserialize_title<'de, D>(deserializer: D) -> std::result::Result<Regex, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Regex::new(&s).map_err(de::Error::custom)
}
fn deserialize_modifier<'de, D>(deserializer: D) -> std::result::Result<Option<Modifiers>, D::Error>
where
D: serde::Deserializer<'de>,
{
let Some(s) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
parse_modifiers(&s)
.map(Some)
.map_err(|e: Error| serde::de::Error::custom(e.to_string()))
}
#[cfg(test)]
#[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
pub(crate) fn serialize_modifier<S: serde::Serializer>(
val: &Option<Modifiers>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_some(&val.as_ref().map(Modifiers::bits))
}
#[cfg(feature = "lua")]
pub(crate) fn format_modifiers(modifiers: Modifiers) -> String {
let mut rem = modifiers;
let mut parts = Vec::new();
for (mask, name) in [
(Modifiers::ALT, "alt"),
(Modifiers::LALT, "lalt"),
(Modifiers::RALT, "ralt"),
(Modifiers::SHIFT, "shift"),
(Modifiers::LSHIFT, "lshift"),
(Modifiers::RSHIFT, "rshift"),
(Modifiers::CMD, "cmd"),
(Modifiers::LCMD, "lcmd"),
(Modifiers::RCMD, "rcmd"),
(Modifiers::CTRL, "ctrl"),
(Modifiers::LCTRL, "lctrl"),
(Modifiers::RCTRL, "rctrl"),
(Modifiers::FN, "fn"),
] {
if rem.contains(mask) {
parts.push(name);
rem.remove(mask);
}
}
parts.join(" + ")
}
#[cfg(feature = "lua")]
pub(crate) fn config_from_lua(lua: &mlua::Lua, value: mlua::Value) -> mlua::Result<Config> {
use mlua::LuaSerdeExt;
if !value.is_table() {
return Err(mlua::Error::RuntimeError(
"paneru.setup: expected a table".to_string(),
));
}
let mut inner: InnerConfig = lua.from_value(value)?;
inner.bindings.clear();
let needs_keys = inner.windows.as_ref().is_some_and(|windows| {
windows
.values()
.any(|params| !params.bindings_passthrough.is_empty())
});
if needs_keys {
let virtual_keys = virtual_keymap();
if let Some(windows) = &mut inner.windows {
for params in windows.values_mut() {
for chord in ¶ms.bindings_passthrough {
match resolve_keybinding_str(chord, virtual_keys) {
Ok(pair) => params.parsed_passthrough.push(pair),
Err(err) => error!("paneru.setup passthrough: {err}"),
}
}
}
}
}
Ok(Config {
inner: Arc::new(ArcSwap::from_pointee(inner)),
})
}
#[cfg(feature = "lua")]
pub(crate) fn resolve_chord(input: &str) -> Result<(u8, Modifiers)> {
if let Ok(resolved) = resolve_keybinding_str(input, &[]) {
return Ok(resolved);
}
resolve_keybinding_str(input, virtual_keymap())
}
#[cfg(feature = "lua")]
static VIRTUAL_KEYMAP: std::sync::OnceLock<Vec<(String, u8)>> = std::sync::OnceLock::new();
#[cfg(feature = "lua")]
pub(crate) fn prime_virtual_keymap() {
let _ = VIRTUAL_KEYMAP.set(generate_virtual_keymap());
}
#[cfg(feature = "lua")]
fn virtual_keymap() -> &'static [(String, u8)] {
VIRTUAL_KEYMAP.get_or_init(generate_virtual_keymap)
}
fn resolve_keybinding_str(input: &str, virtual_keys: &[(String, u8)]) -> Result<(u8, Modifiers)> {
let mut parts: Vec<&str> = input.split('-').map(str::trim).collect();
let key = parts
.pop()
.ok_or_else(|| Error::InvalidConfig("Empty keybinding string".to_string()))?;
let modifiers = match parts.pop() {
Some(mods) => parse_modifiers(mods)?,
None => Modifiers::empty(),
};
if !parts.is_empty() {
return Err(Error::InvalidConfig(format!(
"Too many dashes in keybinding: {input:?}"
)));
}
let code = keycode_for_key_name(key, virtual_keys).ok_or_else(|| {
Error::InvalidConfig(format!("Unknown key '{key}' in keybinding: {input:?}"))
})?;
Ok((code, modifiers))
}
fn keycode_for_key_name(key: &str, virtual_keys: &[(String, u8)]) -> Option<u8> {
virtual_keys
.iter()
.find(|(k, _)| k == key)
.map(|(_, c)| *c)
.or_else(|| virtual_keycode().find(|(k, _)| *k == key).map(|(_, c)| *c))
.or_else(|| literal_keycode().find(|(k, _)| *k == key).map(|(_, c)| *c))
}
fn parse_modifiers(input: &str) -> Result<Modifiers> {
let mut out = Modifiers::empty();
let modifiers = input.split('+').map(str::trim).collect::<Vec<_>>();
for modifier in &modifiers {
out |= match *modifier {
"alt" => Modifiers::ALT,
"lalt" => Modifiers::LALT,
"ralt" => Modifiers::RALT,
"shift" => Modifiers::SHIFT,
"lshift" => Modifiers::LSHIFT,
"rshift" => Modifiers::RSHIFT,
"cmd" => Modifiers::CMD,
"lcmd" => Modifiers::LCMD,
"rcmd" => Modifiers::RCMD,
"ctrl" => Modifiers::CTRL,
"lctrl" => Modifiers::LCTRL,
"rctrl" => Modifiers::RCTRL,
"fn" => Modifiers::FN,
_ => {
return Err(Error::InvalidConfig(format!(
"{}: Invalid modifier: {modifier}",
function_name!()
)));
}
}
}
Ok(out)
}
#[link(name = "Carbon", kind = "framework")]
unsafe extern "C" {
fn TISCopyCurrentASCIICapableKeyboardLayoutInputSource() -> *mut c_void;
fn TISGetInputSourceProperty(keyboard: *const c_void, property: CFStringRef) -> *mut CFData;
fn UCKeyTranslate(
keyLayoutPtr: *mut u8,
virtualKeyCode: u16,
keyAction: u16,
modifierKeyState: u32,
keyboardType: u32,
keyTranslateOptions: u32,
deadKeyState: &mut u32,
maxStringLength: usize,
actualStringLength: &mut isize,
unicodeString: *mut u16,
) -> OSStatus;
fn LMGetKbdType() -> u8;
static kTISPropertyUnicodeKeyLayoutData: CFStringRef;
}
fn virtual_keycode() -> impl Iterator<Item = &'static (&'static str, u8)> {
static VIRTUAL_KEYCODE: LazyLock<Vec<(&'static str, u8)>> = LazyLock::new(|| {
vec![
("a", 0x00),
("s", 0x01),
("d", 0x02),
("f", 0x03),
("h", 0x04),
("g", 0x05),
("z", 0x06),
("x", 0x07),
("c", 0x08),
("v", 0x09),
("section", 0x0a), ("b", 0x0b),
("q", 0x0c),
("w", 0x0d),
("e", 0x0e),
("r", 0x0f),
("y", 0x10),
("t", 0x11),
("1", 0x12),
("2", 0x13),
("3", 0x14),
("4", 0x15),
("6", 0x16),
("5", 0x17),
("equal", 0x18),
("9", 0x19),
("7", 0x1a),
("minus", 0x1b),
("8", 0x1c),
("0", 0x1d),
("rightbracket", 0x1e),
("o", 0x1f),
("u", 0x20),
("leftbracket", 0x21),
("i", 0x22),
("p", 0x23),
("l", 0x25),
("j", 0x26),
("quote", 0x27),
("k", 0x28),
("semicolon", 0x29),
("backslash", 0x2a),
("comma", 0x2b),
("slash", 0x2c),
("n", 0x2d),
("m", 0x2e),
("period", 0x2f),
("grave", 0x32),
("keypaddecimal", 0x41),
("keypadmultiply", 0x43),
("keypadplus", 0x45),
("keypadclear", 0x47),
("keypaddivide", 0x4b),
("keypadenter", 0x4c),
("keypadminus", 0x4e),
("keypadequals", 0x51),
("keypad0", 0x52),
("keypad1", 0x53),
("keypad2", 0x54),
("keypad3", 0x55),
("keypad4", 0x56),
("keypad5", 0x57),
("keypad6", 0x58),
("keypad7", 0x59),
("keypad8", 0x5b),
("keypad9", 0x5c),
]
});
VIRTUAL_KEYCODE.iter()
}
fn literal_keycode() -> impl Iterator<Item = &'static (&'static str, u8)> {
static LITERAL_KEYCODE: LazyLock<Vec<(&'static str, u8)>> = LazyLock::new(|| {
vec![
("return", 0x24),
("tab", 0x30),
("space", 0x31),
("delete", 0x33),
("escape", 0x35),
("command", 0x37),
("shift", 0x38),
("capslock", 0x39),
("option", 0x3a),
("control", 0x3b),
("rightcommand", 0x36),
("rightshift", 0x3c),
("rightoption", 0x3d),
("rightcontrol", 0x3e),
("function", 0x3f),
("f17", 0x40),
("volumeup", 0x48),
("volumedown", 0x49),
("mute", 0x4a),
("f18", 0x4f),
("f19", 0x50),
("f20", 0x5a),
("f5", 0x60),
("f6", 0x61),
("f7", 0x62),
("f3", 0x63),
("f8", 0x64),
("f9", 0x65),
("f11", 0x67),
("f13", 0x69),
("f16", 0x6a),
("f14", 0x6b),
("f10", 0x6d),
("contextualmenu", 0x6e),
("f12", 0x6f),
("f15", 0x71),
("help", 0x72),
("home", 0x73),
("pageup", 0x74),
("forwarddelete", 0x75),
("f4", 0x76),
("end", 0x77),
("f2", 0x78),
("pagedown", 0x79),
("f1", 0x7a),
("leftarrow", 0x7b),
("rightarrow", 0x7c),
("downarrow", 0x7d),
("uparrow", 0x7e),
]
});
LITERAL_KEYCODE.iter()
}
enum UCKeyAction {
Down = 0,
}
fn generate_virtual_keymap() -> Vec<(String, u8)> {
let keyboard = AXUIWrapper::from_retained(unsafe {
TISCopyCurrentASCIICapableKeyboardLayoutInputSource()
})
.ok();
let keyboard_layout = keyboard
.and_then(|keyboard| {
NonNull::new(unsafe {
TISGetInputSourceProperty(
keyboard.as_ptr::<c_void>(),
kTISPropertyUnicodeKeyLayoutData,
)
})
})
.and_then(|uchr| NonNull::new(unsafe { CFData::byte_ptr(uchr.as_ref()).cast_mut() }));
let Some(keyboard_layout) = keyboard_layout else {
error!(
"{}: problem fetching current virtual keyboard layout.",
function_name!()
);
return vec![];
};
let mut state = 0u32;
let mut chars = vec![0u16; 256];
let mut got: isize = 0;
virtual_keycode()
.filter_map(|(_, keycode)| {
unsafe {
UCKeyTranslate(
keyboard_layout.as_ptr(),
(*keycode).into(),
UCKeyAction::Down as u16,
0,
LMGetKbdType().into(),
1,
&mut state,
chars.len(),
&mut got,
chars.as_mut_ptr(),
)
}
.to_result(function_name!())
.ok()
.map(|()| {
let name = unsafe { CFString::with_characters(None, chars.as_ptr(), got) }
.map(|chars| chars.to_string());
name.zip(Some(*keycode))
})
})
.flatten()
.collect()
}
#[cfg(test)]
fn test_virtual_keymap() -> Vec<(String, u8)> {
virtual_keycode()
.map(|(key, code)| ((*key).to_string(), *code))
.collect()
}
#[test]
#[allow(clippy::float_cmp)]
#[allow(clippy::too_many_lines)]
fn test_config_parsing() {
let input = r#"
[options]
focus_follows_mouse = true
[bindings]
quit = "ctrl+alt-q"
window_manage = "ctrl+alt-t"
window_stack = ["ctrl-s", "alt-s"]
window_shrink = "alt-d"
window_snap = "fn-x"
[windows]
[windows.pip]
title = "picture.*picture"
bundle_id = "com.something.apple"
floating = true
index = 1
"#;
let virtual_keys = test_virtual_keymap();
let config = Config {
inner: Arc::new(ArcSwap::from_pointee(
InnerConfig::parse_config_with_virtual_keys(input, &virtual_keys)
.expect("Failed to parse config"),
)),
};
let find_key = |k| {
virtual_keycode()
.find_map(|(s, v)| (format!("{k}") == *s).then_some(*v))
.unwrap()
};
assert_eq!(config.inner().options.focus_follows_mouse, Some(true));
let keycode = find_key('q');
assert!(matches!(
config.find_keybind(keycode, Modifiers::ALT | Modifiers::CTRL),
Some(Command::Quit)
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::LALT | Modifiers::LCTRL),
Some(Command::Quit)
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::RALT | Modifiers::RCTRL),
Some(Command::Quit)
));
let keycode = find_key('t');
assert!(matches!(
config.find_keybind(keycode, Modifiers::ALT | Modifiers::CTRL),
Some(Command::Window(Operation::Manage))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::LALT | Modifiers::LCTRL),
Some(Command::Window(Operation::Manage))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::RALT | Modifiers::RCTRL),
Some(Command::Window(Operation::Manage))
));
let keycode = find_key('s');
assert!(matches!(
config.find_keybind(keycode, Modifiers::CTRL),
Some(Command::Window(Operation::Stack(true)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::LCTRL),
Some(Command::Window(Operation::Stack(true)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::RCTRL),
Some(Command::Window(Operation::Stack(true)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::ALT),
Some(Command::Window(Operation::Stack(true)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::LALT),
Some(Command::Window(Operation::Stack(true)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::RALT),
Some(Command::Window(Operation::Stack(true)))
));
let keycode = find_key('d');
assert!(matches!(
config.find_keybind(keycode, Modifiers::ALT),
Some(Command::Window(Operation::Resize(ResizeDirection::Shrink)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::LALT),
Some(Command::Window(Operation::Resize(ResizeDirection::Shrink)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::RALT),
Some(Command::Window(Operation::Resize(ResizeDirection::Shrink)))
));
let props = config.find_window_properties("picture in picture", "com.something.apple");
assert_eq!(props[0].floating, Some(true));
assert_eq!(props[0].index, Some(1));
let keycode = find_key('x');
assert!(matches!(
config.find_keybind(keycode, Modifiers::FN),
Some(Command::Window(Operation::Snap))
));
let defaults = Config::default();
assert_eq!(defaults.swipe_sensitivity(), 0.35);
assert_eq!(defaults.swipe_deceleration(), 4.0);
}
#[test]
fn test_config_parsing_absolute_virtual_workspace_bindings() {
let input = r#"
[options]
[bindings]
window_virtualnum_3 = "cmd + alt - 3"
window_virtualsendnum_3 = "cmd + alt + shift - 3"
"#;
let virtual_keys = test_virtual_keymap();
let config = Config {
inner: Arc::new(ArcSwap::from_pointee(
InnerConfig::parse_config_with_virtual_keys(input, &virtual_keys)
.expect("Failed to parse config"),
)),
};
let find_key = |k| {
virtual_keycode()
.find_map(|(s, v)| (format!("{k}") == *s).then_some(*v))
.unwrap()
};
let keycode = find_key('3');
assert!(matches!(
config.find_keybind(keycode, Modifiers::CMD | Modifiers::ALT),
Some(Command::Window(Operation::VirtualNumber(2)))
));
assert!(matches!(
config.find_keybind(keycode, Modifiers::CMD | Modifiers::ALT | Modifiers::SHIFT),
Some(Command::Window(Operation::VirtualMoveNumber(
2,
MoveFocus::Stay
)))
));
}
#[test]
fn test_parse_resize_commands() {
assert!(matches!(
parse_command(&["window", "resize"]).unwrap(),
Command::Window(Operation::Resize(ResizeDirection::Grow))
));
assert!(matches!(
parse_command(&["window", "grow"]).unwrap(),
Command::Window(Operation::Resize(ResizeDirection::Grow))
));
assert!(matches!(
parse_command(&["window", "resize", "shrink"]).unwrap(),
Command::Window(Operation::Resize(ResizeDirection::Shrink))
));
assert!(matches!(
parse_command(&["window", "shrink"]).unwrap(),
Command::Window(Operation::Resize(ResizeDirection::Shrink))
));
}
#[test]
fn test_parse_vertical_resize_commands() {
assert!(matches!(
parse_command(&["window", "vertical"]).unwrap(),
Command::Window(Operation::ResizeVertical(ResizeDirection::Grow))
));
assert!(matches!(
parse_command(&["window", "vertical", "resize"]).unwrap(),
Command::Window(Operation::ResizeVertical(ResizeDirection::Grow))
));
assert!(matches!(
parse_command(&["window", "vertical", "grow"]).unwrap(),
Command::Window(Operation::ResizeVertical(ResizeDirection::Grow))
));
assert!(matches!(
parse_command(&["window", "vertical", "shrink"]).unwrap(),
Command::Window(Operation::ResizeVertical(ResizeDirection::Shrink))
));
assert!(matches!(
parse_command(&["window", "vertical", "resize", "shrink"]).unwrap(),
Command::Window(Operation::ResizeVertical(ResizeDirection::Shrink))
));
assert!(parse_command(&["window", "vertical", "wider"]).is_err());
}
#[test]
fn test_parse_restart_command() {
assert!(matches!(
parse_command(&["restart"]).unwrap(),
Command::Restart
));
}
#[test]
fn test_parse_absolute_virtual_workspace_commands() {
assert!(matches!(
parse_command(&["window", "virtualnum", "3"]).unwrap(),
Command::Window(Operation::VirtualNumber(2))
));
assert!(parse_command(&["workspace", "virtual", "3"]).is_err());
assert!(matches!(
parse_command(&["window", "virtualmove", "3"]).unwrap(),
Command::Window(Operation::VirtualMoveNumber(2, MoveFocus::Follow))
));
assert!(matches!(
parse_command(&["window", "virtualsend", "3"]).unwrap(),
Command::Window(Operation::VirtualMoveNumber(2, MoveFocus::Stay))
));
assert!(matches!(
parse_command(&["window", "virtualmovenum", "3"]).unwrap(),
Command::Window(Operation::VirtualMoveNumber(2, MoveFocus::Follow))
));
assert!(matches!(
parse_command(&["window", "virtualsendnum", "3"]).unwrap(),
Command::Window(Operation::VirtualMoveNumber(2, MoveFocus::Stay))
));
assert!(matches!(
parse_command(&["window", "virtualadd"]).unwrap(),
Command::Window(Operation::VirtualAdd)
));
}
#[test]
fn test_default_workspaces() {
let base = "[options]\n[bindings]\n";
let config = Config::try_from(base).unwrap();
assert_eq!(config.default_workspaces(), 1);
let config = Config::try_from(&*format!("default_workspaces = 4\n{base}")).unwrap();
assert_eq!(config.default_workspaces(), 4);
let config = Config::try_from(&*format!("default_workspaces = 0\n{base}")).unwrap();
assert_eq!(config.default_workspaces(), 1);
}
#[test]
#[allow(clippy::float_cmp)]
fn test_grid_ratios() {
use regex::Regex;
let make = |grid: Option<&str>| WindowParams {
title: Regex::new(".*").unwrap(),
bundle_id: None,
floating: None,
manage: None,
index: None,
vertical_padding: None,
horizontal_padding: None,
dont_focus: None,
width: None,
grid: grid.map(Into::into),
border_radius: None,
bindings_passthrough: vec![],
parsed_passthrough: vec![],
};
assert_eq!(
make(Some("2:2:1:1:1:1")).grid_ratios(),
Some((0.5, 0.5, 0.5, 0.5))
);
assert_eq!(
make(Some("3:3:0:0:2:1")).grid_ratios(),
Some((0.0, 0.0, 2.0 / 3.0, 1.0 / 3.0))
);
assert_eq!(
make(Some("1:1:0:0:1:1")).grid_ratios(),
Some((0.0, 0.0, 1.0, 1.0))
);
assert_eq!(make(Some("2:2:1:1")).grid_ratios(), None);
assert_eq!(make(Some("0:2:0:0:1:1")).grid_ratios(), None);
assert_eq!(make(None).grid_ratios(), None);
}
#[test]
fn test_parse_hex_color_valid() {
assert_eq!(
parse_hex_color("#89b4fa"),
(
f64::from(0x89) / 255.0,
f64::from(0xb4) / 255.0,
f64::from(0xfa) / 255.0
)
);
assert_eq!(parse_hex_color("#000000"), (0.0, 0.0, 0.0));
assert_eq!(parse_hex_color("#FFFFFF"), (1.0, 1.0, 1.0));
assert_eq!(parse_hex_color("#FF0000"), (1.0, 0.0, 0.0));
}
#[test]
fn test_parse_hex_color_no_hash() {
assert_eq!(
parse_hex_color("89b4fa"),
(
f64::from(0x89) / 255.0,
f64::from(0xb4) / 255.0,
f64::from(0xfa) / 255.0
)
);
assert_eq!(parse_hex_color("FF0000"), (1.0, 0.0, 0.0));
}
#[test]
fn test_parse_hex_color_invalid_length() {
assert_eq!(parse_hex_color("#FFF"), (1.0, 1.0, 1.0));
assert_eq!(parse_hex_color(""), (1.0, 1.0, 1.0));
assert_eq!(parse_hex_color("#FF"), (1.0, 1.0, 1.0));
}
#[test]
fn test_parse_hex_color_malformed_hex() {
assert_eq!(parse_hex_color("ZZZZZZ"), (1.0, 1.0, 1.0));
assert_eq!(parse_hex_color("GG0000"), (1.0, 0.0, 0.0));
}
#[test]
#[allow(clippy::float_cmp)]
fn test_config_defaults() {
let config = Config::default();
assert_eq!(config.dim_inactive_opacity(), 0.0);
assert_eq!(config.dim_inactive_color(), (0.0, 0.0, 0.0));
assert!(!config.border_active_window());
assert_eq!(config.border_color(), (1.0, 1.0, 1.0));
assert_eq!(config.border_opacity(), 1.0);
assert_eq!(config.border_width(), 2.0);
assert_eq!(config.border_radius(), BorderRadiusOption::Auto);
assert_eq!(config.menubar_height(), None);
}
#[test]
fn test_first_launch_creates_parseable_config_without_overwriting_it() {
let unique = format!(
"paneru-first-launch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let directory = std::env::temp_dir().join(unique);
let path = directory.join("paneru.toml");
assert!(create_configuration_file_at(&path).unwrap());
Config::new(&path).expect("generated configuration should parse");
let custom = "[options]\nauto_center = false\n\n[bindings]\n";
std::fs::write(&path, custom).unwrap();
assert!(!create_configuration_file_at(&path).unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), custom);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn defaults_config_matches_the_generated_stub() {
let stub = InnerConfig::new(DEFAULT_CONFIGURATION).expect("the stub should parse");
let defaults = Config::defaults().expect("defaults should always build");
assert_eq!(
format!("{stub:?}"),
format!("{:?}", defaults.inner.load()),
"a missing TOML should behave exactly like the generated stub"
);
}
#[test]
fn test_window_rules_manage() {
let input = r#"
[options]
[bindings]
[windows.btt_main]
bundle_id = "com.hegenberg.BetterTouchTool"
title = "BetterTouchTool"
manage = true
[windows.btt_floating]
bundle_id = "com.hegenberg.BetterTouchTool"
title = "Screenshot.*"
floating = true
"#;
let config = Config::try_from(input).expect("config should parse");
let props = config.find_window_properties("BetterTouchTool", "com.hegenberg.BetterTouchTool");
assert_eq!(props.len(), 1);
assert_eq!(props[0].manage, Some(true));
let props = config.find_window_properties("Screenshot 1", "com.hegenberg.BetterTouchTool");
assert_eq!(props.len(), 1);
assert_eq!(props[0].floating, Some(true));
}
#[test]
fn test_restore_config_defaults() {
let config = Config::try_from("[options]\n\n[bindings]\n").expect("config should parse");
assert!(config.restore_enabled());
assert_eq!(config.restore_startup_grace(), Duration::from_secs(2));
assert_eq!(
config.restore_missing_windows(),
MissingWindowBehavior::Ignore
);
}
#[test]
fn test_restore_config_explicit_values() {
let config = Config::try_from(
r#"
[options]
[restore]
enabled = false
startup_grace_ms = 750
missing_windows = "ignore"
[bindings]
"#,
)
.expect("config should parse");
assert!(!config.restore_enabled());
assert_eq!(config.restore_startup_grace(), Duration::from_millis(750));
assert_eq!(
config.restore_missing_windows(),
MissingWindowBehavior::Ignore
);
}
#[test]
fn test_restore_config_rejects_unsupported_missing_window_policy() {
let err = Config::try_from(
r#"
[options]
[restore]
missing_windows = "reserve"
[bindings]
"#,
)
.expect_err("unsupported restore missing-window policy should fail");
assert!(err.to_string().contains("unknown variant"));
}
#[test]
fn test_static_virtual_key_names_can_be_bound() {
let config = Config::try_from(
r#"
[options]
[bindings]
window_grow = "alt - minus"
"#,
)
.unwrap();
let minus_keycode = virtual_keycode()
.find_map(|(key, code)| (*key == "minus").then_some(*code))
.unwrap();
assert!(matches!(
config.find_keybind(minus_keycode, Modifiers::ALT),
Some(Command::Window(Operation::Resize(ResizeDirection::Grow)))
));
}
#[cfg(all(test, feature = "lua"))]
mod lua_setup_tests {
use super::*;
use mlua::Lua;
fn config_from_source(source: &str) -> Config {
let lua = Lua::new();
let value: mlua::Value = lua.load(source).eval().expect("lua chunk should evaluate");
config_from_lua(&lua, value).expect("config_from_lua should succeed")
}
#[test]
fn setup_table_populates_accessors() {
let config = config_from_source(
r"return {
default_workspaces = 3,
options = {
sliver_width = 9,
focus_follows_mouse = false,
preset_stack_heights = { 0.3, 0.7 },
},
padding = { top = 10, bottom = 4 },
}",
);
assert_eq!(config.default_workspaces(), 3);
assert_eq!(config.sliver_width(), 9);
assert!(!config.focus_follows_mouse());
assert_eq!(config.preset_stack_heights(), vec![0.3, 0.7]);
let (top, _right, bottom, _left) = config.edge_padding();
assert_eq!((top, bottom), (10, 4));
}
#[test]
fn missing_options_and_bindings_are_ok() {
let config = config_from_source(r"return { padding = { top = 4 } }");
assert_eq!(config.default_workspaces(), 1);
assert_eq!(config.edge_padding().0, 4);
}
#[test]
fn window_rule_passthrough_is_resolved() {
let config = config_from_source(
r#"return {
windows = { term = { title = "kitty", bindings_passthrough = { "ctrl+alt-h" } } },
}"#,
);
let rules = config.find_window_properties("kitty", "");
assert_eq!(rules.len(), 1);
assert!(
!rules[0].passthrough_keys().is_empty(),
"passthrough chords should resolve to keycodes"
);
}
#[test]
fn non_table_argument_is_rejected() {
let lua = Lua::new();
assert!(config_from_lua(&lua, mlua::Value::Integer(3)).is_err());
}
}