Split shell, settings, and compositor into focused modules

This commit is contained in:
Samuel Onoja committed 2026-10-01 12:47:26 +01:00
1 parent 2b9f30fa5d
commit cbf048566a
38 files changed
+9998 -9817

No files matched your search

+414
View File
@@ -0,0 +1,414 @@
use super::*;
impl Config {
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
let focus_ring_width =
nonnegative_theme_value(self.theme.geometry.focus_ring_width, "geometry.focus_ring_width")?;
let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?;
let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let material_radius = nonnegative_theme_value(
self.theme
.geometry
.shell_radius
.or(self.appearance.corner_radius)
.or(self.theme.geometry.top_bar_radius)
.unwrap_or(14.0),
"geometry.shell_radius",
)?;
Ok(ThemeSettings {
border_width,
focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
text_primary_color: parse_color(&self.theme.colors.text_primary, "colors.text_primary")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
border_gradient: self.theme.border.settings("border")?,
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?,
bar_background_color: parse_color(
self.theme
.surface
.bar
.background
.as_deref()
.unwrap_or(&self.theme.colors.surface_base),
"surface.bar.background",
)?,
shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?,
inactive_dim: InactiveDimSettings {
enabled: self.appearance.inactive_dim.enabled,
amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?,
duration_ms: nonnegative_theme_value(
self.appearance.inactive_dim.duration_ms,
"appearance.inactive_dim.duration_ms",
)?,
},
window_radius,
shadow_offset_y,
shadow_blur,
shadow_opacity,
material_style: self.theme.material.style,
backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0),
material_tint_strength: unit_theme_value(self.theme.material.tint_strength, "material.tint_strength")?,
material_radius,
panel_radius: material_radius,
})
}
}
pub(super) fn nonnegative_theme_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && value >= 0.0 {
Ok(value)
} else {
Err(ConfigError::InvalidThemeValue {
field,
value: value.to_string(),
})
}
}
pub(super) fn finite_theme_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() {
Ok(value)
} else {
Err(ConfigError::InvalidThemeValue {
field,
value: value.to_string(),
})
}
}
pub(super) fn unit_theme_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(ConfigError::InvalidThemeValue {
field,
value: value.to_string(),
})
}
}
pub(super) fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor, ConfigError> {
let digits = value.strip_prefix('#').unwrap_or(value);
if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) {
return Err(ConfigError::InvalidThemeValue {
field,
value: value.to_owned(),
});
}
let parse_channel = |offset| u8::from_str_radix(&digits[offset..offset + 2], 16).ok();
let Some((red, green, blue)) = parse_channel(0)
.zip(parse_channel(2))
.zip(parse_channel(4))
.map(|((red, green), blue)| (red, green, blue))
else {
return Err(ConfigError::InvalidThemeValue {
field,
value: value.to_owned(),
});
};
let alpha = if digits.len() == 8 {
parse_channel(6).ok_or_else(|| ConfigError::InvalidThemeValue {
field,
value: value.to_owned(),
})?
} else {
u8::MAX
};
Ok(RgbaColor([
f32::from(red) / 255.0,
f32::from(green) / 255.0,
f32::from(blue) / 255.0,
f32::from(alpha) / 255.0,
]))
}
pub(super) fn default_border_color() -> String {
"#FFFFFF18".to_owned()
}
pub(super) fn default_text_primary_color() -> String {
"#F4F7FB".to_owned()
}
pub(super) fn default_surface_base_color() -> String {
"#111821".to_owned()
}
pub(super) fn default_accent_color() -> String {
"#3D7BE6".to_owned()
}
pub(super) fn default_shadow_color() -> String {
"#00000055".to_owned()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RgbaColor(pub [f32; 4]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BorderGradient {
pub from: RgbaColor,
pub to: RgbaColor,
pub angle: f64,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct BorderPaintConfig {
pub(super) gradient: Option<BorderGradientConfig>,
}
#[derive(Debug, Deserialize)]
pub(super) struct BorderGradientConfig {
pub(super) from: String,
pub(super) to: String,
#[serde(default)]
pub(super) angle: f64,
}
impl BorderPaintConfig {
fn settings(&self, name: &str) -> Result<Option<BorderGradient>, ConfigError> {
let (from_name, to_name, angle_name) = if name == "focus_ring" {
(
"focus_ring.gradient.from",
"focus_ring.gradient.to",
"focus_ring.gradient.angle",
)
} else {
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
};
self.gradient
.as_ref()
.map(|gradient| {
Ok(BorderGradient {
from: parse_color(&gradient.from, from_name)?,
to: parse_color(&gradient.to, to_name)?,
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
})
})
.transpose()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThemeSettings {
pub border_width: f64,
pub focus_ring_width: f64,
pub border_color: RgbaColor,
pub accent_color: RgbaColor,
pub border_gradient: Option<BorderGradient>,
pub focus_ring_gradient: Option<BorderGradient>,
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub bar_background_color: RgbaColor,
pub text_primary_color: RgbaColor,
pub shell_opacity: f64,
pub inactive_dim: InactiveDimSettings,
pub window_radius: f64,
pub shadow_offset_y: f64,
pub shadow_blur: f64,
pub shadow_opacity: f64,
pub material_style: MaterialStyle,
pub backdrop_blur: f64,
pub material_tint_strength: f64,
pub material_radius: f64,
pub panel_radius: f64,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum MaterialStyle {
Translucent,
#[default]
Solid,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct AppearanceConfig {
pub(super) corner_radius: Option<f64>,
#[serde(default)]
pub(super) inactive_dim: InactiveDimConfig,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InactiveDimSettings {
pub enabled: bool,
pub amount: f64,
pub duration_ms: f64,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub(super) struct InactiveDimConfig {
pub(super) enabled: bool,
pub(super) amount: f64,
pub(super) duration_ms: f64,
}
impl Default for InactiveDimConfig {
fn default() -> Self {
Self {
enabled: false,
amount: 0.15,
duration_ms: 150.0,
}
}
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct ThemeConfig {
#[serde(default)]
pub(super) border: BorderPaintConfig,
#[serde(default)]
pub(super) focus_ring: BorderPaintConfig,
#[serde(default)]
pub(super) typography: OverviewTypographyConfig,
#[serde(default)]
pub(super) background: crate::wallpaper::WallpaperConfig,
#[serde(default)]
pub(super) colors: ThemeColorsConfig,
#[serde(default)]
pub(super) geometry: ThemeGeometryConfig,
#[serde(default)]
pub(super) shadow: ThemeShadowConfig,
#[serde(default)]
pub(super) material: ThemeMaterialConfig,
#[serde(default)]
pub(super) surface: ThemeSurfaceConfig,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct ThemeSurfaceConfig {
#[serde(default)]
pub(super) bar: ThemeBarConfig,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct ThemeBarConfig {
pub(super) background: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct OverviewTypographyConfig {
pub(super) font_family: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ThemeMaterialConfig {
#[serde(default = "default_shell_opacity")]
pub(super) opacity: f64,
#[serde(default)]
pub(super) style: MaterialStyle,
#[serde(default = "default_backdrop_blur")]
pub(super) blur_radius: f64,
#[serde(default = "default_material_tint_strength")]
pub(super) tint_strength: f64,
}
impl Default for ThemeMaterialConfig {
fn default() -> Self {
Self {
style: MaterialStyle::Solid,
opacity: default_shell_opacity(),
blur_radius: default_backdrop_blur(),
tint_strength: default_material_tint_strength(),
}
}
}
fn default_shell_opacity() -> f64 {
ferese_config::DEFAULT_MATERIAL_OPACITY
}
fn default_material_tint_strength() -> f64 {
0.5
}
fn default_backdrop_blur() -> f64 {
12.0
}
#[derive(Debug, Deserialize)]
pub(super) struct ThemeColorsConfig {
#[serde(default = "default_text_primary_color")]
pub(super) text_primary: String,
#[serde(default = "default_surface_base_color")]
pub(super) surface_base: String,
#[serde(default = "default_border_color")]
pub(super) border: String,
#[serde(default = "default_accent_color")]
pub(super) accent: String,
#[serde(default = "default_shadow_color")]
pub(super) shadow: String,
}
impl Default for ThemeColorsConfig {
fn default() -> Self {
Self {
surface_base: default_surface_base_color(),
text_primary: default_text_primary_color(),
border: default_border_color(),
accent: default_accent_color(),
shadow: default_shadow_color(),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct ThemeShadowConfig {
#[serde(default)]
pub(super) soft: SoftShadowConfig,
}
#[derive(Debug, Deserialize)]
pub(super) struct SoftShadowConfig {
#[serde(default = "default_shadow_offset_y")]
pub(super) offset_y: f64,
#[serde(default = "default_shadow_blur")]
pub(super) blur: f64,
#[serde(default = "default_shadow_opacity")]
pub(super) opacity: f64,
}
impl Default for SoftShadowConfig {
fn default() -> Self {
Self {
offset_y: default_shadow_offset_y(),
blur: default_shadow_blur(),
opacity: default_shadow_opacity(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ThemeGeometryConfig {
#[serde(default = "default_border_width")]
pub(super) border_width: f64,
#[serde(default = "default_focus_ring_width")]
pub(super) focus_ring_width: f64,
#[serde(default = "default_window_radius")]
pub(super) window_radius: f64,
#[serde(default)]
pub(super) top_bar_radius: Option<f64>,
#[serde(default)]
pub(super) shell_radius: Option<f64>,
}
impl Default for ThemeGeometryConfig {
fn default() -> Self {
Self {
border_width: default_border_width(),
focus_ring_width: default_focus_ring_width(),
window_radius: default_window_radius(),
top_bar_radius: None,
shell_radius: None,
}
}
}
+621
View File
@@ -0,0 +1,621 @@
use super::*;
impl Config {
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
let mut commands = HashMap::from([
("terminal".to_owned(), vec!["foot".to_owned()]),
("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]),
(
"screenshot-full".to_owned(),
vec!["ferese-screenshot".to_owned(), "--full".to_owned()],
),
]);
commands.extend(self.commands.clone());
validate_commands(&commands)?;
let keymap = physical_keymap(input)?;
let mut bindings = default_bindings()
.into_iter()
.map(|binding| parse_binding(&binding, &commands, &keymap))
.collect::<Result<Vec<_>, _>>()?;
let mut supplied = HashSet::new();
for configured in &self.bindings {
let identity = binding_identity(configured, &keymap)?;
if !supplied.insert(identity.clone()) {
return Err(ConfigError::InvalidBinding(format!(
"duplicate binding {:?}",
configured.keys
)));
}
bindings.retain(|binding| binding_identity_for_runtime(binding) != identity);
if configured.disabled {
if configured.action.is_some() || configured.argument.is_some() {
return Err(ConfigError::InvalidBinding(format!(
"disabled binding {:?} cannot have an action or argument",
configured.keys
)));
}
} else {
bindings.push(parse_binding(configured, &commands, &keymap)?);
}
}
Ok(bindings)
}
}
pub(super) fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), ConfigError> {
for (name, argv) in commands {
if name.trim().is_empty() {
return Err(ConfigError::InvalidBinding("command names cannot be empty".to_owned()));
}
if argv.is_empty() || argv[0].is_empty() {
return Err(ConfigError::InvalidBinding(format!(
"command {name:?} must contain a program"
)));
}
}
Ok(())
}
pub(crate) fn physical_keymap(input: &InputSettings) -> Result<xkb::Keymap, ConfigError> {
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(","));
xkb::Keymap::new_from_names(
&context,
"",
"",
&input.xkb_layout,
&input.xkb_variant,
options,
xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.ok_or_else(|| ConfigError::InvalidBinding("failed to compile the XKB keymap".to_owned()))
}
pub(super) fn parse_binding(
configured: &BindingConfig,
commands: &HashMap<String, Vec<String>>,
keymap: &xkb::Keymap,
) -> Result<Binding, ConfigError> {
if configured.disabled {
return Err(ConfigError::InvalidBinding(format!(
"disabled binding {:?} cannot be executed",
configured.keys
)));
}
let identity = binding_identity(configured, keymap)?;
let action = configured
.action
.as_deref()
.ok_or_else(|| ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys)))?;
let action = parse_action(action, configured.argument.as_deref(), commands)?;
let trigger = match identity.match_mode {
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)),
BindingMatch::Swipe => BindingTrigger::Swipe {
fingers: identity.key / 4,
direction: [
crate::gestures::SwipeDirection::Up,
crate::gestures::SwipeDirection::Down,
crate::gestures::SwipeDirection::Left,
crate::gestures::SwipeDirection::Right,
][(identity.key % 4) as usize],
},
};
Ok(Binding {
modifiers: identity.modifiers,
trigger,
action,
})
}
pub(super) fn binding_identity(
configured: &BindingConfig,
keymap: &xkb::Keymap,
) -> Result<BindingIdentity, ConfigError> {
let (modifiers, key) = parse_chord(&configured.keys)?;
if let Some((fingers, direction)) = parse_swipe(&key) {
if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() {
return Err(ConfigError::InvalidBinding(
"swipes cannot use keyboard modifiers or physical key matching".into(),
));
}
return Ok(BindingIdentity {
modifiers,
match_mode: BindingMatch::Swipe,
key: fingers * 4 + direction as u32,
});
}
let key = match configured.match_mode {
BindingMatch::Keysym => parse_keysym(&key)?,
BindingMatch::Physical => keymap
.key_by_name(&key.to_ascii_uppercase())
.map(Keycode::raw)
.ok_or_else(|| {
ConfigError::InvalidBinding(format!(
"unknown XKB physical key name {key:?} in {:?}",
configured.keys
))
})?,
BindingMatch::Swipe => {
return Err(ConfigError::InvalidBinding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
));
}
};
Ok(BindingIdentity {
modifiers,
match_mode: configured.match_mode,
key,
})
}
pub(super) fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity {
let (match_mode, key) = match binding.trigger {
BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key),
BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()),
BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32),
};
BindingIdentity {
modifiers: binding.modifiers,
match_mode,
key,
}
}
pub(super) fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> {
let mut modifiers = BindingModifiers::default();
let mut key = None;
for component in chord.split('+').map(str::trim) {
if component.is_empty() {
return Err(ConfigError::InvalidBinding(format!("invalid key chord {chord:?}")));
}
let slot = match component.to_ascii_lowercase().as_str() {
"super" | "logo" | "mod4" => Some(&mut modifiers.logo),
"ctrl" | "control" => Some(&mut modifiers.ctrl),
"alt" => Some(&mut modifiers.alt),
"shift" => Some(&mut modifiers.shift),
_ => None,
};
if let Some(slot) = slot {
if *slot {
return Err(ConfigError::InvalidBinding(format!("duplicate modifier in {chord:?}")));
}
*slot = true;
} else if key.replace(component.to_owned()).is_some() {
return Err(ConfigError::InvalidBinding(format!(
"key chord {chord:?} contains more than one key"
)));
}
}
let key = key.ok_or_else(|| ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key")))?;
Ok((modifiers, key))
}
pub(super) fn parse_swipe(key: &str) -> Option<(u32, crate::gestures::SwipeDirection)> {
use crate::gestures::SwipeDirection;
let lower = key.to_ascii_lowercase();
let rest = lower.strip_prefix("swipe")?;
let fingers = rest.get(..1)?.parse::<u32>().ok()?;
if !(3..=5).contains(&fingers) {
return None;
}
let direction = match rest.get(1..)? {
"up" => SwipeDirection::Up,
"down" => SwipeDirection::Down,
"left" => SwipeDirection::Left,
"right" => SwipeDirection::Right,
_ => return None,
};
Some((fingers, direction))
}
pub(super) fn parse_keysym(name: &str) -> Result<u32, ConfigError> {
let normalized = match name {
"Enter" | "enter" => "Return".to_owned(),
"Space" | "space" => "space".to_owned(),
"[" => "bracketleft".to_owned(),
"]" => "bracketright".to_owned(),
name if name.len() == 1 => name.to_ascii_lowercase(),
name => name.to_owned(),
};
let mut symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_NO_FLAGS);
if symbol.raw() == keysyms::KEY_NoSymbol {
symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE);
}
if symbol.raw() == keysyms::KEY_NoSymbol {
Err(ConfigError::InvalidBinding(format!("unknown keysym {name:?}")))
} else {
Ok(symbol.raw())
}
}
pub(super) fn parse_action(
action: &str,
argument: Option<&str>,
commands: &HashMap<String, Vec<String>>,
) -> Result<BindingAction, ConfigError> {
let no_argument = || {
if argument.is_some() {
Err(ConfigError::InvalidBinding(format!(
"action {action:?} does not accept an argument"
)))
} else {
Ok(())
}
};
let required_argument =
|| argument.ok_or_else(|| ConfigError::InvalidBinding(format!("action {action:?} requires an argument")));
match action {
"none" => {
no_argument()?;
Ok(BindingAction::None)
}
"workspace-next" | "workspace-previous" => {
no_argument()?;
Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next"))
}
"workspace-back-and-forth" => {
no_argument()?;
Ok(BindingAction::WorkspaceBackAndForth)
}
"spawn" => {
let command = required_argument()?;
let argv = commands
.get(command)
.ok_or_else(|| ConfigError::InvalidBinding(format!("unknown command {command:?}")))?;
Ok(BindingAction::Spawn(argv.clone()))
}
"close" => {
no_argument()?;
Ok(BindingAction::Close)
}
"exit" => {
no_argument()?;
Ok(BindingAction::Exit)
}
"focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)),
"move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)),
"resize" => Ok(BindingAction::Resize(parse_direction(required_argument()?)?)),
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)),
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)),
"toggle-maximized" => {
no_argument()?;
Ok(BindingAction::ToggleMaximized)
}
"toggle-fullscreen" => {
no_argument()?;
Ok(BindingAction::ToggleFullscreen)
}
"toggle-layout" => {
no_argument()?;
Ok(BindingAction::ToggleLayout)
}
"cycle-column-width" => {
no_argument()?;
Ok(BindingAction::CycleColumnWidth)
}
"center-column" => {
no_argument()?;
Ok(BindingAction::CenterColumn)
}
"consume" => {
no_argument()?;
Ok(BindingAction::Consume)
}
"expel" => {
no_argument()?;
Ok(BindingAction::Expel)
}
"toggle-floating" => {
no_argument()?;
Ok(BindingAction::ToggleFloating)
}
"toggle-overview" => {
no_argument()?;
Ok(BindingAction::ToggleOverview)
}
"toggle-keybinding-guide" => {
no_argument()?;
Ok(BindingAction::ToggleKeybindingGuide)
}
_ => Err(ConfigError::InvalidBinding(format!("unknown action {action:?}"))),
}
}
pub(super) fn parse_direction(argument: &str) -> Result<Direction, ConfigError> {
match argument {
"left" => Ok(Direction::Left),
"right" => Ok(Direction::Right),
"up" => Ok(Direction::Up),
"down" => Ok(Direction::Down),
_ => Err(ConfigError::InvalidBinding(format!("invalid direction {argument:?}"))),
}
}
pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
match argument.parse() {
Ok(workspace) if workspace > 0 => Ok(workspace),
_ => Err(ConfigError::InvalidBinding(format!("invalid workspace {argument:?}"))),
}
}
pub(super) fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![
binding("Super+Enter", "spawn", Some("terminal")),
binding("Super+Shift+S", "spawn", Some("screenshot")),
binding("Print", "spawn", Some("screenshot-full")),
binding("Swipe3Up", "workspace-next", None),
binding("Swipe3Down", "workspace-previous", None),
binding("Swipe3Left", "focus", Some("right")),
binding("Swipe3Right", "focus", Some("left")),
binding("Super+Q", "close", None),
binding("Super+F", "toggle-maximized", None),
binding("Super+Shift+F", "toggle-fullscreen", None),
binding("Super+M", "toggle-layout", None),
binding("Super+R", "cycle-column-width", None),
binding("Super+C", "center-column", None),
binding("Super+[", "consume", None),
binding("Super+]", "expel", None),
binding("Super+Shift+Space", "toggle-floating", None),
binding("Super+Tab", "toggle-overview", None),
binding("Super+F1", "toggle-keybinding-guide", None),
binding("Super+Escape", "workspace-back-and-forth", None),
binding("Super+Shift+E", "exit", None),
];
for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] {
bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction)));
bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction)));
bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction)));
}
for workspace in 1..=9 {
let workspace = workspace.to_string();
bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace)));
bindings.push(binding(
format!("Super+Shift+{workspace}"),
"move-to-workspace",
Some(&workspace),
));
}
bindings
}
pub(super) fn binding(keys: impl Into<String>, action: impl Into<String>, argument: Option<&str>) -> BindingConfig {
BindingConfig {
keys: keys.into(),
match_mode: BindingMatch::Keysym,
action: Some(action.into()),
argument: argument.map(str::to_owned),
disabled: false,
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Binding {
pub(super) modifiers: BindingModifiers,
pub(super) trigger: BindingTrigger,
pub action: BindingAction,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum BindingTrigger {
Keysym(u32),
Physical(Keycode),
Swipe {
fingers: u32,
direction: crate::gestures::SwipeDirection,
},
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub(super) struct BindingModifiers {
pub(super) logo: bool,
pub(super) ctrl: bool,
pub(super) alt: bool,
pub(super) shift: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum BindingAction {
None,
Spawn(Vec<String>),
Close,
Exit,
Focus(ferese_layout::Direction),
Move(ferese_layout::Direction),
Resize(ferese_layout::Direction),
SwitchWorkspace(u8),
SwitchRelativeWorkspace(bool),
WorkspaceBackAndForth,
MoveToWorkspace(u8),
ToggleFullscreen,
ToggleMaximized,
ToggleLayout,
CycleColumnWidth,
CenterColumn,
Consume,
Expel,
ToggleFloating,
ToggleOverview,
ToggleKeybindingGuide,
}
impl Binding {
pub(crate) fn portal_trigger(chord: &str) -> Result<Self, ConfigError> {
if chord.len() > 256 {
return Err(ConfigError::InvalidBinding("Shortcut is too long".into()));
}
let parts = chord.split('+').collect::<Vec<_>>();
if parts
.iter()
.filter(|part| part.trim().eq_ignore_ascii_case("num"))
.count()
> 1
{
return Err(ConfigError::InvalidBinding("Duplicate NUM modifier".into()));
}
let chord = parts
.into_iter()
.filter(|part| !part.trim().eq_ignore_ascii_case("num"))
.collect::<Vec<_>>()
.join("+");
let (modifiers, key) = parse_chord(&chord)?;
let symbol = parse_keysym(&key)?;
if (modifiers.ctrl
&& modifiers.alt
&& matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12))
|| (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol)
{
return Err(ConfigError::InvalidBinding("Reserved system shortcut".into()));
}
Ok(Self {
modifiers,
trigger: BindingTrigger::Keysym(symbol),
action: BindingAction::None,
})
}
pub(crate) fn conflicts(&self, other: &Self, map: &xkb::Keymap) -> bool {
if self.modifiers != other.modifiers {
return false;
}
match (&self.trigger, &other.trigger) {
(BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right,
(BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code))
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code))
.any(|layout| {
map.key_get_syms_by_level(*code, layout, 0)
.iter()
.any(|key| key.raw() == *symbol)
}),
(BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right,
_ => false,
}
}
pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool {
self.modifiers == BindingModifiers { logo, ctrl, alt, shift }
&& match self.trigger {
BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
BindingTrigger::Physical(expected) => expected == keycode,
BindingTrigger::Swipe { .. } => false,
}
}
pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> {
let description = match &self.action {
BindingAction::None => return None,
BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => {
if argv.iter().any(|arg| arg == "--full") {
"Capture whole screen"
} else {
"Capture selected area"
}
.into()
}
BindingAction::Spawn(argv) => format!(
"Launch {}",
argv.first()
.and_then(|program| std::path::Path::new(program).file_name())
.and_then(|name| name.to_str())
.unwrap_or("application")
),
BindingAction::Close => "Close window".into(),
BindingAction::Exit => "Log out (with confirmation)".into(),
BindingAction::Focus(direction) => format!("Focus window {direction:?}"),
BindingAction::Move(direction) => format!("Move window {direction:?}"),
BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(),
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
BindingAction::SwitchRelativeWorkspace(next) => {
if *next { "Next workspace" } else { "Previous workspace" }.into()
}
BindingAction::ToggleFullscreen => "Toggle fullscreen".into(),
BindingAction::ToggleMaximized => "Toggle full-width window".into(),
BindingAction::ToggleLayout => "Switch workspace layout".into(),
BindingAction::CycleColumnWidth => "Cycle column width".into(),
BindingAction::CenterColumn => "Center focused column".into(),
BindingAction::Consume => "Join window into column".into(),
BindingAction::Expel => "Move window out of column".into(),
BindingAction::ToggleFloating => "Toggle floating window".into(),
BindingAction::ToggleOverview => "Open or close overview".into(),
BindingAction::ToggleKeybindingGuide => "Open or close shortcut hint".into(),
};
let mut parts = Vec::new();
for (enabled, label) in [
(self.modifiers.logo, "Super"),
(self.modifiers.ctrl, "Ctrl"),
(self.modifiers.alt, "Alt"),
(self.modifiers.shift, "Shift"),
] {
if enabled {
parts.push(label.to_owned());
}
}
parts.push(match self.trigger {
BindingTrigger::Keysym(symbol) => {
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
match name.as_str() {
"Return" => "Enter".into(),
"Print" => "Print Screen".into(),
"space" => "Space".into(),
_ if name.len() == 1 => name.to_uppercase(),
_ => name,
}
}
BindingTrigger::Physical(code) => keymap
.and_then(|map| map.key_get_name(code))
.map(|name| format!("Physical {name}"))
.unwrap_or_else(|| format!("Keycode {}", u32::from(code))),
BindingTrigger::Swipe { fingers, direction } => {
format!("{fingers}-finger swipe {direction:?}")
}
});
Some(serde_json::json!({"keys": parts.join(" + "), "description": description}))
}
pub(crate) fn swipe_fingers(&self, count: u32) -> bool {
matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count)
}
pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool {
matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target)
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(super) enum BindingMatch {
#[default]
Keysym,
Physical,
Swipe,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct BindingConfig {
pub(super) keys: String,
#[serde(default, rename = "match")]
pub(super) match_mode: BindingMatch,
pub(super) action: Option<String>,
pub(super) argument: Option<String>,
#[serde(default)]
pub(super) disabled: bool,
}
+245
View File
@@ -0,0 +1,245 @@
use super::*;
impl Config {
pub fn input_settings(&self) -> Result<InputSettings, ConfigError> {
if self.input.xkb_layout.trim().is_empty() {
return Err(ConfigError::InvalidInputValue {
field: "xkb_layout",
value: self.input.xkb_layout.clone(),
});
}
if self.input.repeat_rate <= 0 {
return Err(ConfigError::InvalidInputValue {
field: "repeat_rate",
value: self.input.repeat_rate.to_string(),
});
}
if self.input.repeat_delay_ms < 0 {
return Err(ConfigError::InvalidInputValue {
field: "repeat_delay_ms",
value: self.input.repeat_delay_ms.to_string(),
});
}
if !(16..=1000).contains(&self.input.touchpad.swipe_threshold) {
return Err(ConfigError::InvalidInputValue {
field: "touchpad.swipe_threshold",
value: self.input.touchpad.swipe_threshold.to_string(),
});
}
Ok(InputSettings {
focus_follows_mouse: self.input.focus_follows_mouse,
xkb_layout: self.input.xkb_layout.clone(),
xkb_variant: self.input.xkb_variant.clone(),
xkb_options: self.input.xkb_options.clone(),
repeat_rate: self.input.repeat_rate,
repeat_delay_ms: self.input.repeat_delay_ms,
touchpad: TouchpadSettings {
tap: self.input.touchpad.tap,
natural_scroll: self.input.touchpad.natural_scroll,
disable_while_typing: self.input.touchpad.disable_while_typing,
swipe_threshold: self.input.touchpad.swipe_threshold,
},
})
}
pub fn animations_enabled(&self) -> bool {
self.animations.enabled && !self.animations.reduced_motion
}
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
positive_animation_value(self.animations.speed, "speed")
}
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?;
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
let damping = self.animations.spring.damping;
if !damping.is_finite() || damping < 0.0 {
return Err(ConfigError::InvalidAnimationValue {
field: "spring.damping",
value: damping,
});
}
Ok(SpringConfig {
mass,
stiffness,
damping,
..SpringConfig::default()
})
}
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
let stiffness =
positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?;
let damping_ratio = positive_animation_value(
self.animations.viewport_spring.damping_ratio,
"viewport_spring.damping_ratio",
)?;
Ok(SpringConfig {
mass,
stiffness,
damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(),
..SpringConfig::default()
})
}
}
pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(ConfigError::InvalidAnimationValue { field, value })
}
}
pub(super) fn default_xkb_layout() -> String {
"us".to_owned()
}
#[derive(Clone, Debug, PartialEq)]
pub struct InputSettings {
pub focus_follows_mouse: bool,
pub xkb_layout: String,
pub xkb_variant: String,
pub xkb_options: Vec<String>,
pub repeat_rate: i32,
pub repeat_delay_ms: i32,
pub touchpad: TouchpadSettings,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TouchpadSettings {
pub tap: bool,
pub natural_scroll: bool,
pub disable_while_typing: bool,
pub swipe_threshold: u16,
}
fn default_swipe_threshold() -> u16 {
80
}
#[derive(Debug, Deserialize)]
pub(super) struct InputConfig {
#[serde(default)]
pub(super) focus_follows_mouse: bool,
#[serde(default = "default_xkb_layout")]
pub(super) xkb_layout: String,
#[serde(default)]
pub(super) xkb_variant: String,
#[serde(default)]
pub(super) xkb_options: Vec<String>,
#[serde(default = "default_repeat_rate")]
pub(super) repeat_rate: i32,
#[serde(default = "default_repeat_delay")]
pub(super) repeat_delay_ms: i32,
#[serde(default)]
pub(super) touchpad: TouchpadConfig,
}
impl Default for InputConfig {
fn default() -> Self {
Self {
focus_follows_mouse: false,
xkb_layout: default_xkb_layout(),
xkb_variant: String::new(),
xkb_options: Vec::new(),
repeat_rate: default_repeat_rate(),
repeat_delay_ms: default_repeat_delay(),
touchpad: TouchpadConfig::default(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct TouchpadConfig {
#[serde(default = "enabled_by_default")]
pub(super) tap: bool,
#[serde(default = "enabled_by_default")]
pub(super) natural_scroll: bool,
#[serde(default = "enabled_by_default")]
pub(super) disable_while_typing: bool,
#[serde(default = "default_swipe_threshold")]
pub(super) swipe_threshold: u16,
}
impl Default for TouchpadConfig {
fn default() -> Self {
Self {
tap: true,
natural_scroll: true,
disable_while_typing: true,
swipe_threshold: default_swipe_threshold(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct AnimationsConfig {
#[serde(default = "enabled_by_default")]
pub(super) enabled: bool,
#[serde(default)]
pub(super) reduced_motion: bool,
#[serde(default = "default_animation_speed")]
pub(super) speed: f64,
#[serde(default)]
pub(super) spring: SpringSettings,
#[serde(default)]
pub(super) viewport_spring: ViewportSpringSettings,
}
impl Default for AnimationsConfig {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
spring: SpringSettings::default(),
viewport_spring: ViewportSpringSettings::default(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct ViewportSpringSettings {
#[serde(default = "default_viewport_mass")]
pub(super) mass: f64,
#[serde(default = "default_viewport_stiffness")]
pub(super) stiffness: f64,
#[serde(default = "default_viewport_damping_ratio")]
pub(super) damping_ratio: f64,
}
impl Default for ViewportSpringSettings {
fn default() -> Self {
Self {
mass: default_viewport_mass(),
stiffness: default_viewport_stiffness(),
damping_ratio: default_viewport_damping_ratio(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct SpringSettings {
#[serde(default = "default_spring_mass")]
pub(super) mass: f64,
#[serde(default = "default_spring_stiffness")]
pub(super) stiffness: f64,
#[serde(default = "default_spring_damping")]
pub(super) damping: f64,
}
impl Default for SpringSettings {
fn default() -> Self {
Self {
mass: default_spring_mass(),
stiffness: default_spring_stiffness(),
damping: default_spring_damping(),
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use super::*;
impl Config {
pub fn layout_mode(&self) -> LayoutMode {
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
LayoutModeValue::Tree => LayoutMode::Tree,
}
}
pub fn gap_config(&self) -> Result<GapConfig, ConfigError> {
let inner = nonnegative_layout_value(self.layout.inner_gap, "inner_gap")?;
let outer = nonnegative_layout_value(self.layout.outer_gap, "outer_gap")?;
Ok(GapConfig {
inner,
outer,
smart: self.layout.smart_gaps,
})
}
pub fn default_column_width(&self) -> Result<ColumnWidth, ConfigError> {
parse_column_width(
self.scrolling
.default_column_width
.as_ref()
.unwrap_or(&ColumnWidthValue::Proportion(0.5)),
"default_column_width",
)
}
pub fn width_presets(&self) -> Result<Vec<ColumnWidth>, ConfigError> {
if self.scrolling.width_presets.is_empty() {
return Ok(vec![
ColumnWidth::Proportion(1.0 / 3.0),
ColumnWidth::Proportion(0.5),
ColumnWidth::Proportion(2.0 / 3.0),
ColumnWidth::Full,
]);
}
self.scrolling
.width_presets
.iter()
.map(|value| parse_column_width(value, "width_presets"))
.collect()
}
pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy {
match self.scrolling.focus_strategy.unwrap_or(FocusStrategyValue::Minimal) {
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
}
}
}
pub(super) fn nonnegative_layout_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
if value.is_finite() && value >= 0.0 {
Ok(value)
} else {
Err(ConfigError::InvalidLayoutValue { field, value })
}
}
pub(super) fn parse_column_width(value: &ColumnWidthValue, field: &'static str) -> Result<ColumnWidth, ConfigError> {
match value {
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)),
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full),
value => Err(ConfigError::InvalidColumnWidth {
field,
value: match value {
ColumnWidthValue::Proportion(value) => value.to_string(),
ColumnWidthValue::Named(value) => format!("{value:?}"),
},
}),
}
}
+182
View File
@@ -0,0 +1,182 @@
use super::*;
impl Config {
pub fn output_profiles(&self) -> Result<Vec<OutputProfile>, ConfigError> {
let mut names = HashSet::new();
self.output_profiles
.iter()
.map(|profile| {
let name = profile.name.trim();
if name.is_empty() {
return Err(ConfigError::InvalidOutputProfile(
"profile names cannot be empty".to_owned(),
));
}
if !names.insert(name.to_owned()) {
return Err(ConfigError::InvalidOutputProfile(format!(
"duplicate profile name {name:?}"
)));
}
if profile.outputs.is_empty() {
return Err(ConfigError::InvalidOutputProfile(format!(
"profile {name:?} has no outputs"
)));
}
let mut matchers = HashSet::new();
let outputs = profile
.outputs
.iter()
.map(|output| {
let matcher = output.matcher.trim();
if matcher.is_empty() {
return Err(ConfigError::InvalidOutputProfile(format!(
"profile {name:?} contains an empty output matcher"
)));
}
if !matchers.insert(matcher.to_owned()) {
return Err(ConfigError::InvalidOutputProfile(format!(
"profile {name:?} repeats output matcher {matcher:?}"
)));
}
if !output.scale.is_finite() || output.scale <= 0.0 {
return Err(ConfigError::InvalidOutputProfile(format!(
"profile {name:?} output {matcher:?} has invalid scale {}",
output.scale
)));
}
Ok(OutputSettings {
auto_refresh: output.auto_refresh,
matcher: matcher.to_owned(),
enabled: output.enabled,
mode: output
.mode
.as_deref()
.map(parse_output_mode)
.transpose()
.map_err(ConfigError::InvalidOutputProfile)?,
scale: output.scale,
transform: output.transform,
position: output.position,
})
})
.collect::<Result<Vec<_>, ConfigError>>()?;
Ok(OutputProfile {
name: name.to_owned(),
outputs,
})
})
.collect()
}
}
pub(super) fn parse_output_mode(value: &str) -> Result<OutputModeRequest, String> {
let (size, refresh) = value
.trim()
.split_once('@')
.map_or((value.trim(), None), |(size, refresh)| (size, Some(refresh)));
let (width, height) = size
.split_once('x')
.ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?;
let width = width
.parse::<u16>()
.map_err(|_| format!("mode {value:?} has an invalid width"))?;
let height = height
.parse::<u16>()
.map_err(|_| format!("mode {value:?} has an invalid height"))?;
if width == 0 || height == 0 {
return Err(format!("mode {value:?} dimensions must be positive"));
}
let refresh_millihertz = refresh
.map(|refresh| {
let hertz = refresh
.parse::<f64>()
.map_err(|_| format!("mode {value:?} has an invalid refresh rate"))?;
if !hertz.is_finite() || hertz <= 0.0 || hertz > u32::MAX as f64 / 1_000.0 {
return Err(format!("mode {value:?} has an invalid refresh rate"));
}
Ok((hertz * 1_000.0).round() as u32)
})
.transpose()?;
Ok(OutputModeRequest {
width,
height,
refresh_millihertz,
})
}
pub(super) const fn default_output_scale() -> f64 {
1.0
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputProfile {
pub name: String,
pub outputs: Vec<OutputSettings>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings {
pub auto_refresh: bool,
pub matcher: String,
pub enabled: bool,
pub mode: Option<OutputModeRequest>,
pub scale: f64,
pub transform: OutputTransform,
pub position: Option<[i32; 2]>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutputModeRequest {
pub width: u16,
pub height: u16,
pub refresh_millihertz: Option<u32>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum OutputTransform {
#[default]
Normal,
#[serde(rename = "rotate_90")]
Rotate90,
#[serde(rename = "rotate_180")]
Rotate180,
#[serde(rename = "rotate_270")]
Rotate270,
Flipped,
#[serde(rename = "flipped_90")]
Flipped90,
#[serde(rename = "flipped_180")]
Flipped180,
#[serde(rename = "flipped_270")]
Flipped270,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct OutputProfileConfig {
pub(super) name: String,
#[serde(default)]
pub(super) outputs: Vec<OutputConfig>,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct OutputConfig {
#[serde(default)]
pub(super) auto_refresh: bool,
#[serde(rename = "match")]
pub(super) matcher: String,
#[serde(default = "default_true")]
pub(super) enabled: bool,
pub(super) mode: Option<String>,
#[serde(default = "default_output_scale")]
pub(super) scale: f64,
#[serde(default)]
pub(super) transform: OutputTransform,
pub(super) position: Option<[i32; 2]>,
}