feat(gui-1b): B4 --- a middle click pastes the PRIMARY selection

`PointerRoute::UnusedButton`'s own doc named this row: "Stage 1b's B4
gives the middle button a meaning (PRIMARY-selection paste on Linux) and
lands here." B4 splits that variant, as §2a said it would.

A middle PRESS is now `PointerRoute::MiddlePress` and reads the PRIMARY
selection, shipping it as the same `Paste` wire operation Ctrl-V uses.
Its RELEASE stays unused, like the right button's --- the paste happens
once, on the press.

PRIMARY and CLIPBOARD are different selections with different contents:
the clipboard holds what was last explicitly copied, PRIMARY holds what
is currently selected. Reading the wrong one still produces a paste,
just not the one the platform convention promises, so the row asserts
the SOURCE rather than that a paste happened.
`middle_click_paste_source` is the seam that makes that assertable
without an OS clipboard; `read_os_selection` takes the source and uses
arboard's `GetExtLinux` for PRIMARY.

Two rows, three mutations, each firing:

  source = Clipboard        -> the source row
  middle press unrouted     -> the routing row
  release also pastes       -> the routing row

Three existing rows encoded the old behaviour --- that a middle press is
semantics-free. They are updated to keep testing what they SAY rather
than being weakened to accommodate B4: the routing row now covers
Back/Forward/Other plus the middle RELEASE, and the two effect/order
rows switch to `Back`, a button that still has no semantics. Widening
them to accept the new meaning would have left no row asserting that
semantics-free buttons stay inert.
This commit is contained in:
Levi Neuwirth 2026-09-01 11:43:30 +02:00
parent 5371229b9a
commit 493db8f965
No known key found for this signature in database
1 changed files with 162 additions and 11 deletions

View File

