summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 03:15:28 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit6cd33661064df4163f3bd98db432526ad2165b7f (patch)
tree6d3cffe1f59eb6b5df8193ddd9c092f781f0979f
parentf2b8a25d0a25df778bce57efa4c2c7282c71893b (diff)
downloadpardes-6cd33661064df4163f3bd98db432526ad2165b7f.tar.gz
pardes-6cd33661064df4163f3bd98db432526ad2165b7f.zip
a mostly-vertical touchpad swipe stays vertical
The pad faithfully turns a two-finger scroll's sideways drift into wheel_left and wheel_right, so a plain scroll slid the view sideways underneath you. Every vertical tick now re-arms a guard and every horizontal tick spends one instead of scrolling, so horizontal has to EARN its way back by landing three ticks in a row with no vertical among them. Clock-free on purpose: the core is a state machine with no timestamp on a mouse event, and faking one by counting renders would be worse than the counter. The guard is only ever armed BY vertical scrolling, so a horizontal swipe from a still view still moves on its first tick — only horizontal that interrupts vertical has anything to prove. A tilt wheel gets the same treatment, where recent-vertical is a much weaker signal of accident. Deliberate: the only honest fix is a per-device flag out of the shell, and that layer costs more than the three clicks it would save. The rule is one pure function next to the number it reads, with an inline test written to hold for any tuning of that number. unit-test grew a fourth binary over the core module hxdiff already links. New golden wheeldrift; none moved.
-rw-r--r--build.zig8
-rw-r--r--next-steps.txt2
-rw-r--r--src/config.zig56
-rw-r--r--src/pardes.zig18
-rw-r--r--test/snapshot.zig9
-rw-r--r--test/snapshots/wheeldrift.golden85
-rw-r--r--test/snapshots/wheeldrift.snap45
7 files changed, 219 insertions, 4 deletions
diff --git a/build.zig b/build.zig
index 04516cc8..e5a63532 100644
--- a/build.zig
+++ b/build.zig
@@ -668,7 +668,7 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
.root_source_file = b.path("src/modal.zig"),
}) });
- const unit_step = b.step("unit-test", "run the modal.zig + crt.zig + deck replay unit tests");
+ const unit_step = b.step("unit-test", "run the modal.zig + crt.zig + deck replay + core unit tests");
unit_step.dependOn(&b.addRunArtifact(unit).step);
// crt.zig is pure std — the mouse-mapping-vs-shader-formula test
const crt_test = b.addTest(.{ .root_module = b.createModule(.{
@@ -690,6 +690,12 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/gui/deck.zig"),
}));
unit_step.dependOn(&b.addRunArtifact(deck_test).step);
+ // the core's own inline tests (config.zig's wheel drift guard). It
+ // links the SAME module hxdiff does rather than a fourth hand-wired
+ // copy of ghostty-vt/tree-sitter/zstbi — a second wiring of that graph
+ // is a second thing to keep in step.
+ const core_test = b.addTest(.{ .root_module = hx_core_mod });
+ unit_step.dependOn(&b.addRunArtifact(core_test).step);
}
}
diff --git a/next-steps.txt b/next-steps.txt
index 3b00d67d..250908c6 100644
--- a/next-steps.txt
+++ b/next-steps.txt
@@ -3,7 +3,7 @@
+ File panes need to split the latest column which a file pane was being used.
- Add multiple cursor support, and Ctrl-c auto comments. Use the same methodology of running helix in a harness as the oracle for differential testing. Also add regex interactive selection like on helix and the more specific selection semantics, use https://github.com/mnemnion/mvzr for regexes, add it using zig fetch.
+ Add a new builtin that will list all builtins and their respective shortcuts if they have any (note that shortcuts may be spc+... or just keyboard or mouse chords/movements)
-- Scrolling on a touchpad moves horizontally accidentally way too much, add some heuristics to prioritize vertical scrolling and make horizontal scrolling work only when the user is a bit more explicit about it - for example by measuring if there was recent high-enough vertical movement, etc.
++ Scrolling on a touchpad moves horizontally accidentally way too much, add some heuristics to prioritize vertical scrolling and make horizontal scrolling work only when the user is a bit more explicit about it - for example by measuring if there was recent high-enough vertical movement, etc.
+ Add a new builtin to list the themes and create an output window to select those themes; each theme now must live on its own .zig file, when moving with n/N on out ThemeSel output window it will select that theme so it's a interactive way to select the themes. We're being clever now with how these output windows will work, this feature depends on the new semantics for output windows but basically now the ThemeSel output panes will set the Command flag that will basically tell that when doing n/N movement on the output pane instead of Look-ing it will Execute the line, so the ThemeSel output pane will be like: (Theme acme, Theme a, Theme b) seperated by lines so the n/N will automatically consider selecting the Theme + argument (this is not output pane specific, it should be generalized to work on every pane kind), and will Exec it. Add new themes that will be automatically exported from helix and zed, a new binary will execute that will generate .zig files, this binary will be added as a execute step on the build.zig so when building it will regenarate those .zig that will be then included on the runtime as themes.
- Use zig's 0.16 new threaded io and the std functions that the build system uses to listen for file changes, it needs to use something from the os so it's not a busy wait. It will only track files on open panes. when a file is updated, doing Undo will return to the state that it was before so if a file automatically updates the user won't lose the unsaved state as it was before and the editor won't need to handle with merging the unsaved changes and the new update.
- There's some bug when rendering from a ssh session - the screen gets dirty with old chars when scrolling.
diff --git a/src/config.zig b/src/config.zig
index 35be4b9f..7f83155a 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -303,6 +303,62 @@ pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }};
pub const wheel_rows: i32 = 1;
pub const wheel_cols: i32 = 4;
+/// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be
+/// vertical carries a little sideways drift, and the pad faithfully turns that
+/// drift into wheel_left/wheel_right — so a plain scroll slides the view
+/// sideways under you. Every vertical tick re-arms the guard to this many
+/// ticks and every horizontal tick spends one instead of scrolling, which
+/// makes horizontal EARN its way back: it has to land this many ticks in a row
+/// with no vertical among them. 3, because drift arrives in ones and twos —
+/// at 1 or 2 a doubled drift tick mid-swipe still gets through, and much
+/// higher starts eating deliberate swipes. Set 0 to disable the heuristic.
+///
+/// It costs nothing at rest: the guard is only armed by vertical scrolling, so
+/// a horizontal swipe that starts from a still view moves on its FIRST tick.
+/// Note this applies to a tilt wheel too, where "recent vertical" is a much
+/// weaker signal of accident — a mouse would rather not have it. Living with
+/// that is deliberate: the only honest fix is a per-device flag out of the
+/// shell (libinput/SDL know which is which, vaxis does not), and paying for a
+/// device-detection layer to spare a tilt wheel three clicks after a scroll is
+/// a worse trade than the three clicks.
+pub const wheel_guard_ticks: u8 = 3;
+
+/// The whole guard, as one state machine, so it can be tested as one thing:
+/// fold a wheel tick into `guard` and answer whether it scrolls. The core owns
+/// the counter (Pardes.wheel_guard) because the gesture belongs to the DEVICE,
+/// not to whichever pane the pointer happens to sit over.
+pub fn wheelTick(guard: *u8, vertical: bool) bool {
+ if (vertical) {
+ guard.* = wheel_guard_ticks;
+ return true;
+ }
+ if (guard.* == 0) return true;
+ guard.* -= 1;
+ return false;
+}
+
+// written to hold for ANY tuning of wheel_guard_ticks, since tuning it by hand
+// is what this file is for — a test that pinned the number 3 would just be a
+// second place to edit it
+test "wheel drift guard" {
+ var g: u8 = 0;
+ // from rest, horizontal moves on the first tick — nothing to prove
+ try std.testing.expect(wheelTick(&g, false));
+ try std.testing.expect(wheelTick(&g, false));
+ if (wheel_guard_ticks == 0) return; // guard disabled: nothing left to check
+ // a vertical swipe with drift mixed in: every sideways tick is swallowed,
+ // because each vertical tick re-arms the guard in full
+ for (0..4) |_| try std.testing.expect(wheelTick(&g, true));
+ try std.testing.expect(!wheelTick(&g, false));
+ try std.testing.expect(wheelTick(&g, true));
+ try std.testing.expect(!wheelTick(&g, false));
+ // the deliberate horizontal swipe that follows pays off the rest of the
+ // guard tick by tick, then runs free
+ for (1..wheel_guard_ticks) |_| try std.testing.expect(!wheelTick(&g, false));
+ try std.testing.expect(wheelTick(&g, false));
+ try std.testing.expect(wheelTick(&g, false));
+}
+
// ---- layout numbers that are taste ----
/// the file pane's line-number gutter, in columns
diff --git a/src/pardes.zig b/src/pardes.zig
index cd01187b..93ea1016 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -993,6 +993,16 @@ pub const Pardes = struct {
drag: Drag = .none,
hover_col: u16 = 0,
hover_row: u16 = 0,
+ /// touchpad drift guard, counted down in horizontal wheel ticks — see
+ /// config.wheelTick, which owns the whole rule. Global, and clock-free on
+ /// purpose: the core has no clock, so "recently scrolled vertically" can
+ /// only mean "in the last few wheel events", which is all the heuristic
+ /// needs.
+ /// ponytail: it therefore never times out — only a horizontal tick spends
+ /// it, so a sideways swipe an hour after a scroll still pays the toll. If
+ /// that ever bites, clear it on any non-wheel event: a keypress or a click
+ /// is proof the gesture ended, and still needs no clock.
+ wheel_guard: u8 = 0,
ctrl_w_pending: bool = false,
/// SPC leader in flight, holding the key path typed so far (empty = just
/// SPC). Global like ctrl_w_pending — there is one leader and it acts on
@@ -4292,11 +4302,19 @@ pub const Pardes = struct {
.none => {}, // hover tracked above; motion has no other meaning
.wheel_up, .wheel_down => {
if (m.kind != .press) return;
+ // re-arms the drift guard below; vertical always scrolls, so
+ // the answer is never in doubt here
+ _ = config.wheelTick(&p.wheel_guard, true);
const id = hovered orelse return;
p.panes[id].?.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
if (m.kind != .press) return;
+ // a mostly-vertical two-finger swipe's sideways drift dies
+ // here rather than sliding the view out from under a scroll.
+ // Charged against the gesture, not the pane, so it runs before
+ // we ask what is hovered.
+ if (!config.wheelTick(&p.wheel_guard, false)) return;
const id = hovered orelse return;
const pane = p.panes[id].?;
// ponytail: no right clamp — overscroll shows blank and the
diff --git a/test/snapshot.zig b/test/snapshot.zig
index aeb01187..4344654c 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -26,7 +26,7 @@
//
// Script commands (line-oriented, # comments):
// file <name> <content> create file in the script's cwd (before start)
-// lines <name> <n> create file with n numbered lines
+// lines <name> <n> [tail] create file with n numbered lines, tail on each
// dirmk <name> create a subdirectory
// start <rows> <cols> [arg] fork the app in a pty (optional extra CLI arg)
// wait <ms> <needle...> pump until needle appears on the grid (fails hard)
@@ -346,8 +346,13 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
} else if (std.mem.eql(u8, cmd, "lines")) {
const name = tok.next() orelse return error.BadScript;
const n = try std.fmt.parseInt(usize, tok.next() orelse return error.BadScript, 10);
+ // anything after the count is glued onto every line (no separator:
+ // `rest` eats the delimiter, so spell one in the tail if you want
+ // one) — a file that is long AND wide, which is what a horizontal
+ // scroll test needs and `file` can only give as one huge script line
+ const tail = tok.rest();
var buf: std.ArrayList(u8) = .empty;
- for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}\n", .{i}));
+ for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}{s}\n", .{ i, tail }));
try eh.writeFile(try arena.dupeZ(u8, name), buf.items);
} else if (std.mem.eql(u8, cmd, "ppmdiag")) {
// The diagonal-split test PPM from the old e2e (white over blue),
diff --git a/test/snapshots/wheeldrift.golden b/test/snapshots/wheeldrift.golden
new file mode 100644
index 00000000..44d40eef
--- /dev/null
+++ b/test/snapshots/wheeldrift.golden
@@ -0,0 +1,85 @@
+== snap home grid=70x16 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/wheeldrift/c NOR /tmp/pardes-snap/wheeldrift/c
+| 1 line 1_0123456789_abcdefghij
+| 2 line 2_0123456789_abcdefghij drift.txt
+| 3 line 3_0123456789_abcdefghij
+| 4 line 4_0123456789_abcdefghij
+| 5 line 5_0123456789_abcdefghij
+| 6 line 6_0123456789_abcdefghij
+| 7 line 7_0123456789_abcdefghij
+| 8 line 8_0123456789_abcdefghij
+| 9 line 9_0123456789_abcdefghij
+| 10 line 10_0123456789_abcdefghi
+| 11 line 11_0123456789_abcdefghi
+| 12 line 12_0123456789_abcdefghi
+| 13 line 13_0123456789_abcdefghi
+| 14 line 14_0123456789_abcdefghi
+== snap drifted grid=70x16 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/wheeldrift/c NOR /tmp/pardes-snap/wheeldrift/c
+| 6 line 6_0123456789_abcdefghij
+| 7 line 7_0123456789_abcdefghij drift.txt
+| 8 line 8_0123456789_abcdefghij
+| 9 line 9_0123456789_abcdefghij
+| 10 line 10_0123456789_abcdefghi
+| 11 line 11_0123456789_abcdefghi
+| 12 line 12_0123456789_abcdefghi
+| 13 line 13_0123456789_abcdefghi
+| 14 line 14_0123456789_abcdefghi
+| 15 line 15_0123456789_abcdefghi
+| 16 line 16_0123456789_abcdefghi
+| 17 line 17_0123456789_abcdefghi
+| 18 line 18_0123456789_abcdefghi
+| 19 line 19_0123456789_abcdefghi
+== snap paying grid=70x16 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/wheeldrift/c NOR /tmp/pardes-snap/wheeldrift/c
+| 6 line 6_0123456789_abcdefghij
+| 7 line 7_0123456789_abcdefghij drift.txt
+| 8 line 8_0123456789_abcdefghij
+| 9 line 9_0123456789_abcdefghij
+| 10 line 10_0123456789_abcdefghi
+| 11 line 11_0123456789_abcdefghi
+| 12 line 12_0123456789_abcdefghi
+| 13 line 13_0123456789_abcdefghi
+| 14 line 14_0123456789_abcdefghi
+| 15 line 15_0123456789_abcdefghi
+| 16 line 16_0123456789_abcdefghi
+| 17 line 17_0123456789_abcdefghi
+| 18 line 18_0123456789_abcdefghi
+| 19 line 19_0123456789_abcdefghi
+== snap moved grid=70x16 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/wheeldrift/c NOR /tmp/pardes-snap/wheeldrift/c
+| 6 123456789_abcdefghij_ZEND
+| 7 123456789_abcdefghij_ZEND drift.txt
+| 8 123456789_abcdefghij_ZEND
+| 9 123456789_abcdefghij_ZEND
+| 10 0123456789_abcdefghij_ZEND
+| 11 0123456789_abcdefghij_ZEND
+| 12 0123456789_abcdefghij_ZEND
+| 13 0123456789_abcdefghij_ZEND
+| 14 0123456789_abcdefghij_ZEND
+| 15 0123456789_abcdefghij_ZEND
+| 16 0123456789_abcdefghij_ZEND
+| 17 0123456789_abcdefghij_ZEND
+| 18 0123456789_abcdefghij_ZEND
+| 19 0123456789_abcdefghij_ZEND
+== snap rearmed grid=70x16 cursor=7,2
+|Kill Newcol Tutor Debug NextColor Dump Find Grep Help
+| NOR /tmp/pardes-snap/wheeldrift/c NOR /tmp/pardes-snap/wheeldrift/c
+| 5 123456789_abcdefghij_ZEND
+| 6 123456789_abcdefghij_ZEND drift.txt
+| 7 123456789_abcdefghij_ZEND
+| 8 123456789_abcdefghij_ZEND
+| 9 123456789_abcdefghij_ZEND
+| 10 0123456789_abcdefghij_ZEND
+| 11 0123456789_abcdefghij_ZEND
+| 12 0123456789_abcdefghij_ZEND
+| 13 0123456789_abcdefghij_ZEND
+| 14 0123456789_abcdefghij_ZEND
+| 15 0123456789_abcdefghij_ZEND
+| 16 0123456789_abcdefghij_ZEND
+| 17 0123456789_abcdefghij_ZEND
+| 18 0123456789_abcdefghij_ZEND
diff --git a/test/snapshots/wheeldrift.snap b/test/snapshots/wheeldrift.snap
new file mode 100644
index 00000000..a546d2ff
--- /dev/null
+++ b/test/snapshots/wheeldrift.snap
@@ -0,0 +1,45 @@
+# the touchpad drift guard (config.wheel_guard_ticks). A two-finger swipe that
+# is MEANT to be vertical carries a little sideways drift and the pad turns it
+# into wheel_left/wheel_right, so the view used to slide sideways under a plain
+# scroll. Every vertical tick re-arms the guard; a horizontal tick spends one
+# instead of scrolling. SGR codes are vaxis's: 64/65 = wheel up/down (the
+# `wheel` command), 66/67 = wheel RIGHT/left, sent raw as hscroll.snap does.
+# The drift ticks here are all rightward on purpose: leftward drift at column 0
+# is clamped away and would prove nothing.
+lines drift.txt 20 _0123456789_abcdefghij_ZEND
+start 16 70 drift.txt
+wait 8000 drift.txt
+stable 700 20000
+snap home
+# a drifting swipe: five ticks down with three sideways ones mixed in. The
+# view scrolls DOWN five lines and does not move one column sideways.
+wheel down 10 5
+wheel down 10 5
+send \x1b[<66;10;5M
+wheel down 10 5
+send \x1b[<66;10;5M
+send \x1b[<66;10;5M
+wheel down 10 5
+wheel down 10 5
+stable 400 5000
+snap drifted
+# the guard is armed at 3 now, so a DELIBERATE horizontal swipe pays it off
+# first: these three ticks still move nothing (without the guard they would
+# have shifted the text 12 columns)
+send \x1b[<66;10;5M
+send \x1b[<66;10;5M
+send \x1b[<66;10;5M
+stable 400 5000
+snap paying
+# ...and from the fourth tick on it scrolls freely: two ticks, 8 columns
+send \x1b[<66;10;5M
+send \x1b[<66;10;5M
+stable 400 5000
+snap moved
+# vertical again re-arms the guard, so drift is swallowed once more even
+# though horizontal was just running
+wheel up 10 5
+send \x1b[<66;10;5M
+send \x1b[<66;10;5M
+stable 400 5000
+snap rearmed