Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
da3b3d4
fix(input/diagnostics): close Linux correctness gaps
nisavid Aug 18, 2026
35ef569
fix(sync): keep correctness export source-bound
nisavid Aug 18, 2026
45fbafb
feat(windowing/niri): add bounded IPC backend
nisavid Aug 18, 2026
bc31bc3
fix(input/pointer): preserve extended-button fallthrough
nisavid Aug 18, 2026
3e5fa0d
fix(windowing/kwin): authenticate script callbacks
nisavid Aug 18, 2026
5460acd
fix(windowing/gnome): report stale extension reloads
nisavid Aug 18, 2026
0ba9d5c
fix(windowing/kwin): bound script transactions
nisavid Aug 18, 2026
f6a9751
fix(diagnostics/accessibility): require complete AT-SPI readiness
nisavid Aug 18, 2026
8be5be1
fix(windowing/kwin): preserve callback registration ownership
nisavid Aug 18, 2026
32795bf
fix(windowing/gnome): fail closed on unknown extension state
nisavid Aug 18, 2026
b8afb98
perf(input/pointer): skip unsupported absolute buttons early
nisavid Aug 18, 2026
bebbdcc
fix(windowing/niri): report decorated tile bounds
nisavid Aug 18, 2026
4254542
fix(windowing): remove Niri backend
nisavid Aug 19, 2026
3d857f0
fix(gnome): bound extension setup commands
nisavid Aug 19, 2026
1aa2458
fix(input): report clamped pointer landing
nisavid Aug 19, 2026
2243d26
fix(input): report emitted CLI coordinates
nisavid Aug 19, 2026
d2521d0
test(command): reuse process exit verifier
nisavid Aug 19, 2026
6c5a7c9
refactor(input): model pointer landing explicitly
nisavid Aug 19, 2026
2d7ab1f
fix(windowing/kwin): classify Plasma 6 clients
nisavid Aug 20, 2026
39efa80
docs(changelog): note Plasma 6 client classification
nisavid Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Absolute uinput pointer axes now end at the final logical desktop pixel, so
edge coordinates are advertised and clamped consistently.
- AT-SPI diagnostics now verify that the discovered accessibility bus can
reach `org.a11y.atspi.Registry` before reporting tree support as ready.

## [0.4.9] - 2026-08-12

### Fixed
Expand Down
62 changes: 49 additions & 13 deletions src/abs_pointer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,46 @@ use evdev::{
PropType, UinputAbsSetup,
};

#[derive(Clone, Copy)]
struct AbsPointerGeometry {
max_x: i32,
max_y: i32,
}

impl AbsPointerGeometry {
fn from_dimensions(width: i32, height: i32) -> Self {
Self {
max_x: width.max(1).saturating_sub(1),
max_y: height.max(1).saturating_sub(1),
}
}

fn axis_maxima(self) -> (i32, i32) {
(self.max_x, self.max_y)
}

fn clamp_coordinates(self, x: i32, y: i32) -> (i32, i32) {
(x.clamp(0, self.max_x), y.clamp(0, self.max_y))
}
}

pub struct AbsPointer {
device: VirtualDevice,
width: i32,
height: i32,
geometry: AbsPointerGeometry,
}

impl AbsPointer {
/// Create the absolute pointer sized to the logical desktop `width`×`height`
/// (the portal screenshot dimensions). Blocks ~`settle` ms so libinput picks
/// the device up before the first event.
pub fn create(width: i32, height: i32) -> Result<Self> {
let width = width.max(1);
let height = height.max(1);
let geometry = AbsPointerGeometry::from_dimensions(width, height);
let (max_x, max_y) = geometry.axis_maxima();
// value, min, max, fuzz, flat, resolution. resolution=1 unit/px.
let abs_x =
UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, AbsInfo::new(0, 0, width, 0, 0, 1));
UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, AbsInfo::new(0, 0, max_x, 0, 0, 1));
let abs_y =
UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, AbsInfo::new(0, 0, height, 0, 0, 1));
UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, AbsInfo::new(0, 0, max_y, 0, 0, 1));
let keys =
AttributeSet::from_iter([KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE]);
// INPUT_PROP_DIRECT marks the device as a direct (absolute) pointer so
Expand All @@ -59,17 +81,12 @@ impl AbsPointer {
// Give udev/libinput time to enumerate the new device.
sleep(Duration::from_millis(500));

Ok(Self {
device,
width,
height,
})
Ok(Self { device, geometry })
}

/// Move the pointer to absolute logical coordinates `(x, y)`.
pub fn move_to(&mut self, x: i32, y: i32) -> Result<()> {
let x = x.clamp(0, self.width);
let y = y.clamp(0, self.height);
let (x, y) = self.geometry.clamp_coordinates(x, y);
self.device
.emit(&[
InputEvent::new_now(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, x),
Expand Down Expand Up @@ -141,3 +158,22 @@ impl PointerButton {
}
}
}

#[cfg(test)]
mod tests {
use super::AbsPointerGeometry;

#[test]
fn axis_range_ends_at_last_desktop_pixel() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);

assert_eq!(geometry.axis_maxima(), (1919, 1079));
}

#[test]
fn pointer_coordinates_clamp_to_last_desktop_pixel() {
let geometry = AbsPointerGeometry::from_dimensions(1920, 1080);

assert_eq!(geometry.clamp_coordinates(1920, 1080), (1919, 1079));
}
}
184 changes: 178 additions & 6 deletions src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1256,16 +1256,95 @@ fn atspi_bus_address_check() -> Check {
"GetAddress",
],
);
let discovery = if busctl.ok {
busctl
} else {
gdbus_call_check(
"org.a11y.Bus",
"/org/a11y/bus",
"org.a11y.Bus.GetAddress",
&[],
)
};