@ -3423,6 +3423,26 @@ impl App {
WheelTarget::Chrome
}
/// Perform [`PointerRoute::MiddlePress`] — GUI Stage 1b B4.
///
/// Reads the selection [`middle_click_paste_source`] names and ships
/// it as a `Paste`, the same wire operation Ctrl-V uses. The daemon
/// inserts it; this frontend never edits the document itself.
fn apply_middle_press(&mut self) {
let source = middle_click_paste_source();
let bytes = self
.state
.as_mut()
.and_then(|state| state.read_os_selection(source));
if let Some(bytes) = bytes
&& !bytes.is_empty()
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_paste(bytes)
{
eprintln!("pmacs-gpu: middle-click send_paste failed: {e}");
}
}
/// Perform [`PointerRoute::Wheel`].
///
/// **GUI Stage 1b B1 reorders this pipeline.** It used to round to
@ -3578,6 +3598,7 @@ impl App {
// today nothing to do: a key-up the keyboard family claimed
// and dropped, a button the pointer family has no semantics
// for, and an event no family claims at all.
Route::Pointer(PointerRoute::MiddlePress) => self.apply_middle_press(),
Route::Keyboard {
action: KeyAction::Release,
..
@ -4019,12 +4040,16 @@ enum PointerRoute {
/// opens on the press, so the matching release is deliberately
/// nothing — see `UnusedButton`.
RightPress,
/// A `MouseInput` this frontend has no semantics for: the right
/// button's release, and every middle / back / forward / other
/// button in either state. **Claimed by the pointer family and
/// dropped**, exactly as it behaved when it fell through to the
/// wildcard. Stage 1b's B4 gives the middle button a meaning
/// (PRIMARY-selection paste on Linux) and lands here.
/// `MouseInput` **pressing** the middle button — GUI Stage 1b's
/// B4. On Linux this pastes the **PRIMARY selection**, which is the
/// platform convention and a different selection from the one
/// Ctrl-V reads. The release is deliberately nothing, like the
/// right button's.
MiddlePress,
/// A `MouseInput` this frontend has no semantics for: the right and
/// middle buttons' releases, and every back / forward / other button
/// in either state. **Claimed by the pointer family and dropped**,
/// exactly as it behaved when it fell through to the wildcard.
UnusedButton,
/// `MouseWheel`. The delta is carried raw: converting it to lines
/// needs the code line height, which is `State`'s to know.
@ -4042,6 +4067,7 @@ fn route_pointer(event: &WindowEvent) -> Option<PointerRoute> {
WindowEvent::MouseInput { state, button, .. } => Some(match (button, state) {
(MouseButton::Left, _) => PointerRoute::Left(*state),
(MouseButton::Right, ElementState::Pressed) => PointerRoute::RightPress,
(MouseButton::Middle, ElementState::Pressed) => PointerRoute::MiddlePress,
_ => PointerRoute::UnusedButton,
}),
WindowEvent::MouseWheel { delta, .. } => Some(PointerRoute::Wheel(*delta)),
@ -4610,7 +4636,6 @@ mod input_routing_tests {
#[test]
fn a_button_without_semantics_is_claimed_and_dropped() {
for button in [
MouseButton::Middle,
MouseButton::Back,
MouseButton::Forward,
MouseButton::Other(9),
@ -4624,6 +4649,14 @@ mod input_routing_tests {
);
}
}
// **The middle button is no longer semantics-free**: GUI Stage
// 1b's B4 gives its PRESS a meaning. Its RELEASE still has none,
// so it stays in this row rather than leaving it.
assert_eq!(
route_one(&mouse_input(ElementState::Released, MouseButton::Middle)),
Route::Pointer(PointerRoute::UnusedButton),
"a middle release remains nothing, like the right button's"
);
}
/// P1 — the wheel delta is carried **raw**. Converting it to lines
@ -4939,7 +4972,10 @@ mod input_routing_tests {
#[test]
fn an_unused_button_produces_no_effect_of_any_kind() {
let mut h = EffectHarness::new();
let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle));
// `Back`, not `Middle`: B4 gave the middle PRESS a meaning, so
// this row moved to a button that still has none rather than
// being weakened to accommodate the new one.
let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Back));
assert_eq!(
step,
Step {
@ -5076,7 +5112,10 @@ mod input_routing_tests {
position: PhysicalPosition::new(4.0, 8.0),
},
mouse_input(ElementState::Pressed, MouseButton::Left),
mouse_input(ElementState::Pressed, MouseButton::Middle),
// `Back`: this row is about ORDER, and it keeps a
// semantics-free button so B4's new middle-press meaning
// does not quietly become part of what it asserts.
mouse_input(ElementState::Pressed, MouseButton::Back),
WindowEvent::RedrawRequested,
WindowEvent::Occluded(false),
WindowEvent::CloseRequested,
@ -5288,6 +5327,33 @@ const WHEEL_LINES_PER_TICK: f32 = 3.0;
/// tick", the horizontal twin of [`WHEEL_LINES_PER_TICK`].
const WHEEL_COLUMNS_PER_TICK: f32 = 3.0;
/// Which OS selection a paste gesture reads.
///
/// X11 and Wayland carry two: the CLIPBOARD, written by an explicit
/// copy, and the PRIMARY selection, written merely by selecting text.
/// **They are different selections with different contents**, and the
/// platform convention pairs them with different gestures.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PasteSource {
/// What Ctrl-V reads.
Clipboard,
/// What a middle click reads on Linux (GUI Stage 1b B4).
Primary,
}
/// The selection a middle click pastes from.
///
/// **PRIMARY on Linux** — B4's whole content. Elsewhere there is no
/// PRIMARY selection, so the gesture falls back to the clipboard rather
/// than doing nothing, which is the closest available meaning.
const fn middle_click_paste_source() -> PasteSource {
if cfg!(target_os = "linux") {
PasteSource::Primary
} else {
PasteSource::Clipboard
}
}
/// The wire scroll kinds for a banked `(x, y)` tick count, in order.
///
/// One event per whole tick: a single wheel notch that banks two ticks
@ -6375,10 +6441,36 @@ impl State {
/// Read the OS clipboard as bytes (for Ctrl-V → `Paste`). `None` on
/// any failure (empty / non-text / unavailable).
fn read_os_clipboard(&mut self) -> Option<Vec<u8>> {
match self.os_clipboard()?.get_text() {
self.read_os_selection(PasteSource::Clipboard)
}
/// Read one named OS selection.
///
/// GUI Stage 1b B4 needs the **PRIMARY** selection, which on Linux
/// is a different selection from the clipboard with different
/// contents. Reading the clipboard for a middle click would paste
/// whatever was last explicitly copied instead of what is currently
/// selected — a plausible-looking wrong answer, which is why B4's
/// row asserts the source rather than that "a paste happened".
fn read_os_selection(&mut self, source: PasteSource) -> Option<Vec<u8>> {
let clipboard = self.os_clipboard()?;
let read = match source {
PasteSource::Clipboard => clipboard.get_text(),
#[cfg(target_os = "linux")]
PasteSource::Primary => {
use arboard::{GetExtLinux, LinuxClipboardKind};
clipboard
.get()
.clipboard(LinuxClipboardKind::Primary)
.text()
}
#[cfg(not(target_os = "linux"))]
PasteSource::Primary => clipboard.get_text(),
};
match read {
Ok(s) => Some(s.into_bytes()),
Err(e) => {
eprintln!("pmacs-gpu: clipboard read failed: {e}");
eprintln!("pmacs-gpu: {source:?} read failed: {e}");
None
}
}
@ -14610,6 +14702,65 @@ mod tests {
);
}
/// B4 — a middle-click paste reads the **PRIMARY selection** on
/// Linux, not the clipboard.
///
/// The two are different selections with different contents: the
/// clipboard holds what was last explicitly copied, PRIMARY holds
/// what is currently selected. Reading the wrong one produces a
/// paste — just not the one the platform convention promises — so
/// the row asserts the SOURCE rather than that a paste happened.
///
/// *Mutation: return `PasteSource::Clipboard` → this row.*
#[test]
fn b4_a_middle_click_pastes_the_primary_selection_on_linux() {
let source = super::middle_click_paste_source();
if cfg!(target_os = "linux") {
assert_eq!(
source,
super::PasteSource::Primary,
"B4: the middle button reads PRIMARY on Linux"
);
} else {
assert_eq!(
source,
super::PasteSource::Clipboard,
"off Linux there is no PRIMARY selection; the clipboard is \
the closest available meaning"
);
}
}
/// B4's routing half — a middle **press** is its own route now, and
/// no longer the claimed-and-dropped `UnusedButton`.
///
/// Its RELEASE stays unused, like the right button's: the paste
/// happens once, on the press.
#[test]
fn b4_a_middle_press_routes_to_its_own_variant_and_its_release_does_not() {
use winit::event::{ElementState, MouseButton};
let press = winit::event::WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: ElementState::Pressed,
button: MouseButton::Middle,
};
let release = winit::event::WindowEvent::MouseInput {
device_id: winit::event::DeviceId::dummy(),
state: ElementState::Released,
button: MouseButton::Middle,
};
assert_eq!(
super::route_pointer(&press),
Some(super::PointerRoute::MiddlePress),
"a middle press is B4's gesture"
);
assert_eq!(
super::route_pointer(&release),
Some(super::PointerRoute::UnusedButton),
"and its release remains nothing, like the right button's"
);
}
/// R4 and R5's resets exist and are SEPARATE, so a mutation that
/// omits one is individually visible.
///