git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitb0a306e1d7cfe465b0534d99ede6f05034e5ab3a
parentc50e0c8617
authorLucas Galante <[email protected]>
date2026-05-25 20:58
Update system configuration and interface modules

 Cargo.lock        | 151 +++++++++++
 Cargo.toml        |   2 +
 src/config.rs     |  48 ++++
 src/input.rs      | 728 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/ipc.rs        |  15 ++
 src/ipc_server.rs |   4 +-
 src/lib.rs        |   2 +
 src/main.rs       |  20 +-
 src/types.rs      |   4 +
 src/wayland.rs    |  24 +-
 10 files changed, 995 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index bfd1f8f..8216187 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,12 @@ version = "2.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
 
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
 [[package]]
 name = "cc"
 version = "1.2.62"
@@ -38,6 +44,8 @@ dependencies = [
  "libc",
  "nix",
  "serde",
+ "serde_json",
+ "tokio",
  "toml",
  "wayland-backend",
  "wayland-client",
@@ -90,6 +98,12 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
 [[package]]
 name = "libc"
 version = "0.2.186"
@@ -102,6 +116,15 @@ version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
 
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
 [[package]]
 name = "memchr"
 version = "2.8.0"
@@ -117,6 +140,17 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
 [[package]]
 name = "nix"
 version = "0.29.0"
@@ -129,6 +163,35 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
 [[package]]
 name = "pkg-config"
 version = "0.3.33"
@@ -162,6 +225,15 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
 [[package]]
 name = "rustix"
 version = "1.1.4"
@@ -175,6 +247,12 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
 [[package]]
 name = "serde"
 version = "1.0.228"
@@ -205,6 +283,19 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
 [[package]]
 name = "serde_spanned"
 version = "0.6.9"
@@ -220,12 +311,32 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.15.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
 
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
 [[package]]
 name = "syn"
 version = "2.0.117"
@@ -237,6 +348,34 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.23"
@@ -284,6 +423,12 @@ version = "1.0.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
 [[package]]
 name = "wayland-backend"
 version = "0.3.15"
@@ -381,3 +526,9 @@ name = "xkeysym"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
index b453b40..89449af 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,3 +22,5 @@ serde = { version = "1", features = ["derive"] }
 nix = { version = "0.29", features = ["signal", "process", "fs", "poll"] }
 libc = "0.2"
 bitflags = "2"
+tokio = { version = "1.35", features = ["full"] }
+serde_json = "1.0"
diff --git a/src/config.rs b/src/config.rs
index 74a02cc..4f30e19 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -35,6 +35,8 @@ pub struct Config {
     pub notifications: NotificationsConfig,
     #[serde(default)]
     pub reload: Vec<ReloadEntryConfig>,
+    #[serde(default)]
+    pub inertial: Option<InertialConfig>,
 }
 
 #[derive(Debug, Deserialize)]
@@ -177,6 +179,34 @@ pub struct InputConfig {
     pub trackpoint_accel_profile: Option<String>,
 }
 
+#[derive(Debug, Deserialize, Clone)]
+pub struct InertialConfig {
+    #[serde(default = "default_true")]
+    pub inertial_scroll: bool,
+    #[serde(default = "default_scroll_friction")]
+    pub scroll_friction: u16,
+    #[serde(default = "default_false")]
+    pub inertial_pointer: bool,
+    #[serde(default = "default_pointer_friction")]
+    pub pointer_friction: u16,
+    #[serde(default = "default_false")]
+    pub inertial_trackpad: bool,
+    #[serde(default = "default_trackpad_friction")]
+    pub trackpad_friction: u16,
+    #[serde(default = "default_speed")]
+    pub pointer_speed: f64,
+    #[serde(default = "default_speed")]
+    pub scroll_speed: f64,
+    #[serde(default = "default_speed")]
+    pub trackpad_speed: f64,
+}
+
+fn default_scroll_friction() -> u16 { 90 }
+fn default_pointer_friction() -> u16 { 95 }
+fn default_trackpad_friction() -> u16 { 95 }
+fn default_speed() -> f64 { 1.0 }
+fn default_false() -> bool { false }
+
 #[derive(Debug, Deserialize)]
 pub struct NotificationsConfig {
     #[serde(default = "default_true")]
@@ -399,6 +429,24 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
     state.trackpoint_accel_profile = config.input.trackpoint_accel_profile.clone();
     state.tap_config_applied = false;
 
+    // Send InertialConfig and tap_to_click state to input subsystem daemon
+    let inertial_cfg = config.inertial.clone().unwrap_or_else(|| {
+        InertialConfig {
+            inertial_scroll: true,
+            scroll_friction: 90,
+            inertial_pointer: false,
+            pointer_friction: 95,
+            inertial_trackpad: false,
+            trackpad_friction: 95,
+            pointer_speed: 1.0,
+            scroll_speed: 1.0,
+            trackpad_speed: 1.0,
+        }
+    });
+    if let Some(ref controller) = state.input_controller {
+        let _ = controller.send((inertial_cfg, config.input.tap_to_click));
+    }
+
     // [notifications]
     state.notifications_enable = config.notifications.enable;
 
diff --git a/src/input.rs b/src/input.rs
new file mode 100644
index 0000000..7029458
--- /dev/null
+++ b/src/input.rs
@@ -0,0 +1,728 @@
+use std::collections::HashSet;
+use std::fs::{self, OpenOptions};
+use std::os::unix::fs::OpenOptionsExt;
+use std::os::unix::io::AsRawFd;
+use std::path::PathBuf;
+use std::time::Instant;
+use tokio::io::AsyncReadExt;
+use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
+use tokio::time::{sleep, Duration};
+use crate::config::InertialConfig;
+
+// IOCTL and Event constants
+const UI_DEV_CREATE: libc::c_ulong = 0x5501;
+const UI_DEV_SETUP: libc::c_ulong = 0x405C5503;
+const UI_SET_EVBIT: libc::c_ulong = 0x40045564;
+const UI_SET_RELBIT: libc::c_ulong = 0x40045566;
+
+// Linux input event codes
+const EV_SYN: u16 = 0x00;
+const EV_KEY: u16 = 0x01;
+const EV_REL: u16 = 0x02;
+const EV_ABS: u16 = 0x03;
+
+const REL_X: u16 = 0x00;
+const REL_Y: u16 = 0x01;
+const REL_HWHEEL: u16 = 0x06;
+const REL_WHEEL: u16 = 0x08;
+
+const ABS_X: u16 = 0x00;
+const ABS_Y: u16 = 0x01;
+const ABS_MT_POSITION_X: u16 = 0x35;
+const ABS_MT_POSITION_Y: u16 = 0x36;
+const ABS_MT_TRACKING_ID: u16 = 0x39;
+
+const BTN_TOUCH: u16 = 0x14a;
+
+const SYN_REPORT: u16 = 0x00;
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+struct InputId {
+    bustype: u16,
+    vendor: u16,
+    product: u16,
+    version: u16,
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+struct UinputSetup {
+    id: InputId,
+    name: [u8; 80],
+    ff_effects_max: u32,
+}
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy)]
+struct InputEvent {
+    tv_sec: i64,
+    tv_usec: i64,
+    type_: u16,
+    code: u16,
+    value: i32,
+}
+
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct FingerState {
+    pub slot: usize,
+    pub x: f32,
+    pub y: f32,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+struct SlotState {
+    active: bool,
+    x: f32,
+    y: f32,
+}
+
+fn eviocgabs(abs: u32) -> libc::c_ulong {
+    let size = std::mem::size_of::<libc::input_absinfo>() as u64;
+    ((2u64 << 30) | ((b'E' as u64) << 8) | ((0x40 + abs) as u64) | (size << 16)) as libc::c_ulong
+}
+
+const ABS_MT_SLOT: u16 = 0x2f;
+
+#[derive(Debug)]
+enum CoordinatorMsg {
+    PhysicalMove { dx: i32, dy: i32, timestamp: Instant },
+    PhysicalTrackpadMove { dx: i32, dy: i32, timestamp: Instant },
+    PhysicalTrackpadLift { timestamp: Instant },
+    PhysicalScroll { dwx: i32, dwy: i32, timestamp: Instant },
+    UpdateConfig((InertialConfig, bool)),
+    FingersReport(Vec<FingerState>),
+}
+
+fn setup_uinput() -> std::io::Result<std::fs::File> {
+    let file = OpenOptions::new()
+        .write(true)
+        .custom_flags(libc::O_NONBLOCK)
+        .open("/dev/uinput")?;
+
+    let fd = file.as_raw_fd();
+
+    unsafe {
+        if libc::ioctl(fd, UI_SET_EVBIT, EV_REL as libc::c_int) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        if libc::ioctl(fd, UI_SET_RELBIT, REL_X as libc::c_int) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        if libc::ioctl(fd, UI_SET_RELBIT, REL_Y as libc::c_int) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        if libc::ioctl(fd, UI_SET_RELBIT, REL_WHEEL as libc::c_int) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        if libc::ioctl(fd, UI_SET_RELBIT, REL_HWHEEL as libc::c_int) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+    }
+
+    let mut setup = UinputSetup {
+        id: InputId {
+            bustype: 0x0006, // BUS_VIRTUAL
+            vendor: 0x1234,
+            product: 0x5678,
+            version: 1,
+        },
+        name: [0; 80],
+        ff_effects_max: 0,
+    };
+
+    let name_bytes = b"Clear Virtual Mouse";
+    setup.name[..name_bytes.len()].copy_from_slice(name_bytes);
+
+    unsafe {
+        let setup_ptr = &setup as *const UinputSetup as *const libc::c_void;
+        if libc::ioctl(fd, UI_DEV_SETUP, setup_ptr) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+        if libc::ioctl(fd, UI_DEV_CREATE) < 0 {
+            return Err(std::io::Error::last_os_error());
+        }
+    }
+
+    println!("[input-subsystem] Successfully created virtual uinput device.");
+    Ok(file)
+}
+
+fn write_raw_event(file: &mut std::fs::File, type_: u16, code: u16, value: i32) -> std::io::Result<()> {
+    let event = InputEvent {
+        tv_sec: 0,
+        tv_usec: 0,
+        type_,
+        code,
+        value,
+    };
+    let buf = unsafe {
+        std::slice::from_raw_parts(&event as *const InputEvent as *const u8, std::mem::size_of::<InputEvent>())
+    };
+    use std::io::Write;
+    file.write_all(buf)?;
+    file.flush()?;
+    Ok(())
+}
+
+fn write_mouse_move(file: &mut std::fs::File, dx: i32, dy: i32) -> std::io::Result<()> {
+    if dx != 0 {
+        write_raw_event(file, EV_REL, REL_X, dx)?;
+    }
+    if dy != 0 {
+        write_raw_event(file, EV_REL, REL_Y, dy)?;
+    }
+    write_raw_event(file, EV_SYN, SYN_REPORT, 0)?;
+    Ok(())
+}
+
+fn write_scroll(file: &mut std::fs::File, dwx: i32, dwy: i32) -> std::io::Result<()> {
+    if dwx != 0 {
+        write_raw_event(file, EV_REL, REL_HWHEEL, dwx)?;
+    }
+    if dwy != 0 {
+        write_raw_event(file, EV_REL, REL_WHEEL, dwy)?;
+    }
+    write_raw_event(file, EV_SYN, SYN_REPORT, 0)?;
+    Ok(())
+}
+
+struct TouchTracker {
+    last_x: Option<i32>,
+    last_y: Option<i32>,
+    dx: i32,
+    dy: i32,
+    finger_down: bool,
+}
+
+async fn read_device_loop(
+    path: PathBuf,
+    tx: UnboundedSender<CoordinatorMsg>,
+) -> std::io::Result<()> {
+    println!("[input-subsystem] Started monitoring {:?}", path);
+    let mut file = tokio::fs::File::open(&path).await?;
+    let fd = file.as_raw_fd();
+
+    let mut min_x = 0;
+    let mut max_x = 3528;
+    let mut min_y = 0;
+    let mut max_y = 2006;
+
+    let mut absinfo = libc::input_absinfo {
+        value: 0, minimum: 0, maximum: 0, fuzz: 0, flat: 0, resolution: 0
+    };
+    unsafe {
+        if libc::ioctl(fd, eviocgabs(ABS_MT_POSITION_X as u32), &mut absinfo) >= 0 {
+            min_x = absinfo.minimum;
+            max_x = absinfo.maximum;
+        } else if libc::ioctl(fd, eviocgabs(ABS_X as u32), &mut absinfo) >= 0 {
+            min_x = absinfo.minimum;
+            max_x = absinfo.maximum;
+        }
+        if libc::ioctl(fd, eviocgabs(ABS_MT_POSITION_Y as u32), &mut absinfo) >= 0 {
+            min_y = absinfo.minimum;
+            max_y = absinfo.maximum;
+        } else if libc::ioctl(fd, eviocgabs(ABS_Y as u32), &mut absinfo) >= 0 {
+            min_y = absinfo.minimum;
+            max_y = absinfo.maximum;
+        }
+    }
+
+    let range_x = (max_x - min_x).max(1) as f32;
+    let range_y = (max_y - min_y).max(1) as f32;
+
+    let mut slots = [SlotState::default(); 5];
+    let mut current_slot = 0usize;
+    let mut buf = [0u8; 24];
+
+    let mut touch = TouchTracker {
+        last_x: None,
+        last_y: None,
+        dx: 0,
+        dy: 0,
+        finger_down: false,
+    };
+
+    loop {
+        file.read_exact(&mut buf).await?;
+        let event: InputEvent = unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEvent) };
+        let now = Instant::now();
+
+        if event.type_ == EV_REL {
+            if event.code == REL_X {
+                let _ = tx.send(CoordinatorMsg::PhysicalMove { dx: event.value, dy: 0, timestamp: now });
+            } else if event.code == REL_Y {
+                let _ = tx.send(CoordinatorMsg::PhysicalMove { dx: 0, dy: event.value, timestamp: now });
+            } else if event.code == REL_WHEEL {
+                let _ = tx.send(CoordinatorMsg::PhysicalScroll { dwx: 0, dwy: event.value, timestamp: now });
+            } else if event.code == REL_HWHEEL {
+                let _ = tx.send(CoordinatorMsg::PhysicalScroll { dwx: event.value, dwy: 0, timestamp: now });
+            }
+        } else if event.type_ == EV_ABS {
+            if event.code == ABS_X || event.code == ABS_MT_POSITION_X {
+                let val = event.value;
+                if let Some(lx) = touch.last_x {
+                    touch.dx += val - lx;
+                }
+                touch.last_x = Some(val);
+                slots[current_slot].x = (val - min_x) as f32 / range_x;
+            } else if event.code == ABS_Y || event.code == ABS_MT_POSITION_Y {
+                let val = event.value;
+                if let Some(ly) = touch.last_y {
+                    touch.dy += val - ly;
+                }
+                touch.last_y = Some(val);
+                slots[current_slot].y = (val - min_y) as f32 / range_y;
+            } else if event.code == ABS_MT_TRACKING_ID {
+                if event.value >= 0 {
+                    touch.finger_down = true;
+                    slots[current_slot].active = true;
+                } else {
+                    touch.finger_down = false;
+                    touch.last_x = None;
+                    touch.last_y = None;
+                    slots[current_slot].active = false;
+                }
+            } else if event.code == ABS_MT_SLOT {
+                current_slot = (event.value as usize).min(4);
+            }
+        } else if event.type_ == EV_KEY {
+            if event.code == BTN_TOUCH {
+                if event.value == 1 {
+                    touch.finger_down = true;
+                    slots[0].active = true;
+                } else {
+                    touch.finger_down = false;
+                    touch.last_x = None;
+                    touch.last_y = None;
+                    for s in &mut slots {
+                        s.active = false;
+                    }
+                }
+            }
+        } else if event.type_ == EV_SYN {
+            if event.code == SYN_REPORT {
+                let active_fingers: Vec<FingerState> = slots.iter().enumerate()
+                    .filter(|(_, slot)| slot.active)
+                    .map(|(i, slot)| FingerState { slot: i, x: slot.x, y: slot.y })
+                    .collect();
+                let _ = tx.send(CoordinatorMsg::FingersReport(active_fingers));
+
+                if touch.finger_down {
+                    if touch.dx != 0 || touch.dy != 0 {
+                        let scaled_dx = (touch.dx as f32 * 0.15) as i32;
+                        let scaled_dy = (touch.dy as f32 * 0.15) as i32;
+                        if scaled_dx != 0 || scaled_dy != 0 {
+                            let _ = tx.send(CoordinatorMsg::PhysicalTrackpadMove { dx: scaled_dx, dy: scaled_dy, timestamp: now });
+                        }
+                        touch.dx = 0;
+                        touch.dy = 0;
+                    }
+                } else {
+                    let _ = tx.send(CoordinatorMsg::PhysicalTrackpadLift { timestamp: now });
+                    touch.dx = 0;
+                    touch.dy = 0;
+                    touch.last_x = None;
+                    touch.last_y = None;
+                }
+            }
+        }
+    }
+}
+
+struct PhysicsState {
+    config: InertialConfig,
+
+    last_move_time: Instant,
+    vel_x: f32,
+    vel_y: f32,
+    anim_vel_x: f32,
+    anim_vel_y: f32,
+    is_animating_pointer: bool,
+    accum_x: f32,
+    accum_y: f32,
+
+    last_trackpad_move_time: Instant,
+    trackpad_vel_x: f32,
+    trackpad_vel_y: f32,
+    anim_trackpad_vel_x: f32,
+    anim_trackpad_vel_y: f32,
+    is_animating_trackpad: bool,
+    accum_trackpad_x: f32,
+    accum_trackpad_y: f32,
+    last_physical_scroll_time: Instant,
+
+    last_scroll_time: Instant,
+    scroll_vel_x: f32,
+    scroll_vel_y: f32,
+    anim_scroll_vel_x: f32,
+    anim_scroll_vel_y: f32,
+    is_animating_scroll: bool,
+    accum_scroll_x: f32,
+    accum_scroll_y: f32,
+
+    tap_to_click: bool,
+    trackpad_disabled_by_scroll: bool,
+}
+
+fn trigger_tap_to_click_ipc(tap: bool, ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
+    let cmd = format!("input tap-to-click {}", tap);
+    let _ = ipc_tx.send(cmd);
+    unsafe {
+        libc::write(pipe_write, &1u8 as *const u8 as *const libc::c_void, 1);
+    }
+}
+
+fn trigger_trackpad_disabled_ipc(disabled: bool, ipc_tx: &std::sync::mpsc::Sender<String>, pipe_write: libc::c_int) {
+    let cmd = format!("input trackpad-disabled {}", disabled);
+    let _ = ipc_tx.send(cmd);
+    unsafe {
+        libc::write(pipe_write, &1u8 as *const u8 as *const libc::c_void, 1);
+    }
+}
+
+pub fn run_input_daemon(
+    mut event_queue_rx: tokio::sync::mpsc::UnboundedReceiver<(InertialConfig, bool)>,
+    ipc_tx: std::sync::mpsc::Sender<String>,
+    pipe_write: libc::c_int,
+) -> Result<(), Box<dyn std::error::Error>> {
+    let rt = tokio::runtime::Builder::new_current_thread()
+        .enable_all()
+        .build()?;
+
+    let res: Result<(), Box<dyn std::error::Error>> = rt.block_on(async move {
+        println!("[input-subsystem] Starting Clear Input Subsystem...");
+
+        let mut uinput_file = match setup_uinput() {
+            Ok(f) => f,
+            Err(e) => {
+                eprintln!("[input-subsystem] FATAL: Could not initialize /dev/uinput: {}.", e);
+                return Err(Box::new(e) as Box<dyn std::error::Error>);
+            }
+        };
+
+        let (tx, mut rx): (UnboundedSender<CoordinatorMsg>, UnboundedReceiver<CoordinatorMsg>) = unbounded_channel();
+
+        // Broadcast coordinate socket setup
+        let (broadcast_tx, _) = tokio::sync::broadcast::channel::<String>(32);
+        let socket_path = "/tmp/clear-input-coords.sock";
+        let _ = fs::remove_file(socket_path);
+        let listener = tokio::net::UnixListener::bind(socket_path)?;
+        let b_tx = broadcast_tx.clone();
+        tokio::spawn(async move {
+            loop {
+                if let Ok((mut stream, _)) = listener.accept().await {
+                    let mut rx = b_tx.subscribe();
+                    tokio::spawn(async move {
+                        use tokio::io::AsyncWriteExt;
+                        while let Ok(msg) = rx.recv().await {
+                            if stream.write_all(format!("{}\n", msg).as_bytes()).await.is_err() {
+                                break;
+                            }
+                        }
+                    });
+                }
+            }
+        });
+
+        // Wait for initial config
+        let (initial_config, tap_to_click) = match event_queue_rx.recv().await {
+            Some(res) => res,
+            None => {
+                return Err(Box::from("Failed to receive initial input configuration") as Box<dyn std::error::Error>);
+            }
+        };
+
+        // Config listener task
+        let tx_config = tx.clone();
+        tokio::spawn(async move {
+            while let Some((config, tap)) = event_queue_rx.recv().await {
+                let _ = tx_config.send(CoordinatorMsg::UpdateConfig((config, tap)));
+            }
+        });
+
+        // Event device discovery loop
+        let tx_devices = tx.clone();
+        tokio::spawn(async move {
+            let mut active_devices = HashSet::new();
+            loop {
+                if let Ok(entries) = fs::read_dir("/dev/input/by-path") {
+                    for entry in entries.flatten() {
+                        let path = entry.path();
+                        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
+                            if name.ends_with("-event-mouse") {
+                                if let Ok(canonical) = fs::canonicalize(&path) {
+                                    if !active_devices.contains(&canonical) {
+                                        active_devices.insert(canonical.clone());
+                                        let tx_c = tx_devices.clone();
+                                        let device_path = canonical.clone();
+                                        tokio::spawn(async move {
+                                            if let Err(e) = read_device_loop(device_path.clone(), tx_c).await {
+                                                eprintln!("[input-subsystem] Reader loop exited for {:?}: {}", device_path, e);
+                                            }
+                                        });
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+                sleep(Duration::from_secs(5)).await;
+            }
+        });
+
+        // Central coordinator and physics ticks
+        trigger_tap_to_click_ipc(tap_to_click, &ipc_tx, pipe_write);
+
+        let mut state = PhysicsState {
+            config: initial_config,
+            last_move_time: Instant::now(),
+            vel_x: 0.0,
+            vel_y: 0.0,
+            anim_vel_x: 0.0,
+            anim_vel_y: 0.0,
+            is_animating_pointer: false,
+            accum_x: 0.0,
+            accum_y: 0.0,
+            last_trackpad_move_time: Instant::now(),
+            trackpad_vel_x: 0.0,
+            trackpad_vel_y: 0.0,
+            anim_trackpad_vel_x: 0.0,
+            anim_trackpad_vel_y: 0.0,
+            is_animating_trackpad: false,
+            accum_trackpad_x: 0.0,
+            accum_trackpad_y: 0.0,
+            last_physical_scroll_time: Instant::now(),
+            last_scroll_time: Instant::now(),
+            scroll_vel_x: 0.0,
+            scroll_vel_y: 0.0,
+            anim_scroll_vel_x: 0.0,
+            anim_scroll_vel_y: 0.0,
+            is_animating_scroll: false,
+            accum_scroll_x: 0.0,
+            accum_scroll_y: 0.0,
+            tap_to_click,
+            trackpad_disabled_by_scroll: false,
+        };
+
+        let mut tick_interval = tokio::time::interval(Duration::from_millis(16)); // ~60fps
+
+        loop {
+            tokio::select! {
+                Some(msg) = rx.recv() => {
+                    match msg {
+                        CoordinatorMsg::UpdateConfig((cfg, tap)) => {
+                            state.config = cfg;
+                            if state.tap_to_click != tap {
+                                state.tap_to_click = tap;
+                                trigger_tap_to_click_ipc(tap, &ipc_tx, pipe_write);
+                            }
+                        }
+                        CoordinatorMsg::PhysicalMove { dx, dy, timestamp } => {
+                            let dt = timestamp.duration_since(state.last_move_time).as_secs_f32();
+                            state.last_move_time = timestamp;
+
+                            state.is_animating_pointer = false;
+                            state.anim_vel_x = 0.0;
+                            state.anim_vel_y = 0.0;
+
+                            if dt > 0.1 {
+                                state.vel_x = 0.0;
+                                state.vel_y = 0.0;
+                            } else if dt > 0.001 {
+                                let inst_vx = dx as f32 / dt;
+                                let inst_vy = dy as f32 / dt;
+                                let alpha = 0.35;
+                                state.vel_x = alpha * inst_vx + (1.0 - alpha) * state.vel_x;
+                                state.vel_y = alpha * inst_vy + (1.0 - alpha) * state.vel_y;
+                            }
+                        }
+                        CoordinatorMsg::PhysicalTrackpadMove { dx, dy, timestamp } => {
+                            let dt = timestamp.duration_since(state.last_trackpad_move_time).as_secs_f32();
+                            state.last_trackpad_move_time = timestamp;
+
+                            state.is_animating_trackpad = false;
+                            state.anim_trackpad_vel_x = 0.0;
+                            state.anim_trackpad_vel_y = 0.0;
+
+                            if dt > 0.1 {
+                                state.trackpad_vel_x = 0.0;
+                                state.trackpad_vel_y = 0.0;
+                            } else if dt > 0.001 {
+                                let inst_vx = dx as f32 / dt;
+                                let inst_vy = dy as f32 / dt;
+                                let alpha = 0.35;
+                                state.trackpad_vel_x = alpha * inst_vx + (1.0 - alpha) * state.trackpad_vel_x;
+                                state.trackpad_vel_y = alpha * inst_vy + (1.0 - alpha) * state.trackpad_vel_y;
+                            }
+                        }
+                        CoordinatorMsg::PhysicalTrackpadLift { timestamp } => {
+                            if state.trackpad_disabled_by_scroll {
+                                state.trackpad_disabled_by_scroll = false;
+                                trigger_trackpad_disabled_ipc(false, &ipc_tx, pipe_write);
+                            }
+                            let time_since_scroll = timestamp.duration_since(state.last_physical_scroll_time).as_secs_f32();
+                            if time_since_scroll > 0.15 {
+                                if state.config.inertial_trackpad && !state.is_animating_trackpad {
+                                    let time_since_last = timestamp.duration_since(state.last_trackpad_move_time).as_secs_f32();
+                                    if time_since_last < 0.05 {
+                                        let speed = (state.trackpad_vel_x * state.trackpad_vel_x + state.trackpad_vel_y * state.trackpad_vel_y).sqrt();
+                                        if speed > 150.0 {
+                                            state.is_animating_trackpad = true;
+                                            state.anim_trackpad_vel_x = state.trackpad_vel_x;
+                                            state.anim_trackpad_vel_y = state.trackpad_vel_y;
+                                            state.accum_trackpad_x = 0.0;
+                                            state.accum_trackpad_y = 0.0;
+                                        }
+                                    }
+                                }
+                            } else {
+                                state.trackpad_vel_x = 0.0;
+                                state.trackpad_vel_y = 0.0;
+                            }
+                        }
+                        CoordinatorMsg::PhysicalScroll { dwx, dwy, timestamp } => {
+                            if !state.trackpad_disabled_by_scroll {
+                                state.trackpad_disabled_by_scroll = true;
+                                trigger_trackpad_disabled_ipc(true, &ipc_tx, pipe_write);
+                            }
+                            let dt = timestamp.duration_since(state.last_scroll_time).as_secs_f32();
+                            state.last_scroll_time = timestamp;
+                            state.last_physical_scroll_time = timestamp;
+
+                            state.is_animating_scroll = false;
+                            state.anim_scroll_vel_x = 0.0;
+                            state.anim_scroll_vel_y = 0.0;
+
+                            if dt > 0.15 {
+                                state.scroll_vel_x = 0.0;
+                                state.scroll_vel_y = 0.0;
+                            } else if dt > 0.001 {
+                                let inst_vx = dwx as f32 / dt;
+                                let inst_vy = dwy as f32 / dt;
+                                let alpha = 0.45;
+                                state.scroll_vel_x = alpha * inst_vx + (1.0 - alpha) * state.scroll_vel_x;
+                                state.scroll_vel_y = alpha * inst_vy + (1.0 - alpha) * state.scroll_vel_y;
+                            }
+                        }
+                        CoordinatorMsg::FingersReport(fingers) => {
+                            if fingers.is_empty() && state.trackpad_disabled_by_scroll {
+                                state.trackpad_disabled_by_scroll = false;
+                                trigger_trackpad_disabled_ipc(false, &ipc_tx, pipe_write);
+                            }
+                            if let Ok(serialized) = serde_json::to_string(&fingers) {
+                                let _ = broadcast_tx.send(serialized);
+                            }
+                        }
+                    }
+                }
+                _ = tick_interval.tick() => {
+                    let now = Instant::now();
+                    let tick_dt = 0.016;
+
+                    // --- Pointer physics tick ---
+                    if state.config.inertial_pointer {
+                        if !state.is_animating_pointer {
+                            let time_since_last = now.duration_since(state.last_move_time).as_secs_f32();
+                            if time_since_last >= 0.02 && time_since_last < 0.1 {
+                                let speed = (state.vel_x * state.vel_x + state.vel_y * state.vel_y).sqrt();
+                                if speed > 150.0 {
+                                    state.is_animating_pointer = true;
+                                    state.anim_vel_x = state.vel_x;
+                                    state.anim_vel_y = state.vel_y;
+                                    state.accum_x = 0.0;
+                                    state.accum_y = 0.0;
+                                }
+                            }
+                        } else {
+                            let friction_coef = state.config.pointer_friction as f32 / 100.0;
+                            state.anim_vel_x *= friction_coef;
+                            state.anim_vel_y *= friction_coef;
+
+                            let speed = (state.anim_vel_x * state.anim_vel_x + state.anim_vel_y * state.anim_vel_y).sqrt();
+                            if speed < 12.0 {
+                                state.is_animating_pointer = false;
+                            } else {
+                                state.accum_x += state.anim_vel_x * tick_dt * state.config.pointer_speed as f32;
+                                state.accum_y += state.anim_vel_y * tick_dt * state.config.pointer_speed as f32;
+
+                                let steps_x = state.accum_x.trunc() as i32;
+                                let steps_y = state.accum_y.trunc() as i32;
+                                state.accum_x -= steps_x as f32;
+                                state.accum_y -= steps_y as f32;
+
+                                if steps_x != 0 || steps_y != 0 {
+                                    let _ = write_mouse_move(&mut uinput_file, steps_x, steps_y);
+                                }
+                            }
+                        }
+                    }
+
+                    // --- Trackpad physics tick ---
+                    if state.config.inertial_trackpad && state.is_animating_trackpad {
+                        let friction_coef = state.config.trackpad_friction as f32 / 100.0;
+                        state.anim_trackpad_vel_x *= friction_coef;
+                        state.anim_trackpad_vel_y *= friction_coef;
+
+                        let speed = (state.anim_trackpad_vel_x * state.anim_trackpad_vel_x + state.anim_trackpad_vel_y * state.anim_trackpad_vel_y).sqrt();
+                        if speed < 12.0 {
+                            state.is_animating_trackpad = false;
+                        } else {
+                            state.accum_trackpad_x += state.anim_trackpad_vel_x * tick_dt * state.config.trackpad_speed as f32;
+                            state.accum_trackpad_y += state.anim_trackpad_vel_y * tick_dt * state.config.trackpad_speed as f32;
+
+                            let steps_x = state.accum_trackpad_x.trunc() as i32;
+                            let steps_y = state.accum_trackpad_y.trunc() as i32;
+                            state.accum_trackpad_x -= steps_x as f32;
+                            state.accum_trackpad_y -= steps_y as f32;
+
+                            if steps_x != 0 || steps_y != 0 {
+                                let _ = write_mouse_move(&mut uinput_file, steps_x, steps_y);
+                            }
+                        }
+                    }
+
+                    // --- Scroll physics tick ---
+                    if state.config.inertial_scroll {
+                        if !state.is_animating_scroll {
+                            let time_since_last = now.duration_since(state.last_scroll_time).as_secs_f32();
+                            if time_since_last >= 0.03 && time_since_last < 0.15 {
+                                let speed = (state.scroll_vel_x * state.scroll_vel_x + state.scroll_vel_y * state.scroll_vel_y).sqrt();
+                                if speed > 5.0 {
+                                    state.is_animating_scroll = true;
+                                    state.anim_scroll_vel_x = state.scroll_vel_x;
+                                    state.anim_scroll_vel_y = state.scroll_vel_y;
+                                    state.accum_scroll_x = 0.0;
+                                    state.accum_scroll_y = 0.0;
+                                }
+                            }
+                        } else {
+                            let friction_coef = state.config.scroll_friction as f32 / 100.0;
+                            state.anim_scroll_vel_x *= friction_coef;
+                            state.anim_scroll_vel_y *= friction_coef;
+
+                            let speed = (state.anim_scroll_vel_x * state.anim_scroll_vel_x + state.anim_scroll_vel_y * state.anim_scroll_vel_y).sqrt();
+                            if speed < 0.5 {
+                                state.is_animating_scroll = false;
+                            } else {
+                                state.accum_scroll_x += state.anim_scroll_vel_x * tick_dt * state.config.scroll_speed as f32;
+                                state.accum_scroll_y += state.anim_scroll_vel_y * tick_dt * state.config.scroll_speed as f32;
+
+                                let steps_x = state.accum_scroll_x.trunc() as i32;
+                                let steps_y = state.accum_scroll_y.trunc() as i32;
+                                state.accum_scroll_x -= steps_x as f32;
+                                state.accum_scroll_y -= steps_y as f32;
+
+                                if steps_x != 0 || steps_y != 0 {
+                                    let _ = write_scroll(&mut uinput_file, steps_x, steps_y);
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    });
+
+    res
+}
diff --git a/src/ipc.rs b/src/ipc.rs
index e7126fb..e51ee61 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -668,6 +668,21 @@ fn handle_input_command(rest: &str, state: &mut WindowManager) {
                 }
             }
         }
+        "trackpad-disabled" | "trackpad_disabled" => {
+            let old_val = state.trackpad_disabled;
+            match value_str {
+                "true" | "1" | "enabled" => {
+                    state.trackpad_disabled = true;
+                }
+                "false" | "0" | "disabled" => {
+                    state.trackpad_disabled = false;
+                }
+                _ => {}
+            }
+            if state.trackpad_disabled != old_val {
+                state.tap_config_applied = false;
+            }
+        }
         "trackpoint-accel-speed" | "trackpoint_accel_speed" => {
             if let Ok(val) = value_str.parse::<f64>() {
                 state.trackpoint_accel_speed = Some(val);
diff --git a/src/ipc_server.rs b/src/ipc_server.rs
index c88b819..a73dcd4 100644
--- a/src/ipc_server.rs
+++ b/src/ipc_server.rs
@@ -6,10 +6,12 @@ pub const IPC_SOCKET_PATH: &str = "/tmp/clearwm.sock";
 
 pub struct IpcReceiver {
     pub rx: mpsc::Receiver<String>,
+    pub tx: mpsc::Sender<String>,
 }
 
 pub fn spawn_ipc_server(pipe_write: libc::c_int) -> IpcReceiver {
     let (tx, rx) = mpsc::channel::<String>();
+    let tx_clone = tx.clone();
 
     std::thread::Builder::new()
         .name("clearwm-ipc".into())
@@ -18,7 +20,7 @@ pub fn spawn_ipc_server(pipe_write: libc::c_int) -> IpcReceiver {
         })
         .expect("failed to spawn IPC server thread");
 
-    IpcReceiver { rx }
+    IpcReceiver { rx, tx: tx_clone }
 }
 
 fn ipc_server_main(tx: mpsc::Sender<String>, pipe_write: libc::c_int) {
diff --git a/src/lib.rs b/src/lib.rs
index 87da6c3..9ed148a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,3 +15,5 @@ pub mod types;
 #[allow(unreachable_patterns)] // wayland event match arms use _ => {} for forward-compat
 pub mod wayland;
 pub mod wm;
+pub mod input;
+
diff --git a/src/main.rs b/src/main.rs
index 947aef0..634ef99 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -96,7 +96,25 @@ fn main() {
     let pipe_write = pipe_fds[1];
 
     // Start the IPC server thread (for clearctl and clear-system-interface)
-    let ipc_rx = ipc_server::spawn_ipc_server(pipe_write).rx;
+    let ipc_server = ipc_server::spawn_ipc_server(pipe_write);
+    let ipc_rx = ipc_server.rx;
+    let ipc_tx = ipc_server.tx;
+
+    // Setup channel for configuration updates:
+    let (config_tx, config_rx) = tokio::sync::mpsc::unbounded_channel::<(clearwm::config::InertialConfig, bool)>();
+    state.wm.input_controller = Some(config_tx);
+
+    // Spawn the input subsystem background thread:
+    let pipe_write_clone = pipe_write;
+    let ipc_tx_clone = ipc_tx.clone();
+    std::thread::Builder::new()
+        .name("clearwm-input-subsystem".into())
+        .spawn(move || {
+            if let Err(e) = clearwm::input::run_input_daemon(config_rx, ipc_tx_clone, pipe_write_clone) {
+                eprintln!("[input-subsystem] Fatal error: {:?}", e);
+            }
+        })
+        .expect("failed to spawn input subsystem thread");
 
     // Store the status sender in the app state so RenderStart can push updates
     state.status_sender = Some(status_sender);
diff --git a/src/types.rs b/src/types.rs
index 898e61c..a9c5211 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -341,6 +341,8 @@ pub struct WindowManager {
     pub notifications_enable: bool,
     /// Reload commands to execute on configuration reload
     pub reload_commands: Vec<String>,
+    pub input_controller: Option<tokio::sync::mpsc::UnboundedSender<(crate::config::InertialConfig, bool)>>,
+    pub trackpad_disabled: bool,
 }
 
 impl Default for WindowManager {
@@ -383,6 +385,8 @@ impl Default for WindowManager {
             tap_config_applied: false,
             notifications_enable: true,
             reload_commands: Vec::new(),
+            input_controller: None,
+            trackpad_disabled: false,
         }
     }
 }
diff --git a/src/wayland.rs b/src/wayland.rs
index 169ad98..6e4085b 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -152,6 +152,7 @@ pub struct AppState {
     pub libinput_devices: Vec<LibinputDeviceInfo>,
     /// The surface the pointer is currently hovering over
     pub pointer_hovered_surface: Option<wl_surface::WlSurface>,
+    pub input_device_names: std::collections::HashMap<u32, String>,
 }
 
 /// Info tracked for each libinput device discovered via river_libinput_config_v1
@@ -212,6 +213,7 @@ impl AppState {
             libinput_config: None,
             libinput_devices: Vec::new(),
             pointer_hovered_surface: None,
+            input_device_names: std::collections::HashMap::new(),
         }
     }
 
@@ -1568,6 +1570,7 @@ impl Dispatch<RiverInputDeviceV1, ()> for AppState {
             }
             river_input_device_v1::Event::Name { name } => {
                 eprintln!("input_device name: {}", name);
+                state.input_device_names.insert(proxy.id().protocol_id(), name.clone());
                 if let Some(dev) = state.libinput_devices.iter_mut().find(|d| {
                     if let Some(ref id) = d.input_device {
                         id.id().protocol_id() == proxy.id().protocol_id()
@@ -2644,8 +2647,15 @@ impl Dispatch<RiverLibinputDeviceV1, ()> for AppState {
         match event {
             river_libinput_device_v1::Event::InputDevice { device } => {
                 if let Some(dev) = state.libinput_devices.iter_mut().find(|d| d.device.id().protocol_id() == proxy.id().protocol_id()) {
-                    dev.input_device = Some(device);
+                    dev.input_device = Some(device.clone());
+                    let dev_id = device.id().protocol_id();
+                    if let Some(name) = state.input_device_names.get(&dev_id) {
+                        dev.name = name.clone();
+                        dev.name_received = true;
+                        eprintln!("[libinput] associated name \"{}\" with device on InputDevice event", name);
+                    }
                 }
+                crate::wayland::apply_input_config(state, qhandle);
             }
             river_libinput_device_v1::Event::TapSupport { finger_count } => {
                 if let Some(dev) = state.libinput_devices.iter_mut().find(|d| d.device.id().protocol_id() == proxy.id().protocol_id()) {
@@ -2840,6 +2850,18 @@ pub fn apply_input_config(state: &mut AppState, qhandle: &QueueHandle<AppState>)
                 }
             }
         }
+        // 7. Send events (enable/disable trackpad)
+        let is_touchpad = dev_info.name.to_lowercase().contains("touchpad");
+        let send_events_mode = if state.wm.trackpad_disabled && is_touchpad {
+            river_libinput_device_v1::SendEventsModes::Disabled
+        } else {
+            river_libinput_device_v1::SendEventsModes::Enabled
+        };
+        eprintln!(
+            "[libinput] setting send_events={:?} on device ({})",
+            send_events_mode, &dev_info.name
+        );
+        dev_info.device.set_send_events(send_events_mode, qhandle, ());
 
         dev_info.config_applied = true;
     }