Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions internal/editor/tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions internal/editor/tab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading