From a65b5c231e869dd95c21cfd4e102f00fb1518ee4 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Thu, 29 Oct 2026 12:21:04 -0700 Subject: [PATCH] feat(komorebic): pan the Scrolling layout with the fingers on a touchpad komorebic pan --stream reads the gestures of fingers on a touchpad from standard input, as whkd writes them for a swipe of three or four fingers (docs/gesture-stream.md in its repository): a line for each begin, move, end and cancel, with the distances in centimetres, and any field or message that it does not know ignored. The row follows the fingers while they are down, and when they lift it comes to rest with a column against an edge of the screen, going at the speed that the times of the moves say that the fingers were. Focus goes to the nearest column in view if the focused one is not. A gesture that is given up, or that is not finished when the stream stops, puts the row back to what it was. komorebic works out nothing but what it is asked to: --sensitivity multiplies the distances, --reversed flips the way that the row goes, and --paging takes a step for every two centimetres that the fingers go, as komorebic pan does, and not move the row with them. Moves that wait while one is told are added up, as whkd says that they may be. A gesture is told to komorebi on one connection, which it handles in order, as messages that are sent on a connection each are not. With ghost movement the pictures that cover the windows near the screen are taken when the fingers go down, a move of the fingers sets them where the row puts the windows and does nothing else, and when the fingers lift they glide to where it comes to rest, over the time that an animation takes, and the windows are shown. Without it the workspace is laid out a step at a time, as it is for a drag. Ten centimetres of the fingers move the row the width of the screen. The golden transcript of whkd and the swipes of its demo are fixtures of the tests that read the stream. --- docs/cli/pan.md | 18 +- docs/usage/scrolling-layout.md | 27 + komorebi-client/src/lib.rs | 26 + komorebi-layouts/src/pan_direction.rs | 16 + komorebi/src/core/mod.rs | 2 + komorebi/src/live_resize.rs | 269 +++++- komorebi/src/pan.rs | 408 ++++++++- komorebi/src/process_command.rs | 10 + komorebi/src/scrolling.rs | 65 ++ .../fixtures/gesture-stream.transcript.txt | 5 + komorebic/fixtures/pan3-flick.transcript.txt | 18 + komorebic/fixtures/pan3-long.transcript.txt | 218 +++++ komorebic/fixtures/pan3-slow.transcript.txt | 67 ++ komorebic/src/main.rs | 54 +- komorebic/src/pan.rs | 809 ++++++++++++++++++ 15 files changed, 2001 insertions(+), 11 deletions(-) create mode 100644 komorebic/fixtures/gesture-stream.transcript.txt create mode 100644 komorebic/fixtures/pan3-flick.transcript.txt create mode 100644 komorebic/fixtures/pan3-long.transcript.txt create mode 100644 komorebic/fixtures/pan3-slow.transcript.txt create mode 100644 komorebic/src/pan.rs diff --git a/docs/cli/pan.md b/docs/cli/pan.md index 748fc0d7..912077d6 100644 --- a/docs/cli/pan.md +++ b/docs/cli/pan.md @@ -1,15 +1,27 @@ ``` -Move the view of the Scrolling layout a column to the left or the right, which does nothing in any other layout +Move the view of the Scrolling layout a column to the left or the right, or with the fingers on a touchpad, which does nothing in any other layout -Usage: pan +Usage: pan [OPTIONS] [DIRECTION] Arguments: - + [DIRECTION] The way that the view goes [possible values: left, right] Options: + --stream + Read the gestures of fingers on a touchpad from standard input, as whkd writes them for a swipe of three or four fingers, and pan with them + + --sensitivity + What the distances of a gesture are multiplied by, which is 1 if it is left out, with --stream + + --reversed + Go the other way to the fingers, with --stream + + --paging + Take a step for every two centimetres that the fingers go, and not move the view with them, with --stream + -h, --help Print help diff --git a/docs/usage/scrolling-layout.md b/docs/usage/scrolling-layout.md index ecf3984a..6abbaa42 100644 --- a/docs/usage/scrolling-layout.md +++ b/docs/usage/scrolling-layout.md @@ -146,6 +146,33 @@ alt + wheeldown : komorebic pan right It does nothing in the other layouts. +### With the fingers on a touchpad + +`komorebic pan --stream` reads the gestures of fingers on a touchpad from standard input and moves the view with them: the +row follows the fingers while they are down, and comes to rest with a column against an edge of the screen when they lift, +at the speed that they were going. [whkd](https://github.com/LGUG2Z/whkd) writes that stream for a swipe of three or +four fingers: + +``` +swipe3 : komorebic pan --stream +``` + +Windows does not give three-finger gestures to programs, so whkd reads the touchpad itself, and Windows Settings should be +set to do nothing for the same gesture. + +- A swipe of 10 centimetres across the touchpad moves the row the width of the screen. `--sensitivity` multiplies that, and + `--reversed` makes the row go the other way to the fingers. +- With `--paging` the row does not follow the fingers: it takes a step, as `komorebic pan` does, for every two + centimetres that they go. +- A gesture that is given up, or whose stream stops, puts the row back to where it was. Vertical movement is not used. +- A swipe with a direction, `swipe3left : komorebic pan left`, runs once when the fingers have gone far enough. It does not + stop a `swipe3` stream on the same fingers: with both bound, the row follows the fingers and the step is taken as well, so + bind one or the other. +- It does nothing in the other layouts. + +The format of the stream is whkd's, and is written down in `docs/gesture-stream.md` in its repository: a line for each +`begin`, `move`, `end` and `cancel`, with the distances in centimetres. Fields that are not known are ignored. + ## Resizing [`komorebic resize-edge`](../cli/resize-edge.md) and [`komorebic resize-axis`](../cli/resize-axis.md) change the width of diff --git a/komorebi-client/src/lib.rs b/komorebi-client/src/lib.rs index 913c27fd..a2eb0a28 100644 --- a/komorebi-client/src/lib.rs +++ b/komorebi-client/src/lib.rs @@ -62,6 +62,7 @@ pub use komorebi::core::MoveBehaviour; pub use komorebi::core::OperationBehaviour; pub use komorebi::core::OperationDirection; pub use komorebi::core::PanDirection; +pub use komorebi::core::PanGesture; pub use komorebi::core::PathExt; pub use komorebi::core::Rect; pub use komorebi::core::Sizing; @@ -105,6 +106,31 @@ pub fn send_message(message: &SocketMessage) -> std::io::Result<()> { stream.write_all(serde_json::to_string(message)?.as_bytes()) } +/// A connection to komorebi that messages are written to one after the other, which komorebi +/// handles in the order that they were written, as it does the lines of a batch. Messages sent +/// with [`send_message`] are each on a connection of their own, which komorebi handles on a +/// thread of its own, so two that are sent together can be handled in either order. +pub struct Connection { + stream: UnixStream, +} + +impl Connection { + pub fn open() -> std::io::Result { + let socket = DATA_DIR.join(KOMOREBI); + let stream = UnixStream::connect(socket)?; + stream.set_write_timeout(Some(Duration::from_secs(1)))?; + + Ok(Self { stream }) + } + + pub fn send(&mut self, message: &SocketMessage) -> std::io::Result<()> { + let mut line = serde_json::to_string(message)?; + line.push('\n'); + + self.stream.write_all(line.as_bytes()) + } +} + pub fn send_batch(messages: impl IntoIterator) -> std::io::Result<()> where Q: Borrow, diff --git a/komorebi-layouts/src/pan_direction.rs b/komorebi-layouts/src/pan_direction.rs index dda06de1..354f54bb 100644 --- a/komorebi-layouts/src/pan_direction.rs +++ b/komorebi-layouts/src/pan_direction.rs @@ -27,6 +27,22 @@ impl PanDirection { } } +/// What the fingers of a gesture that pans the strip do, as far as the strip is concerned +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum PanGesture { + /// The fingers have landed + Begin, + /// The fingers moved by `dx` and `dy` centimetres on the touchpad since the last time, to + /// the right and down if they are positive, at `t` milliseconds on a clock that only the + /// differences between the times of a gesture mean anything on + Move { dx: f64, dy: f64, t: f64 }, + /// The fingers lifted + End, + /// The gesture was given up: the strip goes back to where it was + Cancel, +} + #[cfg(test)] mod tests { use super::*; diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 5c257644..ed2b67a3 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -39,6 +39,7 @@ pub use komorebi_layouts::MAX_RATIOS; pub use komorebi_layouts::MIN_RATIO; pub use komorebi_layouts::OperationDirection; pub use komorebi_layouts::PanDirection; +pub use komorebi_layouts::PanGesture; pub use komorebi_layouts::Rect; pub use komorebi_layouts::ScrollingLayoutOptions; pub use komorebi_layouts::Sizing; @@ -134,6 +135,7 @@ pub enum SocketMessage { Expel, ToggleColumnStack, Pan(PanDirection), + PanGesture(PanGesture), ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf), FlipLayout(Axis), ToggleWorkspaceWindowContainerBehaviour, diff --git a/komorebi/src/live_resize.rs b/komorebi/src/live_resize.rs index 05b9f9ae..c01e7eb1 100644 --- a/komorebi/src/live_resize.rs +++ b/komorebi/src/live_resize.rs @@ -14,13 +14,16 @@ //! it began puts the neighbours back. use crate::WindowsApi; +use crate::animation::ANIMATION_DURATION_GLOBAL; use crate::animation::GHOST_MOVEMENT_ENABLED; use crate::animation::ghost::GhostWindow; use crate::animation::ghost::is_swap_chain_window; use crate::animation::ghost::nudge_swap_chain_window; +use crate::animation::lerp::Lerp; use crate::animation::snapshot; use crate::border_manager; use crate::com::SetCloak; +use crate::core::AnimationStyle; use crate::core::OperationDirection; use crate::core::Rect; use crate::core::Sizing; @@ -58,7 +61,8 @@ impl Snapping { /// `paced` is for the steps of a drag, which leave a window that has not /// got to where it was last sent for the next step: the end of a drag puts /// everything where it is to be - fn new(paced: bool) -> Self { + #[must_use] + pub fn new(paced: bool) -> Self { PACED.store(paced, Ordering::SeqCst); STEPPING.store(true, Ordering::SeqCst); Self { paced } @@ -217,6 +221,9 @@ struct Cover { ghost: GhostWindow, /// Where the picture is at: Rect, + /// Where the picture is when the strip has not been moved, which is not where the window is + /// if it is parked at the edge of the screen + origin: Rect, /// Where the window is to go, which is where the last step put the picture target: Rect, } @@ -232,6 +239,15 @@ pub fn is_covered(hwnd: isize) -> bool { .is_some_and(|covers| covers.contains_key(&hwnd)) } +/// A workspace is about to be laid out a step at a time by something that is not a drag: the +/// pictures of the last of them are taken away, and the windows that were sent somewhere +/// are forgotten. +pub fn begin_steps() { + GENERATION.fetch_add(1, Ordering::SeqCst); + uncover(false); + SENT.lock().take(); +} + /// A window that is to be resized for a step of a drag, from `from` to `to`, and /// whether it is not to be sent there: it is covered. /// @@ -272,6 +288,7 @@ pub fn follow(hwnd: isize, from: &Rect, to: &Rect) -> bool { vacant.insert(Cover { ghost, at: *from, + origin: *from, target: *to, }); } @@ -287,6 +304,12 @@ pub fn follow(hwnd: isize, from: &Rect, to: &Rect) -> bool { }; cover.target = *to; + + // where a glide is to end is all that it is told: it is the glide that moves the picture + if GLIDING.load(Ordering::SeqCst) { + return true; + } + match cover.ghost.update(*to, 255) { Ok(()) => { cover.at = *to; @@ -310,6 +333,181 @@ pub fn follow(hwnd: isize, from: &Rect, to: &Rect) -> bool { true } +/// Whether the covers are to be told where they go and not moved there, as they are moved to +/// it by [`glide`] +static GLIDING: AtomicBool = AtomicBool::new(false); + +/// Covers are told where they go, and not moved there, while this is held +pub struct Gliding; + +impl Gliding { + pub fn begin() -> Self { + GLIDING.store(true, Ordering::SeqCst); + Self + } +} + +impl Drop for Gliding { + fn drop(&mut self) { + GLIDING.store(false, Ordering::SeqCst); + } +} + +/// Where a picture is `progress` of the way (0 to 1) from where it was to where it goes: it +/// slows as it gets there, as the windows of the animations do +fn glide_frame(from: &Rect, to: &Rect, progress: f64) -> Rect { + from.lerp(*to, progress.clamp(0.0, 1.0), AnimationStyle::EaseOutCubic) +} + +/// Move the pictures of the covers from where they are to where their windows are to go, as +/// long as an animation takes, and nothing else is done until they are there. For the windows +/// of a strip that is let go of, which then does not jump to where it comes to rest. +pub fn glide() { + let duration = Duration::from_millis(ANIMATION_DURATION_GLOBAL.load(Ordering::SeqCst)); + + let mut covers = COVERS.lock(); + let Some(covers) = covers.as_mut() else { + return; + }; + + let moving: Vec<_> = covers + .iter() + .filter(|(_, cover)| cover.at != cover.target) + .map(|(hwnd, cover)| (*hwnd, cover.at, cover.target)) + .collect(); + + if moving.is_empty() || duration.is_zero() { + return; + } + + let started = Instant::now(); + + loop { + let progress = started.elapsed().as_secs_f64() / duration.as_secs_f64(); + + for (hwnd, from, to) in &moving { + let rect = glide_frame(from, to, progress.min(1.0)); + + if let Some(cover) = covers.get_mut(hwnd) + && cover.ghost.update(rect, 255).is_ok() + { + cover.at = rect; + border_manager::animate_to(*hwnd, rect); + } + } + + if progress >= 1.0 { + break; + } + + // one frame of the screen at a time, as the animations are + unsafe { + let _ = windows::Win32::Graphics::Dwm::DwmFlush(); + } + } +} + +/// Whether a window at `rect` is near enough to a work area to be seen soon: within a width +/// of it +pub fn is_near(rect: &Rect, work_area: &Rect) -> bool { + // a Rect has a width and a height where it would have a right and a bottom + let width = work_area.right; + rect.left + rect.right > work_area.left - width + && rect.left < work_area.left + work_area.right + width +} + +/// Cover the windows that are given with where their pictures are to be, which is not where +/// they are if they are parked at the edge of the screen, all of them at once, so that the +/// first step of something that moves them does not wait for a picture of each to be taken +/// one after the other. A window that is covered already is left as it is. Only with ghost +/// movement. +pub fn cover_all(windows: &[(isize, Rect)]) { + if !GHOST_MOVEMENT_ENABLED.load(Ordering::Relaxed) { + return; + } + + let wanted: Vec<_> = windows + .iter() + .copied() + .filter(|(hwnd, _)| !is_covered(*hwnd)) + .filter(|(hwnd, _)| { + !crate::HIDDEN_HWNDS.lock().contains(hwnd) + && !WindowsApi::is_window_cloaked(*hwnd).unwrap_or(false) + }) + .collect(); + + let made: Vec<_> = wanted + .into_iter() + .map(|(hwnd, origin)| { + std::thread::spawn(move || { + let rect = WindowsApi::window_rect(hwnd).ok()?; + + // the picture is taken before the window is touched + match GhostWindow::create(hwnd, origin, None) { + Ok(ghost) => Some((hwnd, rect, origin, ghost)), + Err(error) => { + tracing::debug!("cover: no cover for hwnd {hwnd}: {error}"); + None + } + } + }) + }) + .collect(); + + let mut covers = COVERS.lock(); + let covers = covers.get_or_insert_with(HashMap::new); + + for (hwnd, rect, origin, ghost) in made + .into_iter() + .filter_map(|made| made.join().ok().flatten()) + { + border_manager::set_ghosted(hwnd, true); + SetCloak(Window { hwnd }.hwnd(), 1, 2); + + covers.insert( + hwnd, + Cover { + ghost, + at: origin, + origin, + target: rect, + }, + ); + } +} + +/// `rect` moved `shift` pixels to the right, or to the left if it is negative +fn shifted(rect: &Rect, shift: f64) -> Rect { + #[allow(clippy::cast_possible_truncation)] + let by = shift.round() as i32; + + Rect { + left: rect.left + by, + ..*rect + } +} + +/// Put the pictures of the covers where they are when the strip is `shift` pixels from where +/// it was when they were taken, which is all there is to a step of a pan: the windows are not +/// touched and nothing is laid out. Whether there were any. +pub fn shift_covers(shift: f64) -> bool { + let mut covers = COVERS.lock(); + let Some(covers) = covers.as_mut().filter(|covers| !covers.is_empty()) else { + return false; + }; + + for (hwnd, cover) in covers.iter_mut() { + let rect = shifted(&cover.origin, shift); + + if cover.ghost.update(rect, 255).is_ok() { + cover.at = rect; + border_manager::animate_to(*hwnd, rect); + } + } + + true +} + /// Take the covers away. After a drag `show` is true: each window is put where it is /// to go, made to lay itself out there and to build its swap chain again, all of it /// hidden, and then shown, and the picture fades out over it. For another drag, which @@ -342,7 +540,9 @@ pub fn uncover(show: bool) { /// Put a window that was covered where it is to go, show it, and fade its picture out fn reveal(hwnd: isize, cover: Cover) { - let Cover { ghost, at, target } = cover; + let Cover { + ghost, at, target, .. + } = cover; if WindowsApi::is_window(hwnd) { if WindowsApi::position_window(hwnd, &target, false, false).is_ok() { @@ -852,6 +1052,71 @@ impl WindowManager { #[cfg(test)] mod tests { + #[test] + fn a_picture_is_shifted_along_the_screen_and_nowhere_else() { + let rect = Rect { + left: 100, + top: 20, + right: 400, + bottom: 300, + }; + + assert_eq!(super::shifted(&rect, 0.0), rect); + assert_eq!(super::shifted(&rect, 50.4).left, 150); + assert_eq!(super::shifted(&rect, -250.6).left, -151); + assert_eq!(super::shifted(&rect, -250.6).top, 20); + assert_eq!(super::shifted(&rect, 9.0).right, 400); + } + + #[test] + fn a_picture_glides_from_where_it_was_to_where_it_goes_and_slows_as_it_gets_there() { + let from = Rect { + left: 0, + top: 0, + right: 400, + bottom: 300, + }; + let to = Rect { + left: 1000, + top: 100, + right: 400, + bottom: 300, + }; + + assert_eq!(super::glide_frame(&from, &to, 0.0), from); + assert_eq!(super::glide_frame(&from, &to, 1.0), to); + assert_eq!(super::glide_frame(&from, &to, 7.0), to, "no further"); + assert_eq!(super::glide_frame(&from, &to, -1.0), from); + + // more than half of the way in the first half of the time + assert!(super::glide_frame(&from, &to, 0.5).left > 500); + // and the size is not changed on the way when it is not to be + assert_eq!(super::glide_frame(&from, &to, 0.3).right, 400); + } + + #[test] + fn a_window_is_near_a_work_area_within_a_width_of_it() { + let area = Rect { + left: 0, + top: 0, + right: 1000, + bottom: 800, + }; + let at = |left: i32, right: i32| Rect { + left, + top: 0, + right: right - left, + bottom: 800, + }; + + assert!(super::is_near(&at(0, 1000), &area)); + assert!(super::is_near(&at(900, 1900), &area), "just in view"); + assert!(super::is_near(&at(1900, 2900), &area), "a width away"); + assert!(!super::is_near(&at(2100, 3100), &area), "two widths away"); + assert!(super::is_near(&at(-1900, -900), &area)); + assert!(!super::is_near(&at(-3100, -2100), &area)); + } + use super::*; fn rect(left: i32, top: i32, width: i32, height: i32) -> Rect { diff --git a/komorebi/src/pan.rs b/komorebi/src/pan.rs index 2af9fa5e..48e85eb6 100644 --- a/komorebi/src/pan.rs +++ b/komorebi/src/pan.rs @@ -1,9 +1,50 @@ //! Moving the strip of the Scrolling layout across the screen without changing which window has -//! focus, by a step, which is what this has been asked for so far. +//! focus: by a step, and with the fingers on a touchpad. +//! +//! A gesture is a begin, any number of moves, and an end, or a cancel. The strip follows the +//! fingers for as long as they are down, and the workspace is laid out a step at a time as the +//! windows of a drag are, with the pictures that cover them, and when the fingers lift it +//! comes to rest with a column against an edge of the screen. use crate::core::PanDirection; +use crate::core::PanGesture; +use crate::core::Rect; +use crate::live_resize; +use crate::live_resize::Snapping; use crate::window_manager::WindowManager; +use crate::windows_api::WindowsApi; use color_eyre::eyre; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::SwipeTracker; +use parking_lot::Mutex; +use std::time::Instant; + +/// How far the fingers go across a touchpad, in centimetres, to move the strip as far as the +/// width of the screen +const CM_PER_SCREEN: f64 = 10.0; + +/// A gesture that is going on +struct Pan { + monitor_idx: usize, + workspace_idx: usize, + /// What the strip was before the gesture, for putting it back if it is given up + saved: ScrollingSpace, + /// How fast the fingers are going + tracker: SwipeTracker, + /// How far into the strip the screen was when it began + offset: f64, + /// When it began, and the time of the touchpad at the first move, for the log + began: Instant, + first_t: Option, +} + +static PAN: Mutex> = Mutex::new(None); + +/// How far the strip is moved on a screen that is `screen_width` pixels wide by fingers that +/// move `cm` centimetres, in pixels, to the right if it is positive +fn distance_on_screen(cm: f64, screen_width: i32) -> f64 { + cm * f64::from(screen_width) / CM_PER_SCREEN +} impl WindowManager { /// Move the view of the focused workspace one step, a column, to the left or the right, and @@ -27,4 +68,369 @@ impl WindowManager { self.update_focused_workspace(focus_moved && self.mouse_follows_focus, focus_moved) } + + /// What the fingers of a gesture do + pub fn pan_gesture(&mut self, gesture: PanGesture) -> eyre::Result<()> { + match gesture { + PanGesture::Begin => self.pan_begin(), + PanGesture::Move { dx, t, .. } => self.pan_move(dx, t), + PanGesture::End => self.pan_end(), + PanGesture::Cancel => self.pan_cancel(), + } + } + + fn pan_begin(&mut self) -> eyre::Result<()> { + // a gesture that was not finished is given up, as a new one begins + self.pan_cancel()?; + + let monitor_idx = self.focused_monitor_idx(); + let workspace_idx = self.focused_workspace_idx()?; + + let Some(saved) = self.focused_workspace_mut()?.begin_scrolling_pan() else { + return Ok(()); + }; + + live_resize::begin_steps(); + + // the pictures that the strip is moved by are taken now, all at once, and not as the + // first move of the fingers asks for them, one after the other + let work_area = self.focused_monitor_work_area()?; + let workspace = self.focused_workspace()?; + let near: Vec<_> = origins(&workspace.scrolling_lefts(), |hwnd| { + WindowsApi::window_rect(hwnd).ok() + }) + .into_iter() + .filter(|(_, origin)| live_resize::is_near(origin, &work_area)) + .collect(); + let offset = workspace.scrolling_view_offset().unwrap_or_default(); + live_resize::cover_all(&near); + + *PAN.lock() = Some(Pan { + monitor_idx, + workspace_idx, + saved, + tracker: SwipeTracker::new(), + offset, + began: Instant::now(), + first_t: None, + }); + + Ok(()) + } + + fn pan_move(&mut self, dx_cm: f64, t_ms: f64) -> eyre::Result<()> { + let mut guard = PAN.lock(); + let Some(pan) = guard.as_mut() else { + return Ok(()); + }; + + // the workspace that the fingers are on is not the one that is on show any more + if self.focused_monitor_idx() != pan.monitor_idx + || self.focused_workspace_idx()? != pan.workspace_idx + { + drop(guard); + return self.pan_cancel(); + } + + let workspace = self.focused_workspace_mut()?; + let Some(width) = workspace.scrolling_view_width() else { + return Ok(()); + }; + + let dx = distance_on_screen(dx_cm, width); + workspace.pan_scrolling(dx); + pan.tracker.push(t_ms / 1000.0, dx); + + // how far behind the fingers a move is handled is how far the wall clock has got ahead + // of the touchpad's since the first + let first = *pan.first_t.get_or_insert(t_ms); + let fingers = t_ms - first; + let wall = pan.began.elapsed().as_secs_f64() * 1000.0; + tracing::trace!( + "pan: move of {dx:.1} px at {fingers:.0} ms of the fingers, handled at {wall:.0} ms ({:+.0} ms)", + wall - fingers + ); + let offset = workspace.scrolling_view_offset().unwrap_or(pan.offset); + let shift = pan.offset - offset; + drop(guard); + + // the windows are under their pictures, and it is the pictures that go with the fingers + if live_resize::shift_covers(shift) { + return Ok(()); + } + + // without them the workspace is laid out a step at a time + let _snapping = Snapping::new(true); + self.update_focused_workspace(false, false) + } + + fn pan_end(&mut self) -> eyre::Result<()> { + let Some(pan) = PAN.lock().take() else { + return Ok(()); + }; + + if self.focused_monitor_idx() != pan.monitor_idx + || self.focused_workspace_idx()? != pan.workspace_idx + { + return Ok(()); + } + + let focus_moved = self + .focused_workspace_mut()? + .end_scrolling_pan(pan.tracker.velocity()); + + // the end of it is laid out at once, and the windows that were covered are shown when + // this is dropped, but the pictures of them are not sent to where they go: they glide + // there, from where the fingers left them, as the windows of an animation do + let _snapping = Snapping::new(false); + { + let _gliding = live_resize::Gliding::begin(); + self.update_focused_workspace(focus_moved && self.mouse_follows_focus, focus_moved)?; + } + + live_resize::glide(); + Ok(()) + } + + fn pan_cancel(&mut self) -> eyre::Result<()> { + let Some(pan) = PAN.lock().take() else { + return Ok(()); + }; + + if self.focused_monitor_idx() != pan.monitor_idx + || self.focused_workspace_idx()? != pan.workspace_idx + { + return Ok(()); + } + + self.focused_workspace_mut()? + .cancel_scrolling_pan(pan.saved); + + let _snapping = Snapping::new(false); + { + let _gliding = live_resize::Gliding::begin(); + self.update_focused_workspace(false, false)?; + } + + live_resize::glide(); + Ok(()) + } +} + +/// Where the pictures of the windows go when the strip has not been moved: each window's own +/// rectangle, with its left edge where the strip puts it. The strip does not say where the +/// screen is, so the windows that are where it puts them (the ones on the screen) say that, and +/// the rest, which are parked at an edge, are put where they would be. +/// +/// `lefts` is each window with how far its left edge is from that of the screen, and whether +/// it is on the screen, and `rect_of` is where a window is. Nothing is covered if no window is +/// where it is put. +fn origins( + lefts: &[(isize, i32, bool)], + rect_of: impl Fn(isize) -> Option, +) -> Vec<(isize, Rect)> { + let Some(offset) = lefts + .iter() + .filter(|(_, _, on_screen)| *on_screen) + .find_map(|(hwnd, left, _)| Some(rect_of(*hwnd)?.left - left)) + else { + return vec![]; + }; + + lefts + .iter() + .filter_map(|(hwnd, left, _)| { + let rect = rect_of(*hwnd)?; + Some(( + *hwnd, + Rect { + left: left + offset, + ..rect + }, + )) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::SocketMessage; + + #[test] + fn pictures_go_where_the_strip_puts_the_windows_and_parked_windows_are_not_where_they_are() { + let at = |left| Rect { + left, + top: 30, + right: 400, + bottom: 300, + }; + // the strip: one window past the left edge (parked, so not where it is put), two on the + // screen, which starts at 10, and one past the right edge + let lefts = [ + (1, -300, false), + (2, 50, true), + (3, 450, true), + (4, 900, false), + ]; + let rects = |hwnd| match hwnd { + 1 => Some(at(-385)), + 2 => Some(at(60)), + 3 => Some(at(460)), + 4 => Some(at(1205)), + _ => None, + }; + + let origins = origins(&lefts, rects); + let lefts_of: Vec<_> = origins + .iter() + .map(|(hwnd, rect)| (*hwnd, rect.left)) + .collect(); + + assert_eq!(lefts_of, vec![(1, -290), (2, 60), (3, 460), (4, 910)]); + // and nothing else of where they are changes + assert!( + origins + .iter() + .all(|(_, rect)| rect.top == 30 && rect.right == 400) + ); + } + + #[test] + fn nothing_is_covered_when_no_window_is_where_it_is_put() { + let lefts = [(1, -300, false), (2, 900, false)]; + assert!(origins(&lefts, |_| Some(Rect::default())).is_empty()); + // or when the windows cannot be found + assert!(origins(&[(1, 0, true)], |_| None).is_empty()); + } + + #[test] + fn fingers_that_go_across_a_touchpad_move_the_strip_across_the_screen() { + assert!((distance_on_screen(10.0, 5000) - 5000.0).abs() < 1e-9); + assert!((distance_on_screen(1.0, 5000) - 500.0).abs() < 1e-9); + assert!((distance_on_screen(-0.5, 1200) + 60.0).abs() < 1e-9); + assert!(distance_on_screen(0.0, 1200).abs() < f64::EPSILON); + } + + /// The moves of a swipe of about two seconds at about 125 times a second, with a time that + /// is a millisecond or two out for each, as a touchpad gives them, the fingers going at + /// `lift` centimetres a second for the last 200 milliseconds and at `earlier` before that + /// (eased in from rest). Each is how far they went since the one before, and when. + fn swipe(earlier: f64, lift: f64) -> Vec<(f64, f64)> { + let mut moves = Vec::new(); + let mut t = 118_000.0; + + for step in 1..=250 { + let jitter = f64::from((step * 37) % 7 - 3) * 0.5; + let next = 118_000.0 + f64::from(step) * 8.0 + jitter; + let dt = (next - t) / 1000.0; + + let elapsed = f64::from(step) * 8.0; + let speed = if elapsed > 1800.0 { + lift + } else { + earlier * (elapsed / 1800.0) + }; + + moves.push((speed * dt, next)); + t = next; + } + + moves + } + + /// How fast the strip is going when the fingers lift, in centimetres of the fingers a + /// second, for these moves on a screen that is 5000 pixels wide + fn lift_speed(moves: &[(f64, f64)]) -> f64 { + let mut tracker = SwipeTracker::new(); + + for (cm, t) in moves { + tracker.push(t / 1000.0, distance_on_screen(*cm, 5000)); + } + + // a centimetre of the fingers is 500 pixels on that screen + tracker.velocity() / 500.0 + } + + #[test] + fn the_speed_that_the_fingers_lift_at_is_what_the_strip_is_let_go_at() { + // fast, with the time of every move a little out + let speed = lift_speed(&swipe(8.0, 16.0)); + assert!((speed - 16.0).abs() < 0.8, "16 cm/s, got {speed:.2}"); + + // a flick + let speed = lift_speed(&swipe(20.0, 33.0)); + assert!((speed - 33.0).abs() < 1.7, "33 cm/s, got {speed:.2}"); + + // one that eased to a stop and lifts almost still + let speed = lift_speed(&swipe(8.0, 0.2)); + assert!(speed < 0.5, "almost still, got {speed:.2}"); + } + + /// The moves of what whkd writes for a swipe, which is what komorebic tells it of them: how + /// far the fingers went, in centimetres, and when + fn moves_of(transcript: &str) -> Vec<(f64, f64)> { + let field = |line: &str, key: &str| -> Option { + line.split_whitespace() + .find_map(|word| word.strip_prefix(key)?.strip_prefix('=')) + .and_then(|value| value.parse().ok()) + }; + + transcript + .lines() + .filter(|line| line.starts_with("move ")) + .filter_map(|line| Some((field(line, "dx")?, field(line, "t")?))) + .collect() + } + + #[test] + fn the_swipes_that_whkd_writes_lift_at_the_speed_that_they_were_made_to() { + let long = lift_speed(&moves_of(include_str!( + "../../komorebic/fixtures/pan3-long.transcript.txt" + ))); + let flick = lift_speed(&moves_of(include_str!( + "../../komorebic/fixtures/pan3-flick.transcript.txt" + ))); + let slow = lift_speed(&moves_of(include_str!( + "../../komorebic/fixtures/pan3-slow.transcript.txt" + ))); + + // 0.128 cm every 8 ms, and 0.267 every 8 + assert!((long - 16.0).abs() < 0.8, "16 cm/s, got {long:.2}"); + assert!((flick - 33.0).abs() < 1.7, "33 cm/s, got {flick:.2}"); + // slowing from 1.9 cm/s to 0.75 over the last 150 ms, which is what the speed is the + // average of: a nudge, and not a throw + assert!(slow > 1.0 && slow < 2.5, "about 1.6 cm/s, got {slow:.2}"); + assert!(flick > long && long > 5.0 * slow); + } + + #[test] + fn what_has_hardly_moved_is_not_going_anywhere() { + assert!(lift_speed(&[]).abs() < f64::EPSILON); + assert!(lift_speed(&[(1.0, 100.0)]).abs() < f64::EPSILON); + // two moves at the same time have no speed + assert!(lift_speed(&[(1.0, 100.0), (1.0, 100.0)]).abs() < f64::EPSILON); + } + + #[test] + fn a_gesture_goes_over_the_socket_as_it_is() { + for gesture in [ + PanGesture::Begin, + PanGesture::Move { + dx: 0.42, + dy: -0.03, + t: 118_345.0, + }, + PanGesture::End, + PanGesture::Cancel, + ] { + let message = SocketMessage::PanGesture(gesture); + let json = serde_json::to_string(&message).unwrap(); + let SocketMessage::PanGesture(back) = serde_json::from_str(&json).unwrap() else { + panic!("not a gesture: {json}"); + }; + + assert_eq!(back, gesture, "{json}"); + } + } } diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 8fe80a69..d11b276f 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -930,6 +930,7 @@ impl WindowManager { self.update_focused_workspace(false, false)?; } SocketMessage::Pan(direction) => self.pan(direction)?, + SocketMessage::PanGesture(gesture) => self.pan_gesture(gesture)?, SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?, SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?, SocketMessage::LayoutRatios(ref columns, ref rows) => { @@ -2267,6 +2268,15 @@ pub fn read_commands_uds( for line in reader.lines() { let message = SocketMessage::from_str(&line?)?; + // the fingers of a gesture can be still for longer than a command is waited for, and + // what they do next is told on this connection: it is closed when they have lifted + if matches!( + message, + SocketMessage::PanGesture(crate::core::PanGesture::Begin) + ) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(30))); + } + match wm.try_lock_for(Duration::from_secs(1)) { None => { tracing::warn!( diff --git a/komorebi/src/scrolling.rs b/komorebi/src/scrolling.rs index 4a3f32d2..5e07e6b7 100644 --- a/komorebi/src/scrolling.rs +++ b/komorebi/src/scrolling.rs @@ -764,6 +764,47 @@ impl Workspace { } } + /// Give up a pan: the strip is put back to what it was when it began. + pub(crate) fn cancel_scrolling_pan(&mut self, saved: ScrollingSpace) { + if self.scrolling.is_some() { + self.scrolling = Some(saved); + } + } + + /// How far into the strip the left edge of the screen is, in pixels. A strip that is moved + /// by `dx` has this smaller by `dx`. + pub(crate) fn scrolling_view_offset(&self) -> Option { + self.scrolling.as_ref().map(ScrollingSpace::view_offset) + } + + /// Where the strip puts the window that shows in each container, as how far its left edge + /// is from the left edge of the screen, and whether it is on the screen there and so + /// where it really is: windows that are not are parked at an edge. + pub(crate) fn scrolling_lefts(&self) -> Vec<(isize, i32, bool)> { + let Some(space) = self.scrolling.as_ref() else { + return vec![]; + }; + + self.containers() + .iter() + .zip(space.layout()) + .filter_map(|(container, placement)| { + let hwnd = container.focused_window()?.hwnd; + let on_screen = !matches!( + placement.visibility, + Visibility::OffLeft | Visibility::OffRight + ); + + Some((hwnd, placement.rect.x, on_screen)) + }) + .collect() + } + + /// How wide the screen is that the strip is moved across, in pixels. + pub(crate) fn scrolling_view_width(&self) -> Option { + self.scrolling.as_ref().map(|space| space.viewport().width) + } + /// How far one step of a pan goes, if nothing says: a column of the width that a new one /// is given. pub(crate) fn scrolling_pan_step(&self) -> Option { @@ -1173,4 +1214,28 @@ mod tests { workspace.layout_scrolling(&AREA, 0); assert_eq!(workspace.scrolling_pan_step(), Some(300)); } + + #[test] + fn a_pan_that_is_given_up_puts_the_strip_back() { + let mut workspace = workspace(3, 6); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(1); + let before = workspace.layout_scrolling(&AREA, 0); + + let saved = workspace.begin_scrolling_pan().expect("a strip to pan"); + workspace.pan_scrolling(-500.0); + workspace.cancel_scrolling_pan(saved); + + assert_eq!(workspace.layout_scrolling(&AREA, 0), before); + assert_eq!(workspace.focused_container_idx(), 1); + } + + #[test] + fn the_strip_is_moved_across_a_screen_as_wide_as_the_work_area() { + let mut workspace = workspace(3, 6); + assert_eq!(workspace.scrolling_view_width(), None, "no layout yet"); + + workspace.layout_scrolling(&AREA, 0); + assert_eq!(workspace.scrolling_view_width(), Some(1200)); + } } diff --git a/komorebic/fixtures/gesture-stream.transcript.txt b/komorebic/fixtures/gesture-stream.transcript.txt new file mode 100644 index 00000000..faf68116 --- /dev/null +++ b/komorebic/fixtures/gesture-stream.transcript.txt @@ -0,0 +1,5 @@ +begin fingers=3 x=4.000 y=2.000 w=10.500 h=7.200 t=118337 +move dx=0.420 dy=-0.030 x=4.420 y=1.970 t=118345 +move dx=0.510 dy=-0.020 x=4.930 y=1.950 t=118353 +move dx=0.470 dy=0.000 x=5.400 y=1.950 t=118361 +end x=5.400 y=1.950 t=118369 diff --git a/komorebic/fixtures/pan3-flick.transcript.txt b/komorebic/fixtures/pan3-flick.transcript.txt new file mode 100644 index 00000000..906d331a --- /dev/null +++ b/komorebic/fixtures/pan3-flick.transcript.txt @@ -0,0 +1,18 @@ +begin fingers=3 x=5.000 y=3.000 w=11.700 h=7.500 t=100048 +move dx=0.267 dy=0.000 x=5.267 y=3.000 t=100088 +move dx=0.267 dy=0.000 x=5.533 y=3.000 t=100096 +move dx=0.267 dy=0.000 x=5.800 y=3.000 t=100104 +move dx=0.267 dy=0.000 x=6.067 y=3.000 t=100112 +move dx=0.267 dy=0.000 x=6.333 y=3.000 t=100120 +move dx=0.267 dy=0.000 x=6.600 y=3.000 t=100128 +swipe swipe3right +move dx=0.267 dy=0.000 x=6.867 y=3.000 t=100136 +move dx=0.267 dy=0.000 x=7.133 y=3.000 t=100144 +move dx=0.267 dy=0.000 x=7.400 y=3.000 t=100152 +move dx=0.267 dy=0.000 x=7.667 y=3.000 t=100160 +move dx=0.267 dy=0.000 x=7.933 y=3.000 t=100168 +move dx=0.267 dy=0.000 x=8.200 y=3.000 t=100176 +move dx=0.267 dy=0.000 x=8.467 y=3.000 t=100184 +move dx=0.267 dy=0.000 x=8.733 y=3.000 t=100192 +move dx=0.267 dy=0.000 x=9.000 y=3.000 t=100200 +end x=9.000 y=3.000 t=100208 diff --git a/komorebic/fixtures/pan3-long.transcript.txt b/komorebic/fixtures/pan3-long.transcript.txt new file mode 100644 index 00000000..eb2a7b0c --- /dev/null +++ b/komorebic/fixtures/pan3-long.transcript.txt @@ -0,0 +1,218 @@ +begin fingers=3 x=5.000 y=3.000 w=11.700 h=7.500 t=100048 +move dx=0.007 dy=0.000 x=5.015 y=3.001 t=100110 +move dx=0.008 dy=0.000 x=5.023 y=3.001 t=100119 +move dx=0.010 dy=0.001 x=5.033 y=3.002 t=100129 +move dx=0.012 dy=0.001 x=5.045 y=3.003 t=100136 +move dx=0.014 dy=0.001 x=5.059 y=3.004 t=100142 +move dx=0.015 dy=0.001 x=5.074 y=3.004 t=100153 +move dx=0.017 dy=0.001 x=5.091 y=3.005 t=100160 +move dx=0.018 dy=0.001 x=5.109 y=3.007 t=100168 +move dx=0.020 dy=0.001 x=5.129 y=3.008 t=100174 +move dx=0.022 dy=0.001 x=5.151 y=3.009 t=100184 +move dx=0.023 dy=0.001 x=5.174 y=3.010 t=100193 +move dx=0.025 dy=0.001 x=5.199 y=3.012 t=100199 +move dx=0.026 dy=0.002 x=5.225 y=3.013 t=100207 +move dx=0.027 dy=0.002 x=5.252 y=3.015 t=100217 +move dx=0.029 dy=0.002 x=5.281 y=3.017 t=100223 +move dx=0.030 dy=0.002 x=5.311 y=3.019 t=100231 +move dx=0.032 dy=0.002 x=5.343 y=3.021 t=100240 +move dx=0.033 dy=0.002 x=5.376 y=3.023 t=100248 +move dx=0.034 dy=0.002 x=5.410 y=3.025 t=100256 +move dx=0.035 dy=0.002 x=5.446 y=3.027 t=100262 +move dx=0.037 dy=0.002 x=5.482 y=3.029 t=100272 +move dx=0.038 dy=0.002 x=5.520 y=3.031 t=100281 +move dx=0.039 dy=0.002 x=5.559 y=3.034 t=100287 +move dx=0.040 dy=0.002 x=5.599 y=3.036 t=100294 +move dx=0.041 dy=0.002 x=5.640 y=3.038 t=100303 +move dx=0.042 dy=0.003 x=5.682 y=3.041 t=100312 +move dx=0.043 dy=0.003 x=5.726 y=3.044 t=100318 +move dx=0.044 dy=0.003 x=5.770 y=3.046 t=100327 +move dx=0.045 dy=0.003 x=5.815 y=3.049 t=100334 +move dx=0.046 dy=0.003 x=5.861 y=3.052 t=100342 +move dx=0.047 dy=0.003 x=5.909 y=3.055 t=100351 +move dx=0.048 dy=0.003 x=5.956 y=3.057 t=100360 +move dx=0.049 dy=0.003 x=6.005 y=3.060 t=100369 +move dx=0.050 dy=0.003 x=6.055 y=3.063 t=100375 +move dx=0.050 dy=0.003 x=6.105 y=3.066 t=100383 +move dx=0.051 dy=0.003 x=6.156 y=3.069 t=100390 +move dx=0.052 dy=0.003 x=6.208 y=3.072 t=100400 +move dx=0.053 dy=0.003 x=6.261 y=3.076 t=100406 +move dx=0.053 dy=0.003 x=6.314 y=3.079 t=100414 +move dx=0.054 dy=0.003 x=6.368 y=3.082 t=100423 +move dx=0.054 dy=0.003 x=6.422 y=3.085 t=100431 +move dx=0.055 dy=0.003 x=6.477 y=3.089 t=100439 +move dx=0.056 dy=0.003 x=6.533 y=3.092 t=100448 +swipe swipe3right +move dx=0.056 dy=0.003 x=6.589 y=3.095 t=100454 +move dx=0.057 dy=0.003 x=6.646 y=3.099 t=100464 +move dx=0.057 dy=0.003 x=6.703 y=3.102 t=100472 +move dx=0.057 dy=0.003 x=6.760 y=3.106 t=100481 +move dx=0.058 dy=0.003 x=6.818 y=3.109 t=100487 +move dx=0.058 dy=0.003 x=6.876 y=3.113 t=100497 +move dx=0.058 dy=0.004 x=6.934 y=3.116 t=100504 +move dx=0.059 dy=0.004 x=6.993 y=3.120 t=100511 +move dx=0.059 dy=0.004 x=7.052 y=3.123 t=100520 +move dx=0.059 dy=0.004 x=7.111 y=3.127 t=100527 +move dx=0.059 dy=0.004 x=7.171 y=3.130 t=100535 +move dx=0.060 dy=0.004 x=7.230 y=3.134 t=100543 +move dx=0.060 dy=0.004 x=7.290 y=3.137 t=100552 +move dx=0.060 dy=0.004 x=7.350 y=3.141 t=100561 +move dx=0.060 dy=0.004 x=7.410 y=3.145 t=100567 +move dx=0.060 dy=0.004 x=7.470 y=3.148 t=100576 +move dx=0.060 dy=0.004 x=7.530 y=3.152 t=100584 +move dx=0.060 dy=0.004 x=7.590 y=3.155 t=100591 +move dx=0.060 dy=0.004 x=7.650 y=3.159 t=100600 +move dx=0.060 dy=0.004 x=7.710 y=3.163 t=100609 +move dx=0.060 dy=0.004 x=7.770 y=3.166 t=100615 +move dx=0.060 dy=0.004 x=7.829 y=3.170 t=100624 +move dx=0.059 dy=0.004 x=7.889 y=3.173 t=100632 +move dx=0.059 dy=0.004 x=7.948 y=3.177 t=100640 +move dx=0.059 dy=0.004 x=8.007 y=3.180 t=100648 +move dx=0.059 dy=0.004 x=8.066 y=3.184 t=100654 +move dx=0.058 dy=0.004 x=8.124 y=3.187 t=100664 +move dx=0.058 dy=0.003 x=8.182 y=3.191 t=100672 +move dx=0.058 dy=0.003 x=8.240 y=3.194 t=100680 +move dx=0.057 dy=0.003 x=8.297 y=3.198 t=100688 +move dx=0.057 dy=0.003 x=8.354 y=3.201 t=100694 +move dx=0.057 dy=0.003 x=8.411 y=3.205 t=100703 +move dx=0.056 dy=0.003 x=8.467 y=3.208 t=100712 +move dx=0.056 dy=0.003 x=8.523 y=3.211 t=100718 +move dx=0.055 dy=0.003 x=8.578 y=3.215 t=100728 +move dx=0.054 dy=0.003 x=8.632 y=3.218 t=100735 +move dx=0.054 dy=0.003 x=8.686 y=3.221 t=100744 +move dx=0.053 dy=0.003 x=8.739 y=3.224 t=100752 +move dx=0.053 dy=0.003 x=8.792 y=3.228 t=100760 +move dx=0.052 dy=0.003 x=8.844 y=3.231 t=100769 +move dx=0.051 dy=0.003 x=8.895 y=3.234 t=100774 +move dx=0.050 dy=0.003 x=8.945 y=3.237 t=100785 +move dx=0.050 dy=0.003 x=8.995 y=3.240 t=100792 +move dx=0.049 dy=0.003 x=9.044 y=3.243 t=100799 +move dx=0.048 dy=0.003 x=9.091 y=3.245 t=100809 +move dx=0.047 dy=0.003 x=9.139 y=3.248 t=100815 +move dx=0.046 dy=0.003 x=9.185 y=3.251 t=100824 +move dx=0.045 dy=0.003 x=9.230 y=3.254 t=100832 +move dx=0.044 dy=0.003 x=9.274 y=3.256 t=100839 +move dx=0.043 dy=0.003 x=9.318 y=3.259 t=100846 +move dx=0.042 dy=0.003 x=9.360 y=3.262 t=100856 +move dx=0.041 dy=0.002 x=9.401 y=3.264 t=100863 +move dx=0.040 dy=0.002 x=9.441 y=3.266 t=100873 +move dx=0.039 dy=0.002 x=9.480 y=3.269 t=100880 +move dx=0.038 dy=0.002 x=9.518 y=3.271 t=100887 +move dx=0.037 dy=0.002 x=9.554 y=3.273 t=100897 +move dx=0.035 dy=0.002 x=9.590 y=3.275 t=100904 +move dx=0.034 dy=0.002 x=9.624 y=3.277 t=100912 +move dx=0.033 dy=0.002 x=9.657 y=3.279 t=100918 +move dx=0.032 dy=0.002 x=9.689 y=3.281 t=100929 +move dx=0.030 dy=0.002 x=9.719 y=3.283 t=100935 +move dx=0.029 dy=0.002 x=9.748 y=3.285 t=100944 +move dx=0.027 dy=0.002 x=9.775 y=3.287 t=100952 +move dx=0.026 dy=0.002 x=9.801 y=3.288 t=100959 +move dx=0.025 dy=0.001 x=9.826 y=3.290 t=100968 +move dx=0.023 dy=0.001 x=9.849 y=3.291 t=100974 +move dx=0.022 dy=0.001 x=9.871 y=3.292 t=100983 +move dx=0.020 dy=0.001 x=9.891 y=3.293 t=100991 +move dx=0.018 dy=0.001 x=9.909 y=3.295 t=101001 +move dx=0.017 dy=0.001 x=9.926 y=3.296 t=101008 +move dx=0.015 dy=0.001 x=9.941 y=3.296 t=101016 +move dx=0.014 dy=0.001 x=9.955 y=3.297 t=101023 +move dx=0.012 dy=0.001 x=9.967 y=3.298 t=101033 +move dx=0.010 dy=0.001 x=9.977 y=3.299 t=101039 +move dx=0.008 dy=0.000 x=9.985 y=3.299 t=101048 +move dx=0.007 dy=0.000 x=9.991 y=3.299 t=101056 +move dx=-0.005 dy=-0.001 x=9.991 y=3.299 t=101105 +move dx=-0.007 dy=-0.001 x=9.984 y=3.298 t=101110 +move dx=-0.009 dy=-0.001 x=9.975 y=3.297 t=101121 +move dx=-0.011 dy=-0.001 x=9.964 y=3.296 t=101126 +move dx=-0.013 dy=-0.001 x=9.951 y=3.295 t=101135 +move dx=-0.014 dy=-0.001 x=9.937 y=3.294 t=101143 +move dx=-0.016 dy=-0.002 x=9.921 y=3.292 t=101151 +move dx=-0.018 dy=-0.002 x=9.903 y=3.290 t=101159 +move dx=-0.019 dy=-0.002 x=9.884 y=3.288 t=101168 +move dx=-0.021 dy=-0.002 x=9.863 y=3.286 t=101177 +move dx=-0.022 dy=-0.002 x=9.841 y=3.284 t=101182 +move dx=-0.024 dy=-0.002 x=9.817 y=3.282 t=101192 +move dx=-0.025 dy=-0.002 x=9.792 y=3.279 t=101198 +move dx=-0.026 dy=-0.003 x=9.766 y=3.277 t=101208 +move dx=-0.027 dy=-0.003 x=9.738 y=3.274 t=101216 +move dx=-0.029 dy=-0.003 x=9.710 y=3.271 t=101222 +move dx=-0.030 dy=-0.003 x=9.680 y=3.268 t=101231 +move dx=-0.031 dy=-0.003 x=9.649 y=3.265 t=101240 +move dx=-0.032 dy=-0.003 x=9.617 y=3.262 t=101247 +move dx=-0.033 dy=-0.003 x=9.585 y=3.258 t=101254 +move dx=-0.034 dy=-0.003 x=9.551 y=3.255 t=101265 +move dx=-0.034 dy=-0.003 x=9.517 y=3.252 t=101273 +move dx=-0.035 dy=-0.004 x=9.481 y=3.248 t=101280 +move dx=-0.036 dy=-0.004 x=9.446 y=3.245 t=101287 +move dx=-0.037 dy=-0.004 x=9.409 y=3.241 t=101295 +move dx=-0.037 dy=-0.004 x=9.372 y=3.237 t=101304 +move dx=-0.038 dy=-0.004 x=9.334 y=3.233 t=101313 +move dx=-0.038 dy=-0.004 x=9.296 y=3.230 t=101319 +move dx=-0.039 dy=-0.004 x=9.257 y=3.226 t=101328 +move dx=-0.039 dy=-0.004 x=9.218 y=3.222 t=101335 +move dx=-0.039 dy=-0.004 x=9.179 y=3.218 t=101342 +move dx=-0.040 dy=-0.004 x=9.140 y=3.214 t=101353 +move dx=-0.040 dy=-0.004 x=9.100 y=3.210 t=101359 +move dx=-0.040 dy=-0.004 x=9.060 y=3.206 t=101367 +move dx=-0.040 dy=-0.004 x=9.020 y=3.202 t=101375 +move dx=-0.040 dy=-0.004 x=8.980 y=3.198 t=101384 +move dx=-0.040 dy=-0.004 x=8.940 y=3.194 t=101390 +move dx=-0.040 dy=-0.004 x=8.900 y=3.190 t=101399 +move dx=-0.040 dy=-0.004 x=8.860 y=3.186 t=101408 +move dx=-0.040 dy=-0.004 x=8.821 y=3.182 t=101417 +move dx=-0.039 dy=-0.004 x=8.782 y=3.178 t=101425 +move dx=-0.039 dy=-0.004 x=8.743 y=3.174 t=101430 +move dx=-0.039 dy=-0.004 x=8.704 y=3.170 t=101441 +move dx=-0.038 dy=-0.004 x=8.666 y=3.167 t=101448 +move dx=-0.038 dy=-0.004 x=8.628 y=3.163 t=101457 +move dx=-0.037 dy=-0.004 x=8.591 y=3.159 t=101465 +move dx=-0.037 dy=-0.004 x=8.554 y=3.155 t=101470 +move dx=-0.036 dy=-0.004 x=8.519 y=3.152 t=101479 +move dx=-0.035 dy=-0.004 x=8.483 y=3.148 t=101488 +move dx=-0.034 dy=-0.003 x=8.449 y=3.145 t=101495 +move dx=-0.034 dy=-0.003 x=8.415 y=3.142 t=101505 +move dx=-0.033 dy=-0.003 x=8.383 y=3.138 t=101511 +move dx=-0.032 dy=-0.003 x=8.351 y=3.135 t=101520 +move dx=-0.031 dy=-0.003 x=8.320 y=3.132 t=101526 +move dx=-0.030 dy=-0.003 x=8.290 y=3.129 t=101537 +move dx=-0.029 dy=-0.003 x=8.262 y=3.126 t=101545 +move dx=-0.027 dy=-0.003 x=8.234 y=3.123 t=101553 +move dx=-0.026 dy=-0.003 x=8.208 y=3.121 t=101559 +move dx=-0.025 dy=-0.002 x=8.183 y=3.118 t=101567 +move dx=-0.024 dy=-0.002 x=8.159 y=3.116 t=101577 +move dx=-0.022 dy=-0.002 x=8.137 y=3.114 t=101584 +move dx=-0.021 dy=-0.002 x=8.116 y=3.112 t=101591 +move dx=-0.019 dy=-0.002 x=8.097 y=3.110 t=101600 +move dx=-0.018 dy=-0.002 x=8.079 y=3.108 t=101606 +move dx=-0.016 dy=-0.002 x=8.063 y=3.106 t=101615 +move dx=-0.014 dy=-0.001 x=8.049 y=3.105 t=101624 +move dx=-0.013 dy=-0.001 x=8.036 y=3.104 t=101633 +move dx=-0.011 dy=-0.001 x=8.025 y=3.103 t=101640 +move dx=-0.009 dy=-0.001 x=8.016 y=3.102 t=101647 +move dx=-0.007 dy=-0.001 x=8.009 y=3.101 t=101656 +move dx=-0.005 dy=-0.001 x=8.004 y=3.100 t=101663 +move dx=0.128 dy=0.000 x=8.128 y=3.100 t=101688 +move dx=0.128 dy=0.000 x=8.256 y=3.100 t=101696 +move dx=0.128 dy=0.000 x=8.384 y=3.100 t=101705 +move dx=0.128 dy=0.000 x=8.512 y=3.100 t=101713 +move dx=0.128 dy=0.000 x=8.640 y=3.100 t=101721 +move dx=0.128 dy=0.000 x=8.768 y=3.100 t=101728 +move dx=0.128 dy=0.000 x=8.896 y=3.100 t=101735 +move dx=0.128 dy=0.000 x=9.024 y=3.100 t=101743 +move dx=0.128 dy=0.000 x=9.152 y=3.100 t=101751 +move dx=0.128 dy=0.000 x=9.280 y=3.100 t=101759 +move dx=0.128 dy=0.000 x=9.408 y=3.100 t=101769 +move dx=0.128 dy=0.000 x=9.536 y=3.100 t=101774 +move dx=0.128 dy=0.000 x=9.664 y=3.100 t=101783 +move dx=0.128 dy=0.000 x=9.792 y=3.100 t=101792 +move dx=0.128 dy=0.000 x=9.920 y=3.100 t=101800 +move dx=0.128 dy=0.000 x=10.048 y=3.100 t=101809 +move dx=0.128 dy=0.000 x=10.176 y=3.100 t=101814 +move dx=0.128 dy=0.000 x=10.304 y=3.100 t=101822 +move dx=0.128 dy=0.000 x=10.432 y=3.100 t=101831 +move dx=0.128 dy=0.000 x=10.560 y=3.100 t=101841 +move dx=0.128 dy=0.000 x=10.688 y=3.100 t=101847 +move dx=0.128 dy=0.000 x=10.816 y=3.100 t=101856 +move dx=0.128 dy=0.000 x=10.944 y=3.100 t=101864 +move dx=0.128 dy=0.000 x=11.072 y=3.100 t=101872 +move dx=0.128 dy=0.000 x=11.200 y=3.100 t=101880 +end x=11.200 y=3.100 t=101888 diff --git a/komorebic/fixtures/pan3-slow.transcript.txt b/komorebic/fixtures/pan3-slow.transcript.txt new file mode 100644 index 00000000..490b91e5 --- /dev/null +++ b/komorebic/fixtures/pan3-slow.transcript.txt @@ -0,0 +1,67 @@ +begin fingers=3 x=5.000 y=3.000 w=11.700 h=7.500 t=100048 +move dx=0.006 dy=0.001 x=5.020 y=3.002 t=100128 +move dx=0.007 dy=0.001 x=5.027 y=3.002 t=100136 +move dx=0.008 dy=0.001 x=5.035 y=3.003 t=100144 +move dx=0.009 dy=0.001 x=5.044 y=3.004 t=100152 +move dx=0.010 dy=0.001 x=5.053 y=3.005 t=100160 +move dx=0.011 dy=0.001 x=5.064 y=3.006 t=100168 +move dx=0.011 dy=0.001 x=5.075 y=3.007 t=100176 +move dx=0.012 dy=0.001 x=5.088 y=3.008 t=100184 +move dx=0.013 dy=0.001 x=5.101 y=3.009 t=100192 +move dx=0.014 dy=0.001 x=5.114 y=3.010 t=100200 +move dx=0.014 dy=0.001 x=5.129 y=3.012 t=100208 +move dx=0.015 dy=0.001 x=5.144 y=3.013 t=100216 +move dx=0.016 dy=0.001 x=5.160 y=3.015 t=100224 +move dx=0.016 dy=0.001 x=5.176 y=3.016 t=100232 +move dx=0.017 dy=0.002 x=5.193 y=3.018 t=100240 +move dx=0.017 dy=0.002 x=5.210 y=3.019 t=100248 +move dx=0.018 dy=0.002 x=5.228 y=3.021 t=100256 +move dx=0.018 dy=0.002 x=5.247 y=3.022 t=100264 +move dx=0.019 dy=0.002 x=5.266 y=3.024 t=100272 +move dx=0.019 dy=0.002 x=5.285 y=3.026 t=100280 +move dx=0.020 dy=0.002 x=5.305 y=3.028 t=100288 +move dx=0.020 dy=0.002 x=5.325 y=3.030 t=100296 +move dx=0.020 dy=0.002 x=5.345 y=3.031 t=100304 +move dx=0.021 dy=0.002 x=5.366 y=3.033 t=100312 +move dx=0.021 dy=0.002 x=5.387 y=3.035 t=100320 +move dx=0.021 dy=0.002 x=5.408 y=3.037 t=100328 +move dx=0.021 dy=0.002 x=5.430 y=3.039 t=100336 +move dx=0.022 dy=0.002 x=5.451 y=3.041 t=100344 +move dx=0.022 dy=0.002 x=5.473 y=3.043 t=100352 +move dx=0.022 dy=0.002 x=5.495 y=3.045 t=100360 +move dx=0.022 dy=0.002 x=5.517 y=3.047 t=100368 +move dx=0.022 dy=0.002 x=5.539 y=3.049 t=100376 +move dx=0.022 dy=0.002 x=5.561 y=3.051 t=100384 +move dx=0.022 dy=0.002 x=5.583 y=3.053 t=100392 +move dx=0.022 dy=0.002 x=5.605 y=3.055 t=100400 +move dx=0.022 dy=0.002 x=5.627 y=3.057 t=100408 +move dx=0.022 dy=0.002 x=5.649 y=3.059 t=100416 +move dx=0.022 dy=0.002 x=5.670 y=3.061 t=100424 +move dx=0.021 dy=0.002 x=5.692 y=3.063 t=100432 +move dx=0.021 dy=0.002 x=5.713 y=3.065 t=100440 +move dx=0.021 dy=0.002 x=5.734 y=3.067 t=100448 +move dx=0.021 dy=0.002 x=5.755 y=3.069 t=100456 +move dx=0.020 dy=0.002 x=5.775 y=3.070 t=100464 +move dx=0.020 dy=0.002 x=5.795 y=3.072 t=100472 +move dx=0.020 dy=0.002 x=5.815 y=3.074 t=100480 +move dx=0.019 dy=0.002 x=5.834 y=3.076 t=100488 +move dx=0.019 dy=0.002 x=5.853 y=3.078 t=100496 +move dx=0.018 dy=0.002 x=5.872 y=3.079 t=100504 +move dx=0.018 dy=0.002 x=5.890 y=3.081 t=100512 +move dx=0.017 dy=0.002 x=5.907 y=3.082 t=100520 +move dx=0.017 dy=0.002 x=5.924 y=3.084 t=100528 +move dx=0.016 dy=0.001 x=5.940 y=3.085 t=100536 +move dx=0.016 dy=0.001 x=5.956 y=3.087 t=100544 +move dx=0.015 dy=0.001 x=5.971 y=3.088 t=100552 +move dx=0.014 dy=0.001 x=5.986 y=3.090 t=100560 +move dx=0.014 dy=0.001 x=5.999 y=3.091 t=100568 +move dx=0.013 dy=0.001 x=6.012 y=3.092 t=100576 +move dx=0.012 dy=0.001 x=6.025 y=3.093 t=100584 +move dx=0.011 dy=0.001 x=6.036 y=3.094 t=100592 +move dx=0.011 dy=0.001 x=6.047 y=3.095 t=100600 +move dx=0.010 dy=0.001 x=6.056 y=3.096 t=100608 +move dx=0.009 dy=0.001 x=6.065 y=3.097 t=100616 +move dx=0.008 dy=0.001 x=6.073 y=3.098 t=100624 +move dx=0.007 dy=0.001 x=6.080 y=3.098 t=100632 +move dx=0.006 dy=0.001 x=6.086 y=3.099 t=100640 +end x=6.100 y=3.100 t=100688 diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index c1abe65d..e5fa9f8b 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -6,6 +6,8 @@ use chrono::Utc; use komorebi_client::PathExt; use komorebi_client::replace_env_in_path; use komorebi_client::splash; +mod pan; + use std::fs::File; use std::fs::OpenOptions; use std::io; @@ -1013,8 +1015,27 @@ struct ScrollingLayoutColumns { #[derive(Parser)] struct Pan { /// The way that the view goes - #[clap(value_enum)] - direction: PanDirection, + #[clap( + value_enum, + required_unless_present = "stream", + conflicts_with = "stream" + )] + direction: Option, + /// Read the gestures of fingers on a touchpad from standard input, as whkd writes them for + /// a swipe of three or four fingers, and pan with them + #[clap(long)] + stream: bool, + /// What the distances of a gesture are multiplied by, which is 1 if it is left out, with + /// --stream + #[clap(long)] + sensitivity: Option, + /// Go the other way to the fingers, with --stream + #[clap(long)] + reversed: bool, + /// Take a step for every two centimetres that the fingers go, and not move the view with + /// them, with --stream + #[clap(long)] + paging: bool, } #[derive(Parser)] @@ -1310,8 +1331,8 @@ enum SubCommand { /// Stack the windows of the focused column of the Scrolling layout, or spread the focused /// stack out into a column with a window in each slot ToggleColumnStack, - /// Move the view of the Scrolling layout a column to the left or the right, which does - /// nothing in any other layout + /// Move the view of the Scrolling layout a column to the left or the right, or with the + /// fingers on a touchpad, which does nothing in any other layout #[clap(arg_required_else_help = true)] Pan(Pan), /// Set the layout column and row ratios for the focused workspace @@ -3005,7 +3026,30 @@ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { send_message(&SocketMessage::ToggleColumnStack)?; } SubCommand::Pan(args) => { - send_message(&SocketMessage::Pan(args.direction))?; + // a flag that has a default counts as given, so what only goes with --stream + // cannot be left to the parser to say + if !args.stream && (args.sensitivity.is_some() || args.reversed || args.paging) { + Opts::command() + .error( + clap::error::ErrorKind::ArgumentConflict, + "--sensitivity, --reversed and --paging can only be used with --stream", + ) + .exit(); + } + + if args.stream { + pan::run( + BufReader::new(io::stdin()), + pan::Options { + sensitivity: args.sensitivity.unwrap_or(1.0), + reversed: args.reversed, + paging: args.paging, + }, + &mut pan::Socket::new(komorebi_client::Connection::open), + )?; + } else if let Some(direction) = args.direction { + send_message(&SocketMessage::Pan(direction))?; + } } SubCommand::LayoutRatios(args) => { if args.columns.is_none() && args.rows.is_none() { diff --git a/komorebic/src/pan.rs b/komorebic/src/pan.rs new file mode 100644 index 00000000..530f33ab --- /dev/null +++ b/komorebic/src/pan.rs @@ -0,0 +1,809 @@ +//! Reading the stream of gestures that `komorebic pan --stream` is given, which whkd writes for a +//! swipe of three or four fingers on a touchpad, and telling komorebi what the fingers do. +//! +//! The format is whkd's, and is written down in `docs/gesture-stream.md` in its repository: one +//! message to a line, a name and then `key=value` fields in any order, the distances in +//! centimetres. A field that is not known is ignored, and so is a message that is not known. + +use komorebi_client::PanDirection; +use komorebi_client::PanGesture; +use komorebi_client::SocketMessage; +use std::collections::VecDeque; +use std::io; +use std::io::BufRead; +use std::sync::mpsc; + +/// How far the fingers go with `--paging` for each step, in centimetres +pub const PAGE_CM: f64 = 2.0; + +/// One thing that the fingers do +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Message { + Begin, + Move { dx: f64, dy: f64, t: f64 }, + End, + Cancel, +} + +/// What a line of the stream comes to +#[derive(Clone, Debug, PartialEq)] +pub enum Line { + Message(Message), + /// Nothing to do: a line with nothing in it, or a comment, or a message that is not known + Ignored, + /// A message that is known, and is not what it should be + Bad(String), +} + +/// What a line of the stream says +#[must_use] +pub fn parse_line(line: &str) -> Line { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return Line::Ignored; + } + + let mut words = line.split_whitespace(); + let Some(name) = words.next() else { + return Line::Ignored; + }; + + let fields: Vec<(&str, &str)> = words.filter_map(|word| word.split_once('=')).collect(); + let number = |key: &str| -> Option> { + fields + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| { + value + .parse::() + .ok() + .filter(|number| number.is_finite()) + .ok_or_else(|| format!("{key}={value} is not a number")) + }) + }; + + match name { + "begin" => Line::Message(Message::Begin), + "end" => Line::Message(Message::End), + "cancel" => Line::Message(Message::Cancel), + "move" => { + let dx = match number("dx") { + Some(Ok(dx)) => dx, + Some(Err(error)) => return Line::Bad(error), + None => return Line::Bad(String::from("a move has no dx")), + }; + let t = match number("t") { + Some(Ok(t)) => t, + Some(Err(error)) => return Line::Bad(error), + None => return Line::Bad(String::from("a move has no t")), + }; + let dy = match number("dy") { + Some(Ok(dy)) => dy, + Some(Err(error)) => return Line::Bad(error), + None => 0.0, + }; + + Line::Message(Message::Move { dx, dy, t }) + } + _ => Line::Ignored, + } +} + +/// What `--stream` is asked to do with the distances +#[derive(Clone, Copy, Debug)] +pub struct Options { + /// What the distances are multiplied by + pub sensitivity: f64, + /// The strip goes the other way to the fingers + pub reversed: bool, + /// One step for each [`PAGE_CM`] that the fingers go, and not the strip going with them + pub paging: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + sensitivity: 1.0, + reversed: false, + paging: false, + } + } +} + +/// Works out what komorebi is told for what the fingers do +#[derive(Debug)] +pub struct Forwarder { + options: Options, + /// Whether a gesture is going on + active: bool, + /// How far the fingers have gone since the last step, with paging + page: f64, +} + +impl Forwarder { + #[must_use] + pub const fn new(options: Options) -> Self { + Self { + options, + active: false, + page: 0.0, + } + } + + /// What komorebi is told for this. Fingers that move when there was no beginning, and an end + /// or a cancel for a gesture that is not going on, are not anything. + pub fn handle(&mut self, message: Message) -> Vec { + let gesture = |gesture| vec![SocketMessage::PanGesture(gesture)]; + + match message { + Message::Begin => { + self.active = true; + self.page = 0.0; + + if self.options.paging { + Vec::new() + } else { + gesture(PanGesture::Begin) + } + } + Message::Move { dx, dy, t } if self.active => { + let sign = if self.options.reversed { -1.0 } else { 1.0 }; + let dx = dx * self.options.sensitivity * sign; + let dy = dy * self.options.sensitivity; + + if self.options.paging { + self.page += dx; + self.pages() + } else { + gesture(PanGesture::Move { dx, dy, t }) + } + } + Message::End if self.active => { + self.active = false; + + if self.options.paging { + Vec::new() + } else { + gesture(PanGesture::End) + } + } + Message::Cancel if self.active => { + self.active = false; + + if self.options.paging { + Vec::new() + } else { + gesture(PanGesture::Cancel) + } + } + _ => Vec::new(), + } + } + + /// The stream is over: a gesture that is still going on was not finished, so it is given up + pub fn finish(&mut self) -> Vec { + let unfinished = self.active && !self.options.paging; + self.active = false; + + if unfinished { + vec![SocketMessage::PanGesture(PanGesture::Cancel)] + } else { + Vec::new() + } + } + + /// The steps that what the fingers have gone since the last one comes to. Fingers that go to + /// the right take the strip with them, which is to look to the left. + fn pages(&mut self) -> Vec { + let mut steps = Vec::new(); + + while self.page.abs() >= PAGE_CM { + let direction = if self.page > 0.0 { + PanDirection::Left + } else { + PanDirection::Right + }; + + self.page -= PAGE_CM.copysign(self.page); + steps.push(SocketMessage::Pan(direction)); + } + + steps + } +} + +/// The next message to handle, with the moves that follow it added to it if it is one: when +/// the stream comes faster than it can be handled, the distances add up and the time is the +/// last one, as whkd says that they may. +pub fn next_merged(queue: &mut VecDeque) -> Option { + let first = queue.pop_front()?; + let Message::Move { + mut dx, + mut dy, + mut t, + } = first + else { + return Some(first); + }; + + while let Some(Message::Move { + dx: more_dx, + dy: more_dy, + t: later, + }) = queue.front().copied() + { + dx += more_dx; + dy += more_dy; + t = later; + queue.pop_front(); + } + + Some(Message::Move { dx, dy, t }) +} + +/// Where komorebi is told what the fingers do +pub trait Sink { + fn send(&mut self, message: &SocketMessage) -> io::Result<()>; + + /// The gesture that was being told is over + fn gesture_over(&mut self); +} + +/// A way of telling komorebi things, in order +pub trait Link { + fn send(&mut self, message: &SocketMessage) -> io::Result<()>; +} + +impl Link for komorebi_client::Connection { + fn send(&mut self, message: &SocketMessage) -> io::Result<()> { + komorebi_client::Connection::send(self, message) + } +} + +/// Tells komorebi what the fingers do over one connection for each gesture. Messages that are +/// sent on a connection each are not handled in the order that they were sent, so a gesture is +/// told on one, which is made when it is first needed and let go of when the gesture is over. +/// If it cannot be written to the connection is made again once, as komorebi lets go of one +/// that nothing has been written to for a while. +pub struct Socket { + open: F, + link: Option, +} + +impl io::Result> Socket { + pub const fn new(open: F) -> Self { + Self { open, link: None } + } + + fn try_send(&mut self, message: &SocketMessage) -> io::Result<()> { + if self.link.is_none() { + self.link = Some((self.open)()?); + } + + match self.link.as_mut() { + Some(link) => link.send(message), + None => Ok(()), + } + } +} + +impl io::Result> Sink for Socket { + fn send(&mut self, message: &SocketMessage) -> io::Result<()> { + if self.try_send(message).is_err() { + self.link = None; + return self.try_send(message); + } + + Ok(()) + } + + fn gesture_over(&mut self) { + self.link = None; + } +} + +/// Read gestures from `input` until it closes and tell komorebi what the fingers do with `sink` +pub fn run( + input: impl BufRead + Send + 'static, + options: Options, + sink: &mut impl Sink, +) -> io::Result<()> { + let (sender, receiver) = mpsc::channel(); + + std::thread::spawn(move || { + for line in input.lines() { + let Ok(line) = line else { + break; + }; + + match parse_line(&line) { + Line::Message(message) => { + if sender.send(message).is_err() { + break; + } + } + Line::Ignored => {} + Line::Bad(why) => eprintln!("pan: {why}: {line}"), + } + } + }); + + let mut forwarder = Forwarder::new(options); + let mut queue = VecDeque::new(); + + while let Ok(first) = receiver.recv() { + queue.push_back(first); + queue.extend(receiver.try_iter()); + + while let Some(message) = next_merged(&mut queue) { + let over = matches!(message, Message::End | Message::Cancel); + + for told in forwarder.handle(message) { + sink.send(&told)?; + } + + if over { + sink.gesture_over(); + } + } + } + + for told in forwarder.finish() { + sink.send(&told)?; + } + sink.gesture_over(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TRANSCRIPT: &str = include_str!("../fixtures/gesture-stream.transcript.txt"); + + fn gestures(told: &[SocketMessage]) -> Vec { + told.iter() + .filter_map(|message| match message { + SocketMessage::PanGesture(gesture) => Some(*gesture), + _ => None, + }) + .collect() + } + + fn messages(text: &str) -> Vec { + text.lines() + .filter_map(|line| match parse_line(line) { + Line::Message(message) => Some(message), + _ => None, + }) + .collect() + } + + /// What is told, and how many gestures have been over + #[derive(Default)] + struct Recorder { + told: Vec, + over: usize, + } + + impl Sink for Recorder { + fn send(&mut self, message: &SocketMessage) -> io::Result<()> { + self.told.push(message.clone()); + Ok(()) + } + + fn gesture_over(&mut self) { + self.over += 1; + } + } + + /// A link that remembers what it was written, and whose connections are numbered + struct Wire { + connection: usize, + written: Written, + fail_next: Failing, + } + + impl Link for Wire { + fn send(&mut self, message: &SocketMessage) -> io::Result<()> { + if self.fail_next.replace(false) { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "let go of")); + } + + self.written + .borrow_mut() + .push((self.connection, message.clone())); + Ok(()) + } + } + + type Written = std::rc::Rc>>; + + type Failing = std::rc::Rc>; + + #[allow(clippy::type_complexity)] + fn wire() -> ( + Socket io::Result>, + Written, + Failing, + ) { + let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let fail_next = std::rc::Rc::new(std::cell::Cell::new(false)); + let mut made = 0; + + let socket = Socket::new({ + let written = written.clone(); + let fail_next = fail_next.clone(); + move || { + made += 1; + Ok(Wire { + connection: made, + written: written.clone(), + fail_next: fail_next.clone(), + }) + } + }); + + (socket, written, fail_next) + } + + #[test] + fn a_gesture_is_told_on_one_connection_and_the_next_has_its_own() { + let (mut socket, written, _) = wire(); + let message = |gesture| SocketMessage::PanGesture(gesture); + let moved = PanGesture::Move { + dx: 1.0, + dy: 0.0, + t: 1.0, + }; + + socket.send(&message(PanGesture::Begin)).unwrap(); + socket.send(&message(moved)).unwrap(); + socket.send(&message(moved)).unwrap(); + socket.send(&message(PanGesture::End)).unwrap(); + socket.gesture_over(); + socket.send(&message(PanGesture::Begin)).unwrap(); + + let connections: Vec<_> = written.borrow().iter().map(|(number, _)| *number).collect(); + assert_eq!(connections, vec![1, 1, 1, 1, 2]); + } + + #[test] + fn a_connection_that_komorebi_let_go_of_is_made_again_once() { + let (mut socket, written, fail_next) = wire(); + let message = SocketMessage::PanGesture(PanGesture::Begin); + + socket.send(&message).unwrap(); + fail_next.set(true); + socket.send(&message).unwrap(); + + let connections: Vec<_> = written.borrow().iter().map(|(number, _)| *number).collect(); + assert_eq!( + connections, + vec![1, 2], + "the second was written on a new one" + ); + } + + #[test] + fn a_message_that_cannot_be_written_twice_is_an_error() { + let mut socket = Socket::new(|| -> io::Result { + Err(io::Error::new(io::ErrorKind::NotFound, "no komorebi")) + }); + + assert!( + socket + .send(&SocketMessage::PanGesture(PanGesture::Begin)) + .is_err() + ); + } + + #[test] + fn what_is_told_ends_each_gesture_that_ends_and_the_stream_that_stops() { + let input = b"begin\nmove dx=1 t=1\nend\nbegin\nmove dx=1 t=2\n".to_vec(); + let mut sink = Recorder::default(); + + run(io::Cursor::new(input), Options::default(), &mut sink).unwrap(); + + // the first ended, and the second was given up when the stream stopped + assert_eq!(sink.over, 2); + assert_eq!(gestures(&sink.told).last(), Some(&PanGesture::Cancel)); + } + + fn step(dx: f64) -> Message { + Message::Move { + dx, + dy: 0.0, + t: 0.0, + } + } + + #[test] + fn what_whkd_writes_is_read_as_it_was_written() { + assert_eq!( + messages(TRANSCRIPT), + vec![ + Message::Begin, + Message::Move { + dx: 0.42, + dy: -0.03, + t: 118_345.0 + }, + Message::Move { + dx: 0.51, + dy: -0.02, + t: 118_353.0 + }, + Message::Move { + dx: 0.47, + dy: 0.0, + t: 118_361.0 + }, + Message::End, + ] + ); + } + + /// What whkd writes for a swipe of three fingers that goes on for about two seconds, a + /// flick, and one that eases to a stop: `whkd touchpad --demo --instant` + const LONG: &str = include_str!("../fixtures/pan3-long.transcript.txt"); + const FLICK: &str = include_str!("../fixtures/pan3-flick.transcript.txt"); + const SLOW: &str = include_str!("../fixtures/pan3-slow.transcript.txt"); + + /// How many lines of a transcript are not part of the stream: whkd's demo says which swipe + /// it saw, as `swipe swipe3right`, among them, and nothing says that the program that it + /// runs is told it, so it is ignored like any other message that is not known + fn others(transcript: &str) -> usize { + transcript + .lines() + .filter(|line| line.starts_with("swipe ")) + .count() + } + + #[test] + fn every_line_that_whkd_writes_for_a_swipe_is_read_and_nothing_is_said_to_be_wrong() { + for (transcript, lines) in [(LONG, 218), (FLICK, 18), (SLOW, 67)] { + assert_eq!(transcript.lines().count(), lines); + + for line in transcript.lines() { + match parse_line(line) { + Line::Message(_) => {} + Line::Ignored => assert!(line.starts_with("swipe "), "ignored: {line}"), + Line::Bad(why) => panic!("{why}: {line}"), + } + } + + let messages = messages(transcript); + assert_eq!(messages.first(), Some(&Message::Begin)); + assert_eq!(messages.last(), Some(&Message::End)); + assert_eq!(messages.len(), lines - others(transcript)); + } + + assert_eq!(others(LONG) + others(FLICK) + others(SLOW), 2); + } + + #[test] + fn a_swipe_is_told_to_komorebi_move_for_move() { + for transcript in [LONG, FLICK, SLOW] { + let mut forwarder = Forwarder::new(Options::default()); + let told: Vec<_> = messages(transcript) + .into_iter() + .flat_map(|message| forwarder.handle(message)) + .collect(); + + let gestures = gestures(&told); + assert_eq!( + gestures.len(), + transcript.lines().count() - others(transcript) + ); + assert_eq!(gestures.first(), Some(&PanGesture::Begin)); + assert_eq!(gestures.last(), Some(&PanGesture::End)); + + // the distances add up to how far the fingers went across the touchpad + let across: f64 = gestures + .iter() + .filter_map(|gesture| match gesture { + PanGesture::Move { dx, .. } => Some(*dx), + _ => None, + }) + .sum(); + assert!(across > 1.0, "{across}"); + } + } + + #[test] + fn a_field_that_is_not_known_is_not_anything_and_nor_is_a_message() { + // x, y, w, h, and a field of the future, which whkd says to ignore without a word + assert_eq!( + parse_line("move dx=1.5 dy=0.25 x=4.420 y=1.970 t=10 pressure=0.7"), + Line::Message(Message::Move { + dx: 1.5, + dy: 0.25, + t: 10.0 + }) + ); + assert_eq!( + parse_line("begin fingers=3 x=4.000 y=2.000 w=10.500 h=7.200 t=118337"), + Line::Message(Message::Begin) + ); + assert_eq!( + parse_line("end x=5.4 y=1.95 t=118369"), + Line::Message(Message::End) + ); + assert_eq!(parse_line("pinch scale=1.1"), Line::Ignored); + assert_eq!(parse_line(""), Line::Ignored); + assert_eq!(parse_line(" "), Line::Ignored); + assert_eq!(parse_line("# a comment"), Line::Ignored); + } + + #[test] + fn a_move_that_is_not_what_it_should_be_is_said_to_be() { + assert!(matches!(parse_line("move dy=1 t=3"), Line::Bad(_)), "no dx"); + assert!(matches!(parse_line("move dx=1 dy=1"), Line::Bad(_)), "no t"); + assert!(matches!(parse_line("move dx=wide t=3"), Line::Bad(_))); + assert!(matches!(parse_line("move dx=NaN t=3"), Line::Bad(_))); + assert_eq!( + parse_line("move dx=2 t=3"), + Line::Message(Message::Move { + dx: 2.0, + dy: 0.0, + t: 3.0 + }), + "dy is not needed" + ); + } + + #[test] + fn the_strip_goes_with_the_fingers_and_the_times_are_told_as_they_are() { + let mut forwarder = Forwarder::new(Options::default()); + let told: Vec<_> = messages(TRANSCRIPT) + .into_iter() + .flat_map(|message| forwarder.handle(message)) + .collect(); + + let gestures = gestures(&told); + assert_eq!(gestures.len(), 5); + assert_eq!(gestures[0], PanGesture::Begin); + assert_eq!( + gestures[1], + PanGesture::Move { + dx: 0.42, + dy: -0.03, + t: 118_345.0 + } + ); + assert_eq!(gestures[4], PanGesture::End); + assert!( + forwarder.finish().is_empty(), + "it ended, so nothing is left" + ); + } + + #[test] + fn sensitivity_scales_the_distances_and_reversing_flips_the_way_it_goes() { + let mut forwarder = Forwarder::new(Options { + sensitivity: 2.0, + reversed: true, + paging: false, + }); + forwarder.handle(Message::Begin); + + let told = forwarder.handle(Message::Move { + dx: 1.0, + dy: 0.5, + t: 1.0, + }); + + assert_eq!( + gestures(&told), + vec![PanGesture::Move { + dx: -2.0, + dy: 1.0, + t: 1.0 + }] + ); + } + + #[test] + fn what_has_no_beginning_is_not_anything() { + let mut forwarder = Forwarder::new(Options::default()); + + assert!(forwarder.handle(step(1.0)).is_empty()); + assert!(forwarder.handle(Message::End).is_empty()); + assert!(forwarder.handle(Message::Cancel).is_empty()); + assert!(forwarder.finish().is_empty()); + } + + #[test] + fn a_stream_that_stops_in_the_middle_of_a_gesture_gives_it_up() { + let mut forwarder = Forwarder::new(Options::default()); + forwarder.handle(Message::Begin); + forwarder.handle(step(1.0)); + + assert_eq!(gestures(&forwarder.finish()), vec![PanGesture::Cancel]); + assert!(forwarder.finish().is_empty(), "once"); + } + + #[test] + fn fingers_that_go_far_enough_take_a_step_each_with_paging() { + let mut forwarder = Forwarder::new(Options { + paging: true, + ..Options::default() + }); + + assert!(forwarder.handle(Message::Begin).is_empty()); + assert!(forwarder.handle(step(1.5)).is_empty(), "not far enough"); + + // 1.5 and 0.7 is 2.2: a step to the left, as the strip goes right with the fingers + let told = forwarder.handle(step(0.7)); + assert!(matches!( + told.as_slice(), + [SocketMessage::Pan(PanDirection::Left)] + )); + + // 0.2 is left over, and with 4.0 more it is two steps + let told = forwarder.handle(step(4.0)); + assert_eq!(told.len(), 2); + + // and the other way: 0.2 and 8.5 back is 8.3, which is four + let told = forwarder.handle(step(-8.5)); + assert_eq!(told.len(), 4); + assert!( + told.iter() + .all(|message| matches!(message, SocketMessage::Pan(PanDirection::Right))) + ); + + assert!(forwarder.handle(Message::End).is_empty()); + assert!(forwarder.finish().is_empty()); + } + + #[test] + fn moves_that_wait_are_added_up_and_nothing_else_is_put_out_of_its_place() { + let mut queue: VecDeque = [ + Message::Begin, + Message::Move { + dx: 1.0, + dy: 0.5, + t: 1.0, + }, + Message::Move { + dx: 2.0, + dy: 0.25, + t: 2.0, + }, + Message::Move { + dx: 4.0, + dy: 0.0, + t: 3.0, + }, + Message::End, + ] + .into(); + + assert_eq!(next_merged(&mut queue), Some(Message::Begin)); + assert_eq!( + next_merged(&mut queue), + Some(Message::Move { + dx: 7.0, + dy: 0.75, + t: 3.0 + }) + ); + assert_eq!(next_merged(&mut queue), Some(Message::End)); + assert_eq!(next_merged(&mut queue), None); + } + + #[test] + fn a_stream_is_told_to_komorebi_in_order_and_given_up_if_it_stops() { + let input = b"begin fingers=3\nmove dx=1.0 dy=0 t=1\nmove dx=2.0 dy=0 t=2\n".to_vec(); + let mut sink = Recorder::default(); + + run(io::Cursor::new(input), Options::default(), &mut sink).unwrap(); + + let gestures = gestures(&sink.told); + assert_eq!(gestures[0], PanGesture::Begin); + assert!(matches!(gestures[1], PanGesture::Move { .. })); + assert_eq!(gestures.last(), Some(&PanGesture::Cancel)); + } +}