Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ All notable changes to PolterType are recorded here. The format is
loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added — macOS: the key gate (opt-in)

With `POLTERTYPE_HOLD_KEYS=1`, PolterType on macOS now holds your
keystrokes back while a correction types, and replays them behind it
— the race that used to scramble `зтзь ш ` into `ipnpm ` is closed on
a third platform. The event tap moves from listen-only to active
when the gate is on; our own emissions bypass the hold via the
emitter stamp; a tap the OS disables for overrunning its callback
budget is re-enabled instead of going deaf. Validated on Intel
hardware: a 4-key burst fired mid-correction lands exactly once, in
order, in the freshly switched layout.

Off by default for the same reason as Windows: the flush delays held
keys until the burst ends, which reads as the caret lagging after
every correction. Turn it on if you type fast enough to hit the
race — see `docs/PERMISSIONS.md`.

Two findings rode along:

- `core-graphics` 0.24's tap trampoline mapped a callback's `None`
back to the *original* event, so an "active" tap swallowed nothing
— the reason the gate now requires 0.25 (`CallbackResult::Drop`).
- The final post-release sweep sent held keystrokes through
`send_keys`, which is `Unsupported` on macOS and Windows — they are
now emitted via the same `send_text` fallback as the main flush,
closing a narrow window where a fast typist could lose characters
outright.

## [0.12.0] — the AI socket ships in the box