validate_atspi_bus_address(discovery, atspi_registry_check)
}

fn validate_atspi_bus_address(
discovery: Check,
probe_registry: impl FnOnce(&str) -> Check,
) -> Check {
if !discovery.ok {
return discovery;
}

let Some(address) = parse_atspi_bus_address(&discovery.detail) else {
return Check::fail(format!(
"org.a11y.Bus.GetAddress returned an invalid address: {}",
discovery.detail
));
};
let registry = probe_registry(&address);
if registry.ok {
discovery
} else {
Check::fail(format!(
Comment thread
nisavid marked this conversation as resolved.
Outdated
"AT-SPI bus was discovered, but org.a11y.atspi.Registry is unreachable: {}",
registry.detail
))
}
}

fn parse_atspi_bus_address(detail: &str) -> Option<String> {
let start = detail.find(['\'', '"'])?;
let quote = detail.as_bytes()[start];
let value = &detail[start + 1..];
let end = value.as_bytes().iter().position(|byte| *byte == quote)?;
let address = &value[..end];
(!address.is_empty() && !address.chars().any(char::is_control)).then(|| address.to_string())
}

fn atspi_registry_check(address: &str) -> Check {
atspi_registry_check_with(address, command_check)
}

fn atspi_registry_check_with(address: &str, mut run: impl FnMut(&str, &[&str]) -> Check) -> Check {
let busctl_address = format!("--address={address}");
let busctl = run(
Comment thread
nisavid marked this conversation as resolved.
Outdated
"busctl",
&[
&busctl_address,
"call",
"org.a11y.atspi.Registry",
"/org/a11y/atspi/registry",
"org.freedesktop.DBus.Peer",
"Ping",
],
);
if busctl.ok {
return busctl;
}

gdbus_call_check(
"org.a11y.Bus",
"/org/a11y/bus",
"org.a11y.Bus.GetAddress",
&[],
)
let gdbus = run(
"gdbus",
&[
"introspect",
"--address",
address,
"--dest",
"org.a11y.atspi.Registry",
"--object-path",
"/org/a11y/atspi/registry",
],
);
if gdbus.ok {
gdbus
} else {
Check::fail(format!(
"busctl: {}; gdbus: {}",
busctl.detail, gdbus.detail
))
}
}

fn atspi_status_property_check(property: &str) -> Check {
Expand Down Expand Up @@ -1460,6 +1539,99 @@ mod tests {
assert!(can_build_accessibility_tree(&report));
}

#[test]
fn at_spi_bus_check_rejects_discovered_but_unreachable_registry() {
let check = validate_atspi_bus_address(
Check::ok("s \"unix:path=/run/user/1000/at-spi/bus\""),
|_| Check::fail("org.a11y.atspi.Registry is unreachable"),
);

assert!(!check.ok);
assert!(check.detail.contains("unreachable"));
}

#[test]
fn at_spi_bus_check_accepts_a_reachable_discovered_registry() {
let check = validate_atspi_bus_address(
Check::ok("('unix:path=/run/user/1000/at-spi/bus',)"),
|address| {
assert_eq!(address, "unix:path=/run/user/1000/at-spi/bus");
Check::ok("registry reachable")
},
);

assert!(check.ok);
assert_eq!(check.detail, "('unix:path=/run/user/1000/at-spi/bus',)");
}

#[test]
fn at_spi_bus_check_rejects_a_malformed_discovery_result() {
let check = validate_atspi_bus_address(Check::ok("s \"\""), |_| {
panic!("a malformed address must not be probed")
});

assert!(!check.ok);
assert!(check.detail.contains("invalid address"));
}

#[test]
fn at_spi_registry_busctl_probe_pings_the_registry_object() {
let mut commands = Vec::new();
let check =
atspi_registry_check_with("unix:path=/run/user/1000/at-spi/bus", |command, args| {
commands.push((
command.to_string(),
args.iter()
.map(ToString::to_string)
.collect::<Vec<String>>(),
));
Check::ok("pong")
});

assert!(check.ok);
assert_eq!(
commands,
vec![(
"busctl".to_string(),
vec![
"--address=unix:path=/run/user/1000/at-spi/bus".to_string(),
"call".to_string(),
"org.a11y.atspi.Registry".to_string(),
"/org/a11y/atspi/registry".to_string(),
"org.freedesktop.DBus.Peer".to_string(),
"Ping".to_string(),
],
)]
);
}

#[test]
fn at_spi_registry_probe_falls_back_to_gdbus_introspection() {
let mut commands = Vec::new();
let check =
atspi_registry_check_with("unix:path=/run/user/1000/at-spi/bus", |command, args| {
commands.push((
command.to_string(),
args.iter()
.map(ToString::to_string)
.collect::<Vec<String>>(),
));
if command == "busctl" {
Check::fail("busctl unavailable")
} else {
Check::ok("registry introspection")
}
});

assert!(check.ok);
assert_eq!(commands.len(), 2);
assert_eq!(commands[1].0, "gdbus");
assert_eq!(commands[1].1[0], "introspect");
assert_eq!(commands[1].1[2], "unix:path=/run/user/1000/at-spi/bus");
assert_eq!(commands[1].1[4], "org.a11y.atspi.Registry");
assert_eq!(commands[1].1[6], "/org/a11y/atspi/registry");
}

#[test]
fn parses_parent_pid_from_proc_status() {
let status = "Name:\ttest\nPid:\t42\nPPid:\t7\n";
Expand Down
Loading