From f442004dcd8e91114765a918201ecabe99f69eeb Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 6 Jul 2026 21:08:40 -0300 Subject: horizontal scroll for file panes (terminals wrap at pty width — nothing to scroll): pane.hscroll cuts content columns after the lineno gutter in bodyText (post edit-splice, so typed runs shift with the text), no scrollbar. Drivers: horizontal wheel (new wheel_left/right Mouse.Buttons, SGR 66/67 via vaxis on the tty, SDL wheel.x on the gui, ±4 cols, clamped at 0, right overscroll allowed) and keyboard movement — ensureCursorVisible grew the horizontal mirror of the scrolloff reconcile, so $ pulls the view right with 3 columns of margin and 0 reels it home; the wheel never moves the cursor. Shifted alongside: syntax recolor pass, modal cursor paint (hidden when off-view), vsel highlight, click->content-col mapping, cursorWordSel. Byte columns like the rest of the file-pane code. New hscroll.snap golden: wheel shift with fixed gutter, left clamp, $ margin follow, 0 home. 30/30. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui.zig | 12 ++++++++++-- src/pardes.zig | 54 ++++++++++++++++++++++++++++++++++++++++++++++-------- src/tty.zig | 2 ++ 3 files changed, 58 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/gui.zig b/src/gui.zig index 15631efa..60d9501f 100644 --- a/src/gui.zig +++ b/src/gui.zig @@ -1080,6 +1080,8 @@ const StdinFeed = struct { .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, .none => .none, else => null, }; @@ -1266,16 +1268,22 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { }, c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; - if (w.y == 0) return; + if (w.y == 0 and w.x == 0) return; g.mouse_x = w.mouse_x; g.mouse_y = w.mouse_y; const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); - core.update(.{ .mouse = .{ + if (w.y != 0) core.update(.{ .mouse = .{ .button = if (w.y > 0) .wheel_up else .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, } }); + if (w.x != 0) core.update(.{ .mouse = .{ + .button = if (w.x > 0) .wheel_right else .wheel_left, + .kind = .press, + .col = mc.col, + .row = mc.row, + } }); }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { const f = sev.tfinger; diff --git a/src/pardes.zig b/src/pardes.zig index ebc54426..32a58f89 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -284,7 +284,7 @@ pub const Surface = struct { pub const Mouse = struct { // sel indexing relies on left/middle/right being 0/1/2 - pub const Button = enum { left, middle, right, wheel_up, wheel_down, none }; + pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none }; pub const Kind = enum { press, release, motion, drag }; button: Button, kind: Kind, @@ -498,6 +498,12 @@ pub const Pane = struct { cur_pinned: bool = false, cur_row: i32 = 0, cur_col: i32 = 0, + /// horizontal scroll, file panes only (terminals wrap at pty width, they + /// never have wider lines): content columns hidden left of the gutter. + /// No scrollbar — the wheel and cursor movement (with scrolloff) drive it, + /// the goal is just being able to read long lines. Byte columns, like the + /// rest of the file-pane code. + hscroll: i32 = 0, cur_off: u16 = 0, sel: [3]Sel = @splat(.{}), edits: [MAX_EDITS]Edit = @splat(.{}), @@ -552,6 +558,17 @@ pub const Pane = struct { if (pane.file) |f| to = @min(to, @as(i32, @intCast(modal.lineCount(f.content) -| 1))); pane.scrollBy(@max(0, to - last)); } + // the horizontal mirror, files only: keep scroll_off columns of + // context around the cursor (wheel-driven hscroll is exempt — it + // never moves the cursor, and a cursor move pulls the view back) + if (pane.file != null) { + const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, PREFIX_W)); + const hmargin: i32 = @min(scroll_off, @divTrunc(w - 1, 2)); + if (pane.cur_col < pane.hscroll + hmargin) + pane.hscroll = @max(0, pane.cur_col - hmargin) + else if (pane.cur_col > pane.hscroll + w - 1 - hmargin) + pane.hscroll = pane.cur_col - (w - 1 - hmargin); + } } /// the last navigable row: file -> last content line; terminal -> viewport bottom @@ -1172,7 +1189,7 @@ pub const Pardes = struct { /// coords: row 0 is the tag; file panes carry the line-number prefix) fn cursorWordSel(pane: *Pane) Sel { const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H); - const vcol = if (pane.file != null) pane.cur_col + @as(i32, PREFIX_W) else pane.cur_col; + const vcol = if (pane.file != null) pane.cur_col - pane.hscroll + @as(i32, PREFIX_W) else pane.cur_col; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } @@ -2368,6 +2385,15 @@ pub const Pardes = struct { const id = hovered orelse return; p.panes[id].?.scrollBy(if (m.button == .wheel_up) -1 else 1); }, + .wheel_left, .wheel_right => { + if (m.kind != .press) return; + const id = hovered orelse return; + const pane = p.panes[id].?; + // ponytail: no right clamp — overscroll shows blank and the + // next cursor move or left wheel pulls it back + if (pane.file != null) + pane.hscroll = @max(0, pane.hscroll + @as(i32, if (m.button == .wheel_right) 4 else -4)); + }, .left => switch (m.kind) { .press => { // middle+left chord: a left press during a middle select- @@ -2560,7 +2586,7 @@ pub const Pardes = struct { const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { pane.cur_row = body_vis + pane.scroll(); - pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1; + pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) + pane.hscroll else sl.c1; pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; pane.cur_pinned = true; pane.mode = .normal; @@ -3602,13 +3628,14 @@ pub const Pardes = struct { while (skip < f.scroll) : (skip += 1) { if (flines.next() == null) break; } + const hs: usize = @intCast(@max(0, pane.hscroll)); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { const line = flines.next() orelse break; const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); var c: usize = 0; - while (c < line.len and PREFIX_W + c < tw) : (c += 1) { - const idx = base + c; + while (hs + c < line.len and PREFIX_W + c < tw) : (c += 1) { + const idx = base + hs + c; if (idx < f.highlight_start) continue; const hidx = idx - f.highlight_start; if (hidx >= f.highlights.len) continue; @@ -3697,12 +3724,13 @@ pub const Pardes = struct { if (pane.mode == .normal and pane.vsel.active) { const bnd = vselBounds(pane); const vpfx: i32 = if (pane.file != null) PREFIX_W else 0; + const vhs: i32 = if (pane.file != null) pane.hscroll else 0; var ar: i32 = bnd.lo_row; while (ar <= bnd.hi_row) : (ar += 1) { const prow = ar - off + @as(i32, BOX_H); if (prow < 0 or prow >= @as(i32, r.h)) continue; - const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + vpfx else vpfx; - const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + vpfx else @as(i32, tw) - 1; + const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx; + const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); while (col <= cend and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow))); @@ -3726,7 +3754,7 @@ pub const Pardes = struct { // insert mode maps the run anchor to its visual column; normal // mode keeps cur_col already-visual. const cx = if (pane.file != null) - @as(i32, PREFIX_W) + ccol + @as(i32, coff) + @as(i32, PREFIX_W) + ccol + @as(i32, coff) - pane.hscroll else if (pane.mode == .insert) pane.renderedCol(crow, ccol, coff) else @@ -3861,6 +3889,7 @@ pub const Pardes = struct { while (i < pane.rows) : (i += 1) { if (i > 0) try out.append(arena, '\n'); const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); + const row_start = out.items.len; if (flines.next()) |text| { var lbuf: [16]u8 = undefined; // unsigned: {d} prints a leading '+' for signed ints @@ -3871,6 +3900,15 @@ pub const Pardes = struct { try base.appendSlice(arena, text); try spliceRow(arena, &out, pane, abs, base.items); } else try spliceRow(arena, &out, pane, abs, ""); + // horizontal scroll: cut hscroll columns after the gutter, + // from the fully composed row (edit splices shift with it) + if (pane.hscroll > 0) { + const cut_at = row_start + PREFIX_W; + if (out.items.len > cut_at) { + const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); + out.replaceRangeAssumeCapacity(cut_at, cut, &.{}); + } + } } return out.items; } diff --git a/src/tty.zig b/src/tty.zig index c62ace3d..f503303a 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -163,6 +163,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, .none => .none, // button-less motion: hover tracking else => null, }; -- cgit v1.3