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)); + } +}