atagen

monolith

public
Star0
Watch0

Commit 9944e8618a62

tarball

atagen <boss@atagen.co>2026-10-18 10:18 UTC

8 files changed+422-2
input: configure libinput devices from the config

Touchpads came up with libinput defaults, which leave tap to click and natural scrolling off, and mouse acceleration could not be flattened. Unset options go back to the device default on reload, so dropping a line from the config undoes it without a replug.
Modified file: Cargo.lock+1 additions
@@ -544,6 +544,7 @@ name = "monolith"
544544 version = "0.0.0"
545545 dependencies = [
546546 "calloop-wayland-source",
547+ "input",
547548 "libc",
548549 "monolith-config",
549550 "monolith-ipc",
Modified file: crates/config/default.toml+27 additions
@@ -54,6 +54,33 @@ layout = ""
5454 repeat_delay = 200
5555 repeat_rate = 25
5656
57+# libinput settings, bare metal only. Anything left out keeps the device's
58+# own default, and options a device lacks are skipped. Touchpads are devices
59+# that can tap, every other pointing device takes [input.pointer].
60+#
61+# accel_profile flat, adaptive or custom
62+# accel_speed -1 to 1
63+# accel_points the custom profile's curve, pointer speed at 0, accel_step,
64+# accel_step 2 * accel_step and so on, in device units per millisecond
65+# natural_scroll, left_handed, tap, tap_drag, tap_drag_lock, dwt (disable
66+# while typing), middle_emulation true or false
67+# scroll_method none, two-finger, edge or on-button-down
68+# scroll_button the evdev code of the on-button-down button, 274 for middle
69+# click_method button-areas or clickfinger
70+# scroll_factor multiplies scroll distance, applied by monolith
71+[input.pointer]
72+
73+[input.touchpad]
74+# tap = true
75+# natural_scroll = true
76+
77+# Per device, by the name `libinput list-devices` shows, over its kind's settings.
78+# Later entries win.
79+# [[input.device]]
80+# name = "Logitech G502"
81+# accel_profile = "flat"
82+# accel_speed = -0.3
83+
5784 # Each value is a command in word form, or an argv-style list.
5885 [binds]
5986 "Mod+Return" = "spawn beer"
Modified file: crates/config/src/lib.rs+192 additions
@@ -16,6 +16,7 @@ pub struct Config {
1616 pub decoration: Decoration,
1717 pub animation: Animation,
1818 pub keyboard: Keyboard,
19+ pub input: Input,
1920 pub binds: Vec<Bind>,
2021 pub rules: Vec<Rule>,
2122 pub startup: Vec<Vec<String>>,
@@ -260,6 +261,122 @@ impl Default for Keyboard {
260261 }
261262 }
262263
264+/// libinput settings for pointing devices. Unset options keep libinput's
265+/// default for the device.
266+#[derive(Debug, Clone, PartialEq, Default)]
267+pub struct Input {
268+ pub pointer: DeviceSettings,
269+ /// Devices that can tap, which libinput only offers on touchpads.
270+ pub touchpad: DeviceSettings,
271+ /// Overrides for devices by name, later entries winning.
272+ pub device: Vec<NamedDevice>,
273+}
274+
275+#[derive(Debug, Clone, PartialEq)]
276+pub struct NamedDevice {
277+ pub name: String,
278+ pub settings: DeviceSettings,
279+}
280+
281+#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
282+#[serde(default, deny_unknown_fields)]
283+pub struct DeviceSettings {
284+ pub accel_profile: Option<AccelProfile>,
285+ pub accel_speed: Option<f64>,
286+ /// The custom profile's curve, pointer speed sampled every `accel_step`
287+ /// device units per millisecond.
288+ pub accel_points: Option<Vec<f64>>,
289+ pub accel_step: Option<f64>,
290+ pub natural_scroll: Option<bool>,
291+ pub left_handed: Option<bool>,
292+ pub tap: Option<bool>,
293+ pub tap_drag: Option<bool>,
294+ pub tap_drag_lock: Option<bool>,
295+ pub dwt: Option<bool>,
296+ pub middle_emulation: Option<bool>,
297+ pub scroll_method: Option<ScrollMethod>,
298+ pub scroll_button: Option<u32>,
299+ pub click_method: Option<ClickMethod>,
300+ /// Applied by the compositor, libinput has no such setting.
301+ pub scroll_factor: Option<f64>,
302+}
303+
304+impl DeviceSettings {
305+ /// `self` with every option `over` sets replaced.
306+ pub fn overlay(&self, over: &Self) -> Self {
307+ Self {
308+ accel_profile: over.accel_profile.or(self.accel_profile),
309+ accel_speed: over.accel_speed.or(self.accel_speed),
310+ accel_points: over
311+ .accel_points
312+ .clone()
313+ .or_else(|| self.accel_points.clone()),
314+ accel_step: over.accel_step.or(self.accel_step),
315+ natural_scroll: over.natural_scroll.or(self.natural_scroll),
316+ left_handed: over.left_handed.or(self.left_handed),
317+ tap: over.tap.or(self.tap),
318+ tap_drag: over.tap_drag.or(self.tap_drag),
319+ tap_drag_lock: over.tap_drag_lock.or(self.tap_drag_lock),
320+ dwt: over.dwt.or(self.dwt),
321+ middle_emulation: over.middle_emulation.or(self.middle_emulation),
322+ scroll_method: over.scroll_method.or(self.scroll_method),
323+ scroll_button: over.scroll_button.or(self.scroll_button),
324+ click_method: over.click_method.or(self.click_method),
325+ scroll_factor: over.scroll_factor.or(self.scroll_factor),
326+ }
327+ }
328+
329+ fn validate(&mut self, name: &str, w: &mut Vec<String>) {
330+ if let Some(s) = self.accel_speed
331+ && !(-1.0..=1.0).contains(&s)
332+ {
333+ w.push(format!(
334+ "{name}.accel_speed: {s} is outside -1 to 1, ignored"
335+ ));
336+ self.accel_speed = None;
337+ }
338+ if let Some(f) = self.scroll_factor
339+ && !(f.is_finite() && f >= 0.0)
340+ {
341+ w.push(format!(
342+ "{name}.scroll_factor: {f} must be zero or more, ignored"
343+ ));
344+ self.scroll_factor = None;
345+ }
346+ if self.accel_points.is_some() != self.accel_step.is_some() {
347+ w.push(format!(
348+ "{name}: accel_points needs accel_step and the other way round, both ignored"
349+ ));
350+ self.accel_points = None;
351+ self.accel_step = None;
352+ }
353+ }
354+}
355+
356+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
357+#[serde(rename_all = "lowercase")]
358+pub enum AccelProfile {
359+ Flat,
360+ Adaptive,
361+ Custom,
362+}
363+
364+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
365+#[serde(rename_all = "kebab-case")]
366+pub enum ScrollMethod {
367+ None,
368+ TwoFinger,
369+ Edge,
370+ OnButtonDown,
371+}
372+
373+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
374+#[serde(rename_all = "kebab-case")]
375+pub enum ClickMethod {
376+ ButtonAreas,
377+ Clickfinger,
378+}
379+
263380 #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
264381 #[serde(rename_all = "lowercase")]
265382 pub enum Modifier {
@@ -394,6 +511,7 @@ impl Config {
394511 let w = &mut warnings;
395512 let general: General = section("general", table.remove("general"), w);
396513 let keyboard = section("keyboard", table.remove("keyboard"), w);
514+ let input = input(table.remove("input"), w);
397515 let animation = section("animation", table.remove("animation"), w);
398516 let decoration = section("decoration", table.remove("decoration"), w);
399517 let output: Outputs = section("output", table.remove("output"), w);
@@ -452,6 +570,7 @@ impl Config {
452570 decoration,
453571 output,
454572 keyboard,
573+ input,
455574 binds,
456575 rules,
457576 startup,
@@ -518,6 +637,45 @@ fn entry<T: DeserializeOwned>(name: &str, value: toml::Value, w: &mut Vec<String
518637 .ok()
519638 }
520639
640+fn input(value: Option<toml::Value>, w: &mut Vec<String>) -> Input {
641+ let mut table = match value {
642+ None => toml::Table::new(),
643+ Some(toml::Value::Table(t)) => t,
644+ Some(_) => {
645+ w.push("[input] is not a table, using the defaults".into());
646+ toml::Table::new()
647+ }
648+ };
649+ let mut input = Input {
650+ pointer: section("input.pointer", table.remove("pointer"), w),
651+ touchpad: section("input.touchpad", table.remove("touchpad"), w),
652+ device: Vec::new(),
653+ };
654+ for (i, mut entry) in items::<toml::Table>("input.device", table.remove("device"), w)
655+ .into_iter()
656+ .enumerate()
657+ {
658+ let name = format!("input.device[{i}]");
659+ let Some(toml::Value::String(device)) = entry.remove("name") else {
660+ w.push(format!("{name}: no name, left out"));
661+ continue;
662+ };
663+ input.device.push(NamedDevice {
664+ name: device,
665+ settings: section(&name, Some(toml::Value::Table(entry)), w),
666+ });
667+ }
668+ for key in table.keys() {
669+ w.push(format!("input.{key}: unknown option, ignored"));
670+ }
671+ input.pointer.validate("input.pointer", w);
672+ input.touchpad.validate("input.touchpad", w);
673+ for (i, d) in input.device.iter_mut().enumerate() {
674+ d.settings.validate(&format!("input.device[{i}]"), w);
675+ }
676+ input
677+}
678+
521679 fn connector_like(name: &str) -> bool {
522680 name.rsplit_once('-').is_some_and(|(kind, n)| {
523681 !kind.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
@@ -600,6 +758,7 @@ mod tests {
600758 assert_eq!(c.decoration, Decoration::default());
601759 assert_eq!(c.animation, Animation::default());
602760 assert_eq!(c.keyboard, Keyboard::default());
761+ assert_eq!(c.input, Input::default());
603762 assert!(c.binds.len() > 40);
604763 }
605764
@@ -641,4 +800,37 @@ mod tests {
641800 assert!(!c.output.named.contains_key("DP-2"));
642801 assert_eq!(loaded.warnings.len(), 7, "{:#?}", loaded.warnings);
643802 }
803+
804+ #[test]
805+ fn a_bad_device_option_costs_only_itself() {
806+ let loaded = Config::parse(
807+ r#"
808+ [input.pointer]
809+ accel_speed = 2.0
810+ scroll_method = "two-finger"
811+ [[input.device]]
812+ name = "Mouse"
813+ accel_profile = "flat"
814+ [[input.device]]
815+ name = "Typo"
816+ tapp = true
817+ [[input.device]]
818+ name = "Half"
819+ accel_points = [0.0, 1.0]
820+ "#,
821+ )
822+ .unwrap();
823+ let input = loaded.config.input;
824+ assert_eq!(input.pointer.accel_speed, None);
825+ assert_eq!(input.pointer.scroll_method, Some(ScrollMethod::TwoFinger));
826+ let names: Vec<_> = input.device.iter().map(|d| d.name.as_str()).collect();
827+ assert_eq!(names, ["Mouse", "Typo", "Half"]);
828+ assert_eq!(
829+ input.device[0].settings.accel_profile,
830+ Some(AccelProfile::Flat)
831+ );
832+ assert_eq!(input.device[1].settings, DeviceSettings::default());
833+ assert_eq!(input.device[2].settings.accel_points, None);
834+ assert_eq!(loaded.warnings.len(), 3, "{:#?}", loaded.warnings);
835+ }
644836 }
Modified file: crates/monolith/Cargo.toml+3 additions
@@ -22,6 +22,9 @@ monolith-planner.workspace = true
2222 monolith-protocols.workspace = true
2323 monolith-render-vk.workspace = true
2424 libc = "0.2"
25+# Raises the libinput bindings smithay uses. 1.23 brings custom acceleration,
26+# and below 1.27 reading a sticky drag lock default panics.
27+input = { version = "0.10", default-features = false, features = ["libinput_1_27"] }
2528 pound = "0.5"
2629 profiling = "1"
2730 serde_json.workspace = true
Modified file: crates/monolith/src/commands.rs+4 additions
@@ -217,6 +217,7 @@ impl State {
217217 let loaded = Config::load(self.settings.path.as_deref()).map_err(|e| e.to_string())?;
218218 let config = warn_config(loaded);
219219 let keyboard_changed = config.keyboard != self.settings.config.keyboard;
220+ let input_changed = config.input != self.settings.config.input;
220221 self.settings.binds = Binds::new(&config);
221222 self.wm.layout.gaps = crate::state::gaps(&config);
222223 self.wm.anim.settings = config.animation.clone();
@@ -224,6 +225,9 @@ impl State {
224225 if keyboard_changed {
225226 self.reconfigure_keyboard();
226227 }
228+ if input_changed && let Some(drm) = self.backend.drm_mut() {
229+ drm.devices.reconfigure(&self.settings.config.input);
230+ }
227231 crate::drm::reconfigure_outputs(self);
228232 tracing::info!("config reloaded");
229233 Ok(())
Added file: crates/monolith/src/devices.rs+173 additions
@@ -0,0 +1,173 @@
1+//! libinput device settings from `[input]`, on bare metal only. A nested
2+//! compositor's devices belong to its host.
3+
4+use monolith_config::{AccelProfile, ClickMethod, DeviceSettings, Input, ScrollMethod};
5+use smithay::reexports::input::{
6+ self, AccelConfig, AccelType, Device, DeviceCapability, DeviceConfigResult, DragLockState,
7+};
8+
9+#[derive(Default)]
10+pub struct Devices(Vec<(Device, f64)>);
11+
12+impl Devices {
13+ pub fn add(&mut self, mut device: Device, config: &Input) {
14+ let factor = configure(&mut device, config);
15+ self.0.push((device, factor));
16+ }
17+
18+ pub fn remove(&mut self, device: &Device) {
19+ self.0.retain(|(d, _)| d != device);
20+ }
21+
22+ pub fn reconfigure(&mut self, config: &Input) {
23+ for (device, factor) in &mut self.0 {
24+ *factor = configure(device, config);
25+ }
26+ }
27+
28+ pub fn scroll_factor(&self, device: &Device) -> f64 {
29+ self.0
30+ .iter()
31+ .find(|(d, _)| d == device)
32+ .map_or(1.0, |&(_, f)| f)
33+ }
34+}
35+
36+fn settings(device: &Device, config: &Input) -> DeviceSettings {
37+ let base = if device.config_tap_finger_count() > 0 {
38+ config.touchpad.clone()
39+ } else if device.has_capability(DeviceCapability::Pointer) {
40+ config.pointer.clone()
41+ } else {
42+ DeviceSettings::default()
43+ };
44+ let name = device.name();
45+ config
46+ .device
47+ .iter()
48+ .filter(|d| d.name == name)
49+ .fold(base, |s, d| s.overlay(&d.settings))
50+}
51+
52+/// Unset options go back to the device's default rather than being left
53+/// alone, so removing one from the config undoes it on reload.
54+fn configure(device: &mut Device, config: &Input) -> f64 {
55+ let s = settings(device, config);
56+ let name = device.name().into_owned();
57+ let check = |what: &str, result: DeviceConfigResult| {
58+ if let Err(e) = result {
59+ tracing::warn!("input {name:?}: libinput refused {what}: {e:?}");
60+ }
61+ };
62+
63+ if device.config_accel_is_available() {
64+ let profile = s.accel_profile.map(|p| match p {
65+ AccelProfile::Flat => input::AccelProfile::Flat,
66+ AccelProfile::Adaptive => input::AccelProfile::Adaptive,
67+ AccelProfile::Custom => input::AccelProfile::Custom,
68+ });
69+ let curve = s.accel_points.as_deref().zip(s.accel_step);
70+ match (profile.or(device.config_accel_default_profile()), curve) {
71+ (Some(input::AccelProfile::Custom), Some((points, step))) => {
72+ if let Some(accel) = AccelConfig::new(input::AccelProfile::Custom) {
73+ check(
74+ "accel_points",
75+ accel.set_points(AccelType::Motion, step, points),
76+ );
77+ check("accel_profile", device.config_accel_apply(accel));
78+ }
79+ }
80+ (Some(p), _) if device.config_accel_profiles().contains(&p) => {
81+ check("accel_profile", device.config_accel_set_profile(p));
82+ }
83+ (Some(p), _) => tracing::warn!("input {name:?}: no {p:?} accel profile"),
84+ (None, _) => {}
85+ }
86+ let speed = s
87+ .accel_speed
88+ .unwrap_or_else(|| device.config_accel_default_speed());
89+ check("accel_speed", device.config_accel_set_speed(speed));
90+ }
91+ if device.config_scroll_has_natural_scroll() {
92+ let on = s
93+ .natural_scroll
94+ .unwrap_or_else(|| device.config_scroll_default_natural_scroll_enabled());
95+ check(
96+ "natural_scroll",
97+ device.config_scroll_set_natural_scroll_enabled(on),
98+ );
99+ }
100+ if device.config_left_handed_is_available() {
101+ let on = s
102+ .left_handed
103+ .unwrap_or_else(|| device.config_left_handed_default());
104+ check("left_handed", device.config_left_handed_set(on));
105+ }
106+ if device.config_tap_finger_count() > 0 {
107+ let on = s.tap.unwrap_or_else(|| device.config_tap_default_enabled());
108+ check("tap", device.config_tap_set_enabled(on));
109+ let on = s
110+ .tap_drag
111+ .unwrap_or_else(|| device.config_tap_default_drag_enabled());
112+ check("tap_drag", device.config_tap_set_drag_enabled(on));
113+ let lock = match s.tap_drag_lock {
114+ Some(true) => DragLockState::EnabledTimeout,
115+ Some(false) => DragLockState::Disabled,
116+ None => device.config_tap_default_drag_lock_enabled(),
117+ };
118+ check(
119+ "tap_drag_lock",
120+ device.config_tap_set_drag_lock_enabled(lock),
121+ );
122+ }
123+ if device.config_dwt_is_available() {
124+ let on = s.dwt.unwrap_or_else(|| device.config_dwt_default_enabled());
125+ check("dwt", device.config_dwt_set_enabled(on));
126+ }
127+ if device.config_middle_emulation_is_available() {
128+ let on = s
129+ .middle_emulation
130+ .unwrap_or_else(|| device.config_middle_emulation_default_enabled());
131+ check(
132+ "middle_emulation",
133+ device.config_middle_emulation_set_enabled(on),
134+ );
135+ }
136+ let methods = device.config_scroll_methods();
137+ if !methods.is_empty() {
138+ let method = s.scroll_method.map(|m| match m {
139+ ScrollMethod::None => input::ScrollMethod::NoScroll,
140+ ScrollMethod::TwoFinger => input::ScrollMethod::TwoFinger,
141+ ScrollMethod::Edge => input::ScrollMethod::Edge,
142+ ScrollMethod::OnButtonDown => input::ScrollMethod::OnButtonDown,
143+ });
144+ match method.or(device.config_scroll_default_method()) {
145+ Some(m) if m == input::ScrollMethod::NoScroll || methods.contains(&m) => {
146+ check("scroll_method", device.config_scroll_set_method(m));
147+ }
148+ Some(m) => tracing::warn!("input {name:?}: no {m:?} scroll method"),
149+ None => {}
150+ }
151+ }
152+ if methods.contains(&input::ScrollMethod::OnButtonDown) {
153+ let button = s
154+ .scroll_button
155+ .unwrap_or_else(|| device.config_scroll_default_button());
156+ check("scroll_button", device.config_scroll_set_button(button));
157+ }
158+ let methods = device.config_click_methods();
159+ if !methods.is_empty() {
160+ let method = s.click_method.map(|m| match m {
161+ ClickMethod::ButtonAreas => input::ClickMethod::ButtonAreas,
162+ ClickMethod::Clickfinger => input::ClickMethod::Clickfinger,
163+ });
164+ match method.or(device.config_click_default_method()) {
165+ Some(m) if methods.contains(&m) => {
166+ check("click_method", device.config_click_set_method(m));
167+ }
168+ Some(m) => tracing::warn!("input {name:?}: no {m:?} click method"),
169+ None => {}
170+ }
171+ }
172+ s.scroll_factor.unwrap_or(1.0)
173+}
Modified file: crates/monolith/src/drm.rs+21 additions−2 deletions
@@ -3,6 +3,7 @@
33 //! each rendered when its own vblank lands.
44
55 use crate::backend::Backend;
6+use crate::devices::Devices;
67 use crate::scene::Draw;
78 use crate::state::Services;
89 use crate::state::{State, now_ns};
@@ -63,6 +64,7 @@ pub struct Drm {
6364 nvidia: bool,
6465 frame_flags: FrameFlags,
6566 surfaces: HashMap<crtc::Handle, Surface>,
67+ pub devices: Devices,
6668 }
6769
6870 struct Surface {
@@ -227,6 +229,7 @@ pub fn init(
227229 FrameFlags::DEFAULT
228230 },
229231 surfaces: HashMap::new(),
232+ devices: Devices::default(),
230233 }));
231234 scan(state);
232235 Ok(())
@@ -883,15 +886,21 @@ fn on_input(state: &mut State, event: InputEvent<LibinputInputBackend>) {
883886 state.pointer_frame();
884887 }
885888 InputEvent::PointerAxis { event } => {
889+ let factor = state
890+ .backend
891+ .drm()
892+ .map_or(1.0, |d| d.devices.scroll_factor(&event.device()));
886893 let source = event.source();
887894 let mut frame = AxisFrame::new(event.time()).source(source);
888895 for axis in [Axis::Horizontal, Axis::Vertical] {
889- if let Some(v120) = event.amount_v120(axis) {
896+ let v120 = event.amount_v120(axis).map(|v| v * factor);
897+ if let Some(v120) = v120 {
890898 frame = frame.v120(axis, v120 as i32);
891899 }
892900 let amount = event
893901 .amount(axis)
894- .or_else(|| event.amount_v120(axis).map(|v| v * 15.0 / 120.0));
902+ .map(|a| a * factor)
903+ .or_else(|| v120.map(|v| v * 15.0 / 120.0));
895904 match amount {
896905 Some(a) if a != 0.0 => frame = frame.value(axis, a),
897906 Some(_) if source == AxisSource::Finger => frame = frame.stop(axis),
@@ -901,6 +910,16 @@ fn on_input(state: &mut State, event: InputEvent<LibinputInputBackend>) {
901910 state.pointer_axis(frame);
902911 state.pointer_frame();
903912 }
913+ InputEvent::DeviceAdded { device } => {
914+ if let Some(drm) = state.backend.drm_mut() {
915+ drm.devices.add(device, &state.settings.config.input);
916+ }
917+ }
918+ InputEvent::DeviceRemoved { device } => {
919+ if let Some(drm) = state.backend.drm_mut() {
920+ drm.devices.remove(&device);
921+ }
922+ }
904923 _ => {}
905924 }
906925 }
Modified file: crates/monolith/src/main.rs+1 additions
@@ -5,6 +5,7 @@ mod capture;
55 mod commands;
66 mod commit;
77 mod cursor;
8+mod devices;
89 mod drm;
910 mod focus;
1011 mod frame;