From 2f83c58040ec9ff5f535056c7d5c971629f5ca2a Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 02:50:19 +0100 Subject: [PATCH] feat: configure keyboard and touchpad input --- README.md | 20 ++++ compositor/src/backends/direct.rs | 40 +++++++- compositor/src/config.rs | 158 +++++++++++++++++++++++++++++- compositor/src/main.rs | 1 + compositor/src/state.rs | 19 +++- 5 files changed, 233 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index abcf2e0..1df2711 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,26 @@ to center the focused column, `Super+[` to consume the focused window into an adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the corresponding direction. +Keyboard and direct-session touchpad settings are applied at startup: + +```toml +[input] +xkb_layout = "us" +xkb_variant = "" +xkb_options = [] +repeat_rate = 25 +repeat_delay_ms = 600 + +[input.touchpad] +tap = true +natural_scroll = true +disable_while_typing = true +``` + +Touchpad settings apply to libinput devices in the direct DRM session. In the +nested backend, the host compositor remains responsible for physical touchpad +configuration. + Text-input-v3 is available to applications by default. Input-method-v2 is hidden unless the session explicitly opts in, because an input method can request privileged keyboard access. Enable it only when launching a trusted diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 55a5a25..8f6e09a 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -14,6 +14,7 @@ use smithay::{ }, drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface}, egl::{EGLContext, EGLDisplay}, + input::InputEvent, libinput::{LibinputInputBackend, LibinputSessionInterface}, renderer::{ Bind, Frame, ImportDma, ImportMemWl, Renderer, damage::OutputDamageTracker, @@ -29,7 +30,7 @@ use smithay::{ drm::control::{ Device as ControlDevice, Mode as DrmMode, ModeTypeFlags, connector, crtc, property, }, - input::Libinput, + input::{Device as LibinputDevice, Libinput}, rustix::fs::OFlags, wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind, wayland_server::backend::GlobalId, @@ -108,8 +109,9 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() event_loop .handle() - .insert_source(libinput_backend, |event, _, state| { - state.process_input_event(event); + .insert_source(libinput_backend, |event, _, state| match event { + InputEvent::DeviceAdded { mut device } => configure_libinput_device(state, &mut device), + event => state.process_input_event(event), })?; event_loop .handle() @@ -176,6 +178,38 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() Ok(()) } +fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) { + let settings = state.input_settings.touchpad; + + if device.config_tap_finger_count() > 0 + && let Err(error) = device.config_tap_set_enabled(settings.tap) + { + tracing::warn!( + device = device.name(), + ?error, + "failed to configure tap-to-click" + ); + } + if device.config_scroll_has_natural_scroll() + && let Err(error) = device.config_scroll_set_natural_scroll_enabled(settings.natural_scroll) + { + tracing::warn!( + device = device.name(), + ?error, + "failed to configure natural scrolling" + ); + } + if device.config_dwt_is_available() + && let Err(error) = device.config_dwt_set_enabled(settings.disable_while_typing) + { + tracing::warn!( + device = device.name(), + ?error, + "failed to configure disable-while-typing" + ); + } +} + fn open_primary_device( event_loop: &mut EventLoop, state: &mut Ferese, diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 8b8b03c..8f54840 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -17,9 +17,77 @@ pub struct Config { #[serde(default)] layout: LayoutConfig, #[serde(default)] + input: InputConfig, + #[serde(default)] scrolling: ScrollingConfig, } +#[derive(Clone, Debug, PartialEq)] +pub struct InputSettings { + pub xkb_layout: String, + pub xkb_variant: String, + pub xkb_options: Vec, + 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, +} + +#[derive(Debug, Deserialize)] +struct InputConfig { + #[serde(default = "default_xkb_layout")] + xkb_layout: String, + #[serde(default)] + xkb_variant: String, + #[serde(default)] + xkb_options: Vec, + #[serde(default = "default_repeat_rate")] + repeat_rate: i32, + #[serde(default = "default_repeat_delay")] + repeat_delay_ms: i32, + #[serde(default)] + touchpad: TouchpadConfig, +} + +impl Default for InputConfig { + fn default() -> Self { + Self { + 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)] +struct TouchpadConfig { + #[serde(default = "enabled_by_default")] + tap: bool, + #[serde(default = "enabled_by_default")] + natural_scroll: bool, + #[serde(default = "enabled_by_default")] + disable_while_typing: bool, +} + +impl Default for TouchpadConfig { + fn default() -> Self { + Self { + tap: true, + natural_scroll: true, + disable_while_typing: true, + } + } +} + #[derive(Debug, Deserialize)] struct AnimationsConfig { #[serde(default = "enabled_by_default")] @@ -159,6 +227,10 @@ pub enum ConfigError { field: &'static str, value: f64, }, + InvalidInputValue { + field: &'static str, + value: String, + }, } impl fmt::Display for ConfigError { @@ -180,6 +252,9 @@ impl fmt::Display for ConfigError { Self::InvalidLayoutValue { field, value } => { write!(formatter, "invalid layout.{field} value {value}") } + Self::InvalidInputValue { field, value } => { + write!(formatter, "invalid input.{field} value {value}") + } } } } @@ -191,7 +266,8 @@ impl Error for ConfigError { Self::Parse { source, .. } => Some(source), Self::InvalidColumnWidth { .. } | Self::InvalidAnimationValue { .. } - | Self::InvalidLayoutValue { .. } => None, + | Self::InvalidLayoutValue { .. } + | Self::InvalidInputValue { .. } => None, } } } @@ -266,6 +342,40 @@ impl Config { } } + pub fn input_settings(&self) -> Result { + 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(), + }); + } + + Ok(InputSettings { + 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, + }, + }) + } + pub fn animations_enabled(&self) -> bool { self.animations.enabled && !self.animations.reduced_motion } @@ -343,6 +453,18 @@ const fn default_outer_gap() -> f64 { 10.0 } +fn default_xkb_layout() -> String { + "us".to_owned() +} + +const fn default_repeat_rate() -> i32 { + 25 +} + +const fn default_repeat_delay() -> i32 { + 600 +} + const fn default_animation_speed() -> f64 { 1.0 } @@ -519,6 +641,40 @@ mod tests { assert!(invalid.gap_config().is_err()); } + #[test] + fn parses_input_and_touchpad_settings() { + let config = parse( + "[input]\nxkb_layout = \"us,de\"\nxkb_variant = \",nodeadkeys\"\nxkb_options = [\"grp:alt_shift_toggle\"]\nrepeat_rate = 30\nrepeat_delay_ms = 450\n\n[input.touchpad]\ntap = false\nnatural_scroll = false\ndisable_while_typing = true", + ); + + assert_eq!( + config.input_settings().unwrap(), + InputSettings { + xkb_layout: "us,de".to_owned(), + xkb_variant: ",nodeadkeys".to_owned(), + xkb_options: vec!["grp:alt_shift_toggle".to_owned()], + repeat_rate: 30, + repeat_delay_ms: 450, + touchpad: TouchpadSettings { + tap: false, + natural_scroll: false, + disable_while_typing: true, + }, + } + ); + } + + #[test] + fn rejects_invalid_input_settings() { + let layout = parse("[input]\nxkb_layout = \"\""); + let rate = parse("[input]\nrepeat_rate = 0"); + let delay = parse("[input]\nrepeat_delay_ms = -1"); + + assert!(layout.input_settings().is_err()); + assert!(rate.input_settings().is_err()); + assert!(delay.input_settings().is_err()); + } + #[test] fn derives_critical_viewport_damping() { let config = parse(""); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 9f9ee1b..fb904ad 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -29,6 +29,7 @@ fn main() -> Result<(), Box> { let runtime = RuntimeConfig { layout_mode: config.layout_mode(), gap_config: config.gap_config()?, + input_settings: config.input_settings()?, default_column_width: config.default_column_width()?, scrolling_focus_strategy: config.scrolling_focus_strategy(), column_width_presets: config.width_presets()?, diff --git a/compositor/src/state.rs b/compositor/src/state.rs index f97aced..1b14078 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -28,6 +28,7 @@ use smithay::{ }, input::{ Seat, SeatState, + keyboard::XkbConfig, pointer::{CursorIcon, CursorImageStatus}, }, output::Output, @@ -74,6 +75,8 @@ use smithay::{ }, }; +use crate::config::InputSettings; + pub struct Ferese { pub start_time: Instant, pub socket_name: OsString, @@ -91,6 +94,7 @@ pub struct Ferese { pub focused_window: Option, column_width_presets: Vec, gap_config: GapConfig, + pub(crate) input_settings: InputSettings, animations_enabled: bool, animation_speed: f64, spring_config: SpringConfig, @@ -139,6 +143,7 @@ pub struct Ferese { pub struct RuntimeConfig { pub layout_mode: LayoutMode, pub gap_config: GapConfig, + pub input_settings: InputSettings, pub default_column_width: ColumnWidth, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, @@ -335,7 +340,18 @@ impl Ferese { let xdg_toplevel_icon_manager = XdgToplevelIconManager::new::(&display_handle); let mut seat_state = SeatState::new(); let mut seat = seat_state.new_wl_seat(&display_handle, "ferese-winit"); - seat.add_keyboard(Default::default(), 200, 25)?; + let xkb_options = (!config.input_settings.xkb_options.is_empty()) + .then(|| config.input_settings.xkb_options.join(",")); + seat.add_keyboard( + XkbConfig { + layout: &config.input_settings.xkb_layout, + variant: &config.input_settings.xkb_variant, + options: xkb_options, + ..XkbConfig::default() + }, + config.input_settings.repeat_delay_ms, + config.input_settings.repeat_rate, + )?; seat.add_pointer(); seat.add_touch(); let socket_name = Self::init_wayland_listener(display, event_loop)?; @@ -366,6 +382,7 @@ impl Ferese { focused_window: None, column_width_presets: config.column_width_presets, gap_config: config.gap_config, + input_settings: config.input_settings, animations_enabled: config.animations_enabled, animation_speed: config.animation_speed, spring_config: config.spring_config,