diff --git a/internal/app/app.go b/internal/app/app.go index 594ffdf..b37900e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -50,6 +50,17 @@ const ( doubleClickMs = 500 * time.Millisecond doubleEscMs = 500 * time.Millisecond wheelLines = 3 + wheelCols = 6 // horizontal step per WheelLeft/WheelRight event + + // modifierStickyWindow is how long a previously-seen Shift modifier + // state is allowed to persist forward onto the next wheel event. + // Some terminals (Zellij + macOS Terminal among them) emit the + // Shift state as a separate ButtonNone+Shift event right before + // firing the WheelUp/WheelDown without the modifier — so without + // this carry-forward, shift+wheel reads as plain wheel. 250ms is + // long enough to bridge the gap and short enough that releasing + // Shift before scrolling reliably reverts to vertical scroll. + modifierStickyWindow = 250 * time.Millisecond // treeRefreshInterval is how often the background goroutine kicks off // a file-tree reload so the sidebar stays in sync with on-disk changes @@ -300,6 +311,15 @@ type App struct { lastClick clickRecord lastTabRects []tabRect + // lastShiftAt is the wall-clock time we last saw any mouse event + // carrying the Shift modifier. Some terminals (notably Zellij over + // macOS Terminal) report modifier state in a separate ButtonNone + // event right before the wheel event, instead of folding the + // modifier into the wheel event itself. We treat a wheel event as + // shifted when one of those modifier-state events arrived within + // modifierStickyWindow. See handleMouse. + lastShiftAt time.Time + menuOpen bool hoveredMenuRow int // index into menuItems of the row under the mouse, or -1. lastEscape time.Time // timestamp of the previous Esc press, for double-tap detection. @@ -994,6 +1014,14 @@ func (a *App) handleMouse(ev *tcell.EventMouse) { x, y := ev.Position() btn := ev.Buttons() + // Remember when we last saw Shift held down on ANY mouse event. + // Zellij + macOS Terminal split shift+wheel into two events: a + // ButtonNone+Shift "modifier state" event, then a WheelDown/Up + // with no modifier. We bridge them via modifierStickyWindow below. + if ev.Modifiers()&tcell.ModShift != 0 { + a.lastShiftAt = time.Now() + } + // Secondary modals absorb all mouse input. The order here matches // keyboard routing so behavior stays predictable. if a.promptOpen { @@ -1041,12 +1069,39 @@ func (a *App) handleMouse(ev *tcell.EventMouse) { } // Wheel events take priority — they fire even with no button held. + // Shift+wheel rotates the vertical wheel into horizontal scrolling + // (the VS Code convention). Most terminals never emit native + // WheelLeft/WheelRight, so this is the path that actually fires in + // practice; the dedicated horizontal-wheel branch below is a bonus + // for terminals that do. + // + // We accept "shift was just seen" within modifierStickyWindow as + // equivalent to shift-on-this-event, because Zellij and friends + // strip the modifier from the actual wheel event. + shift := ev.Modifiers()&tcell.ModShift != 0 || + (!a.lastShiftAt.IsZero() && time.Since(a.lastShiftAt) < modifierStickyWindow) if btn&tcell.WheelUp != 0 { - a.scrollAt(x, y, -wheelLines) + if shift { + a.scrollAtH(x, y, -wheelCols) + } else { + a.scrollAt(x, y, -wheelLines) + } return } if btn&tcell.WheelDown != 0 { - a.scrollAt(x, y, wheelLines) + if shift { + a.scrollAtH(x, y, wheelCols) + } else { + a.scrollAt(x, y, wheelLines) + } + return + } + if btn&tcell.WheelLeft != 0 { + a.scrollAtH(x, y, -wheelCols) + return + } + if btn&tcell.WheelRight != 0 { + a.scrollAtH(x, y, wheelCols) return } @@ -1128,6 +1183,21 @@ func (a *App) scrollAt(x, y, delta int) { } } +// scrollAtH scrolls the panel under (x, y) horizontally by delta cells. +// The file tree has no useful horizontal axis (each row is a single label), +// so we only honor horizontal wheel events when they fall inside the +// editor pane. +func (a *App) scrollAtH(x, y, delta int) { + if sw := a.sidebarW(); sw > 0 && x < sw { + return + } + if y > 0 && y < a.height-1 { + if t := a.activeTabPtr(); t != nil { + t.ScrollH(delta) + } + } +} + // tryTreeContextClick opens the right-click context menu when (x, y) lands // on a tree row. Returns true if it consumed the event so the caller knows // not to fall back to the main action menu. Right-clicking a node also diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 192ca4a..4a856b3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1298,6 +1298,123 @@ func TestHandleMouse_Wheel(t *testing.T) { a.handleMouse(ev) } +// TestHandleMouse_WheelHorizontal confirms WheelLeft / WheelRight events +// shift the active tab's ScrollX. The test opens a tab with a long line, +// fires WheelRight to scroll horizontally, then WheelLeft to walk it +// back to zero. +func TestHandleMouse_WheelHorizontal(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "long.txt") + if err := os.WriteFile(target, []byte(strings.Repeat("x", 200)+"\n"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + a.openFile(target) + tab := a.activeTabPtr() + if tab == nil { + t.Fatal("no active tab after openFile") + } + // Aim well inside the editor pane (past the sidebar, below the tab bar). + editorX := a.sidebarW() + 10 + ev := tcell.NewEventMouse(editorX, 5, tcell.WheelRight, tcell.ModNone) + a.handleMouse(ev) + if tab.ScrollX == 0 { + t.Fatalf("WheelRight should advance ScrollX, still 0") + } + startX := tab.ScrollX + ev = tcell.NewEventMouse(editorX, 5, tcell.WheelLeft, tcell.ModNone) + a.handleMouse(ev) + if tab.ScrollX >= startX { + t.Fatalf("WheelLeft should reduce ScrollX, got %d (was %d)", tab.ScrollX, startX) + } +} + +// TestHandleMouse_ShiftWheelScrollsHorizontally confirms that holding +// shift while turning the vertical wheel scrolls the X axis instead — +// this is the path that actually works in most terminals (which never +// emit native WheelLeft/WheelRight). Without shift, the same wheel +// event must scroll vertically; we check both to make sure the modifier +// is what gates the rotation. +func TestHandleMouse_ShiftWheelScrollsHorizontally(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "long.txt") + if err := os.WriteFile(target, []byte(strings.Repeat("x", 200)+"\n"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + a.openFile(target) + tab := a.activeTabPtr() + if tab == nil { + t.Fatal("no active tab after openFile") + } + editorX := a.sidebarW() + 10 + + // Shift+WheelDown → horizontal scroll right. + ev := tcell.NewEventMouse(editorX, 5, tcell.WheelDown, tcell.ModShift) + a.handleMouse(ev) + if tab.ScrollX == 0 { + t.Fatalf("Shift+WheelDown should scroll horizontally, ScrollX still 0") + } + if tab.ScrollY != 0 { + t.Fatalf("Shift+WheelDown should NOT touch ScrollY, got %d", tab.ScrollY) + } + + // Shift+WheelUp → horizontal scroll left. + startX := tab.ScrollX + ev = tcell.NewEventMouse(editorX, 5, tcell.WheelUp, tcell.ModShift) + a.handleMouse(ev) + if tab.ScrollX >= startX { + t.Fatalf("Shift+WheelUp should reduce ScrollX, got %d (was %d)", tab.ScrollX, startX) + } + + // Unmodified WheelDown still scrolls vertically. Reset the sticky + // shift state first — within modifierStickyWindow of the previous + // shift events it'd still register as a shifted wheel. + tab.ScrollX = 0 + tab.ScrollY = 0 + a.lastShiftAt = time.Time{} + ev = tcell.NewEventMouse(editorX, 5, tcell.WheelDown, tcell.ModNone) + a.handleMouse(ev) + if tab.ScrollY == 0 { + t.Fatalf("WheelDown without shift should scroll vertically, ScrollY still 0") + } + if tab.ScrollX != 0 { + t.Fatalf("WheelDown without shift should NOT touch ScrollX, got %d", tab.ScrollX) + } +} + +// TestHandleMouse_ShiftStickyForWheel covers the Zellij quirk where +// Shift arrives in a ButtonNone+Shift event right before an unmodified +// WheelDown. We feed that exact sequence and confirm the wheel event is +// treated as horizontal because the sticky-shift window picked it up. +func TestHandleMouse_ShiftStickyForWheel(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "long.txt") + if err := os.WriteFile(target, []byte(strings.Repeat("x", 200)+"\n"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + a.openFile(target) + tab := a.activeTabPtr() + editorX := a.sidebarW() + 10 + + // First event: ButtonNone with Shift modifier — what Zellij emits + // when the user holds shift but hasn't moved or wheeled yet. + ev := tcell.NewEventMouse(editorX, 5, tcell.ButtonNone, tcell.ModShift) + a.handleMouse(ev) + // Second event: WheelDown with NO modifier — what arrives milliseconds + // later. Without the sticky window this would scroll vertically. + ev = tcell.NewEventMouse(editorX, 5, tcell.WheelDown, tcell.ModNone) + a.handleMouse(ev) + + if tab.ScrollX == 0 { + t.Fatalf("expected sticky-shift to route WheelDown to horizontal, ScrollX still 0") + } + if tab.ScrollY != 0 { + t.Fatalf("sticky-shift WheelDown shouldn't touch ScrollY, got %d", tab.ScrollY) + } +} + // TestHandleMouse_RightClickOpensMenu falls back to the main menu when the // right-click isn't on a tree row. func TestHandleMouse_RightClickOpensMenu(t *testing.T) { diff --git a/internal/editor/tab.go b/internal/editor/tab.go index 9c4bb34..22e5518 100644 --- a/internal/editor/tab.go +++ b/internal/editor/tab.go @@ -627,6 +627,21 @@ func (t *Tab) Render(scr tcell.Screen, th theme.Theme, x, y, w, h int) { } visualCol += width } + + // Overflow affordance: paint a muted '‹' / '›' over the leftmost / + // rightmost content cell when the line extends past the viewport + // in that direction. Without this hint a terminal user has no way + // to tell that more content exists off-screen — there's no + // scrollbar to clue them in. visualCol now equals the total + // visual width of the line; scrollVisual is the visual cell + // corresponding to ScrollX. + overflowStyle := tcell.StyleDefault.Background(lineBg).Foreground(th.Muted) + if t.ScrollX > 0 { + scr.SetContent(contentX, cy, '‹', nil, overflowStyle) + } + if visualCol-scrollVisual > contentW { + scr.SetContent(contentX+contentW-1, cy, '›', nil, overflowStyle) + } } // Position the hardware cursor at its visual column (so a cursor @@ -685,6 +700,19 @@ func (t *Tab) Scroll(deltaLines int) { } } +// ScrollH moves the viewport horizontally by delta rune-columns (negative +// = left). Clamped at zero; the right side is naturally bounded by +// Render's contentW window — scrolling past the longest visible line just +// shows blank space, which is fine. Lives next to Scroll so the app's +// mouse-wheel dispatcher can treat horizontal and vertical wheels +// symmetrically. +func (t *Tab) ScrollH(deltaCols int) { + t.ScrollX += deltaCols + if t.ScrollX < 0 { + t.ScrollX = 0 + } +} + // clampScroll keeps ScrollY inside a sensible range for the current viewport // height. The max is "last line still on screen, plus a small overscroll // pad" so the user can scroll the bottom of the file up to the middle of diff --git a/internal/editor/tab_test.go b/internal/editor/tab_test.go index bb94e30..6a00329 100644 --- a/internal/editor/tab_test.go +++ b/internal/editor/tab_test.go @@ -908,3 +908,91 @@ func TestTab_clampScroll_BoundsScroll(t *testing.T) { t.Fatalf("ScrollY not clamped: %d", tab.ScrollY) } } + +// TestTab_ScrollH_AdjustsAndClamps confirms that ScrollH adds the delta +// and never lets ScrollX go negative — mirroring how Scroll behaves for +// the vertical axis. +func TestTab_ScrollH_AdjustsAndClamps(t *testing.T) { + tab := &Tab{Buffer: NewBuffer("hello world")} + tab.ScrollH(5) + if tab.ScrollX != 5 { + t.Fatalf("ScrollX = %d, want 5", tab.ScrollX) + } + tab.ScrollH(-100) + if tab.ScrollX != 0 { + t.Fatalf("ScrollX after negative delta = %d, want 0", tab.ScrollX) + } +} + +// TestTab_Render_OverflowIndicator_Right paints a long line into a narrow +// viewport and confirms a '›' glyph appears at the rightmost content cell, +// signaling that more content exists off-screen. Without this affordance +// the user has no way to discover horizontal scroll is available. +func TestTab_Render_OverflowIndicator_Right(t *testing.T) { + scr := newSimScreen(t, 20, 5) + defer scr.Fini() + + tab, _ := NewTab("") + // 30 chars on one line; viewport content width = 20 - gutterWidth - 1 = 13. + tab.Buffer = NewBuffer(strings.Repeat("x", 30)) + tab.Cursor = Position{Line: 0, Col: 0} + tab.Anchor = tab.Cursor + + tab.Render(scr, theme.Default(), 0, 0, 20, 5) + scr.Show() + + cells, w, _ := scr.GetContents() + // Last cell of row 0 should be the right-overflow glyph. + last := cells[w-1] + if len(last.Runes) == 0 || last.Runes[0] != '›' { + t.Fatalf("expected '›' at row 0 col %d, got %q", w-1, string(last.Runes)) + } +} + +// TestTab_Render_OverflowIndicator_Left scrolls a long line right and +// confirms a '‹' glyph appears at the leftmost content cell to signal +// off-screen content to the left. +func TestTab_Render_OverflowIndicator_Left(t *testing.T) { + scr := newSimScreen(t, 20, 5) + defer scr.Fini() + + tab, _ := NewTab("") + tab.Buffer = NewBuffer(strings.Repeat("x", 30)) + tab.ScrollX = 10 + tab.Cursor = Position{Line: 0, Col: 10} + tab.Anchor = tab.Cursor + + tab.Render(scr, theme.Default(), 0, 0, 20, 5) + scr.Show() + + cells, w, _ := scr.GetContents() + // First content cell is at column gutterWidth + 1. + left := cells[gutterWidth+1] + if len(left.Runes) == 0 || left.Runes[0] != '‹' { + t.Fatalf("expected '‹' at row 0 col %d, got %q", gutterWidth+1, string(left.Runes)) + } + _ = w +} + +// TestTab_Render_NoOverflowIndicator_WhenLineFits is the negative control: +// a line that fits within contentW should NOT get a '›' glyph painted over +// its trailing real content. +func TestTab_Render_NoOverflowIndicator_WhenLineFits(t *testing.T) { + scr := newSimScreen(t, 40, 5) + defer scr.Fini() + + tab, _ := NewTab("") + tab.Buffer = NewBuffer("short") + tab.Cursor = Position{Line: 0, Col: 0} + tab.Anchor = tab.Cursor + + tab.Render(scr, theme.Default(), 0, 0, 40, 5) + scr.Show() + + cells, w, _ := scr.GetContents() + for i := 0; i < w; i++ { + if len(cells[i].Runes) > 0 && (cells[i].Runes[0] == '›' || cells[i].Runes[0] == '‹') { + t.Fatalf("unexpected overflow glyph at col %d", i) + } + } +}