### Changed
Expand Down
15 changes: 14 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ tarde` lands correctly as typed.*
> costs a noticeable delay after every correction, which is not a
> trade worth making for everyone
> ([#7](https://github.com/Just-Code-NET/PolterType/issues/7)).
> On macOS the same hold-back exists too, equally **off by default**
> (`POLTERTYPE_HOLD_KEYS=1`), validated on Intel hardware — see
> [issue #8](https://github.com/Just-Code-NET/PolterType/issues/8).
> **macOS: read this
> before updating.** 0.6.2 was validated on real hardware (macOS 15,
> Intel), but 0.7.0 changed the macOS input path — modifier events now
Expand Down
10 changes: 7 additions & 3 deletions crates/poltertype-core/src/engine/switcher/correction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,9 +567,13 @@ impl SwitcherEngine {
}
if !last.is_empty() {
debug!(count = last.len(), "typing out the last held keystrokes");
let sent = self.key_emitter.send_keys(&last);
self.push_echoes(self.key_emitter.take_emitted());
if let Err(e) = sent {
// Not `send_keys` directly: on macOS and Windows
// that is `Unsupported`, and these keystrokes were
// already swallowed from the application — dropping
// them here would lose them outright. Same fix as
// the main flush path; the second call site was
// missed when `emit_held_keys` got its fallback.
if let Err(e) = self.emit_held_keys(&last, to) {
warn!(?e, "flushing the last held keystrokes failed");
}
}
Expand Down
6 changes: 5 additions & 1 deletion crates/poltertype-input/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ windows = { version = "0.58", features = [

[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-graphics = "0.24"
# 0.25 is the first release whose tap callback can actually swallow an
# event (CallbackResult::Drop → NULL). 0.24's trampoline turned our
# `None` back into the original event, so an "active" tap swallowed
# nothing and the key gate doubled the user's keystrokes.
core-graphics = "0.25"

[target.'cfg(target_os = "linux")'.dependencies]
# Wayland-first: evdev requires the user to be in the `input` group.
Expand Down
13 changes: 11 additions & 2 deletions crates/poltertype-input/src/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ pub fn create_key_gate() -> KeyGate {
{
KeyGate::windows(std::sync::Arc::new(windows::WindowsGate::new()))
}
#[cfg(not(any(target_os = "linux", windows)))]
#[cfg(target_os = "macos")]
{
KeyGate::macos(std::sync::Arc::new(macos::MacosGate::new()))
}
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
{
KeyGate::disabled()
}
Expand All @@ -41,7 +45,12 @@ pub fn create_listener(gate: &KeyGate) -> Result<Box<dyn InputListener>, InputEr
}
#[cfg(target_os = "macos")]
{
Ok(Box::new(macos::MacosListener::new()))
// Same wiring as Windows: the tap callback consults the gate
// on every keystroke.
Ok(Box::new(match gate.macos_inner() {
Some(g) => macos::MacosListener::with_gate(std::sync::Arc::clone(g)),
None => macos::MacosListener::new(),
}))
}
#[cfg(target_os = "linux")]
{
Expand Down
37 changes: 25 additions & 12 deletions crates/poltertype-input/src/gate.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! `KeyGate` — the "hold the user's keystrokes back while we type" seam.

// Only the evdev and Windows backends have anything behind the gate;
// elsewhere `KeyGate` is an empty struct and this import would be dead.
#[cfg(any(target_os = "linux", windows))]
// Only the evdev, Windows and macOS backends have anything behind the
// gate; elsewhere `KeyGate` is an empty struct and this import would
// be dead.
#[cfg(any(target_os = "linux", windows, target_os = "macos"))]
use std::sync::Arc;

/// Holds physical keystrokes back from applications for the duration of
Expand All @@ -17,10 +18,10 @@ use std::sync::Arc;
/// behind the correction in the order they were typed.
///
/// A gate that reports `available() == false` is a no-op, and that is
/// the common case: macOS has no implementation, the Windows one is
/// off unless `POLTERTYPE_HOLD_KEYS=1` because it has never run on real
/// hardware, and even the evdev gate stands down on stacks where it
/// would do more harm than good. Callers must therefore treat
/// the common case: the Windows one is off unless
/// `POLTERTYPE_HOLD_KEYS=1` because it has never run on real hardware,
/// and even the evdev gate stands down on stacks where it would do
/// more harm than good. Callers must therefore treat
/// [`hold`](Self::hold) returning `false` as normal and stay correct
/// without it.
#[derive(Clone, Default)]
Expand All @@ -29,6 +30,8 @@ pub struct KeyGate {
inner: Option<Arc<crate::linux::wayland::EvdevGate>>,
#[cfg(windows)]
inner: Option<Arc<crate::windows::WindowsGate>>,
#[cfg(target_os = "macos")]
inner: Option<Arc<crate::macos::MacosGate>>,
}

impl KeyGate {
Expand Down Expand Up @@ -58,15 +61,25 @@ impl KeyGate {
self.inner.as_ref()
}

#[cfg(target_os = "macos")]
pub(crate) fn macos(inner: Arc<crate::macos::MacosGate>) -> Self {
Self { inner: Some(inner) }
}

#[cfg(target_os = "macos")]
pub(crate) fn macos_inner(&self) -> Option<&Arc<crate::macos::MacosGate>> {
self.inner.as_ref()
}

/// Can this gate actually hold keys? Answered by the backend once
/// the input stack is up, so it is only meaningful after the
/// listener has started.
pub fn available(&self) -> bool {
#[cfg(any(target_os = "linux", windows))]
#[cfg(any(target_os = "linux", windows, target_os = "macos"))]
{
self.inner.as_ref().is_some_and(|g| g.available())
}
#[cfg(not(any(target_os = "linux", windows)))]
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
{
false
}
Expand All @@ -79,19 +92,19 @@ impl KeyGate {
/// the backend also enforces its own ceiling: a caller that dies
/// mid-correction cannot leave the keyboard dead.
pub fn hold(&self) -> bool {
#[cfg(any(target_os = "linux", windows))]
#[cfg(any(target_os = "linux", windows, target_os = "macos"))]
{
self.inner.as_ref().is_some_and(|g| g.hold())
}
#[cfg(not(any(target_os = "linux", windows)))]
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
{
false
}
}

/// Let the user's keystrokes through again. Idempotent.
pub fn release(&self) {
#[cfg(any(target_os = "linux", windows))]
#[cfg(any(target_os = "linux", windows, target_os = "macos"))]
if let Some(g) = self.inner.as_ref() {
g.release();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! The key gate's decision, with no Win32 in it.
//! The key gate's decision, with no OS API in it.
//!
//! Everything that decides *whether to swallow a keystroke* lives here,
//! deliberately free of `windows-rs`, so it compiles under `cfg(test)`
//! on any host and the safety properties get tested on a machine this
//! project actually has. The hook callback in `listener.rs` does
//! nothing but read an event's flags and ask [`HoldState::swallow`].
//! deliberately platform-free, so it compiles under `cfg(test)` on any
//! host and the safety properties get tested on machines this project
//! actually has. The Windows hook callback and the macOS event-tap
//! callback each do nothing but read an event's flags and ask
//! [`HoldState::swallow`].
//!
//! ## Why this is safer than it sounds
//!
Expand Down
6 changes: 6 additions & 0 deletions crates/poltertype-input/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ mod windows;
mod enums;
mod factory;
mod gate;
// The key gate's swallow decision, shared by the Windows and macOS
// gates. Pure std, no OS imports — compiled under `cfg(test)` on every
// host so its safety properties are tested where the project actually
// runs CI.
#[cfg(any(windows, target_os = "macos", test))]
mod hold;
mod traits;
mod types;

Expand Down
Loading