Support the mouse in the branch popup

A click on a branch switches to it, the wheel moves the selection, a click
outside closes the popup. The popup is hit-tested by its own position, as it
is drawn after zone.Scan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:38:14 +02:00
co-authored by Claude Sonnet 5.5
parent 2b6b781826
commit 648e1fd46c
11 changed files with 179 additions and 28 deletions
+4 -2
View File
@@ -218,8 +218,10 @@ branches.
| `enter` | Switch to the selected branch | | `enter` | Switch to the selected branch |
| `esc` | Close | | `esc` | Close |
Mouse and all other keys are ignored while the popup is open. A header Mouse: a click on a branch switches to it (like `enter`), the wheel moves the
selection switches the superproject. selection, a click outside the popup closes it; a click on the title, the
search line or an empty row does nothing. Everything else is ignored while the
popup is open. A header selection switches the superproject.
### Effect ### Effect
+1 -1
View File
@@ -28,7 +28,7 @@ internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view) keys.go key bindings (also feed the help view)
restore.go, switch.go restore / switch flows; a switch reuses restoreState restore.go, switch.go restore / switch flows; a switch reuses restoreState
overlay.go draws the picker centered over the finished view overlay.go draws the picker over the finished view at the position the picker reports
msgs.go tree/status/fetch/change messages msgs.go tree/status/fetch/change messages
refresh.go reloader: one load per key, coalesced reruns refresh.go reloader: one load per key, coalesced reruns
theme/ colors, glyphs, issue words, change letters, level styles theme/ colors, glyphs, issue words, change letters, level styles
+3 -1
View File
@@ -376,7 +376,9 @@ restores below, without staging.
list itself uses none; `f` refreshes remote branches. list itself uses none; `f` refreshes remote branches.
- *Popup.* The first overlay: drawn over the finished view (after - *Popup.* The first overlay: drawn over the finished view (after
`zone.Scan`, so no zone marker is cut); the keys go to the picker while it `zone.Scan`, so no zone marker is cut); the keys go to the picker while it
is open. The backdrop is dimmed: stripped of its styles and drawn in is open, and so does the mouse: the popup remembers where it was drawn and
tests clicks itself (bubblezone can't see it, it is drawn after the scan).
A click on a branch switches, like `enter`; a click outside closes. The backdrop is dimmed: stripped of its styles and drawn in
`Backdrop`, an adaptive 256-color grey (238 dark / 252 light). `Muted` `Backdrop`, an adaptive 256-color grey (238 dark / 252 light). `Muted`
(ANSI 8) was too bright to set the popup apart, and ANSI faint (SGR 2) is (ANSI 8) was too bright to set the popup apart, and ANSI faint (SGR 2) is
ignored by some terminals. Like `Bar` and `Selected` it carries no meaning, ignored by some terminals. Like `Bar` and `Selected` it carries no meaning,
+3
View File
@@ -353,6 +353,9 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
- Click a row: select it. Click the header: select the superproject. - Click a row: select it. Click the header: select the superproject.
- Click `▸` / `▾`: select and toggle. - Click `▸` / `▾`: select and toggle.
- Scroll wheel: move the selection. - Scroll wheel: move the selection.
- In the [branch popup](#switch-branch): click a branch to switch to it,
wheel to move, click outside to close. The popup is hit-tested by its own
position, not with bubblezone, since it is drawn after `zone.Scan`.
## Accessibility and terminals ## Accessibility and terminals
+4 -2
View File
@@ -244,7 +244,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseMsg: case tea.MouseMsg:
if m.picker != nil { if m.picker != nil {
return m, nil return m.mousePicker(msg)
} }
m.tree.Update(msg) m.tree.Update(msg)
return m, nil return m, nil
@@ -563,7 +563,9 @@ func (m Model) View() string {
} }
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...)) view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
if m.picker != nil { // after zone.Scan, so no zone marker is cut if m.picker != nil { // after zone.Scan, so no zone marker is cut
view = overlay(view, m.picker.View(m.width, m.height), m.width, m.height, theme.Fg(m.theme.Backdrop)) pop := m.picker.View(m.width, m.height)
x, y := m.picker.Origin(m.width, m.height)
view = overlay(view, pop, x, y, theme.Fg(m.theme.Backdrop))
} }
return view return view
} }
+31 -1
View File
@@ -35,6 +35,7 @@ type Model struct {
loading bool loading bool
err error err error
spinner string spinner string
w, h int // size of the popup as last rendered, for hit tests
} }
// New returns a picker in the loading state. // New returns a picker in the loading state.
@@ -177,11 +178,40 @@ func (m *Model) View(termWidth, termHeight int) string {
lines = append(lines, body[:m.rows]...) lines = append(lines, body[:m.rows]...)
lines = append(lines, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…"))) lines = append(lines, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…")))
return lipgloss.NewStyle(). out := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Border). BorderForeground(m.theme.Border).
Padding(0, 1). Padding(0, 1).
Render(strings.Join(lines, "\n")) Render(strings.Join(lines, "\n"))
m.w, m.h = lipgloss.Width(out), lipgloss.Height(out)
return out
}
// Origin is the top-left cell of the popup, centered in a terminal of the
// given size. Valid after View.
func (m *Model) Origin(termWidth, termHeight int) (x, y int) {
return max((termWidth-m.w)/2, 0), max((termHeight-m.h)/2, 0)
}
// listTop is the line of the popup (border, title, search, rule) where the
// list starts.
const listTop = 4
// Click handles a left click at cell (x, y) of a terminal of the given size.
// inside reports whether the click was within the popup. If it hit a branch,
// that branch is selected and hit is true.
func (m *Model) Click(x, y, termWidth, termHeight int) (inside, hit bool) {
ox, oy := m.Origin(termWidth, termHeight)
x, y = x-ox, y-oy
if x < 0 || y < 0 || x >= m.w || y >= m.h {
return false, false
}
row := y - listTop
if m.loading || m.err != nil || x == 0 || x == m.w-1 || row < 0 || row >= m.rows || m.offset+row >= len(m.shown) {
return true, false
}
m.cursor = m.offset + row
return true, true
} }
// row renders one branch: the name, right-aligned the short SHA and age. // row renders one branch: the name, right-aligned the short SHA and age.
+41
View File
@@ -127,3 +127,44 @@ func TestListScrollsInASmallTerminal(t *testing.T) {
t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows) t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows)
} }
} }
func TestClickSelectsTheBranchUnderTheMouse(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
m.View(100, 30)
x, y := m.Origin(100, 30)
if inside, hit := m.Click(x+3, y+listTop+1, 100, 30); !inside || !hit || name(m) != "dev" {
t.Errorf("click on the 2nd row: inside=%v hit=%v selected %q", inside, hit, name(m))
}
if inside, hit := m.Click(x+3, y+listTop+3, 100, 30); !inside || !hit || name(m) != "origin/feat/search" {
t.Errorf("click on the 4th row: inside=%v hit=%v selected %q", inside, hit, name(m))
}
for _, c := range []struct {
name string
x, y int
}{
{"title", x + 3, y + 1},
{"search line", x + 3, y + 2},
{"hint line", x + 3, y + m.h - 2},
{"left border", x, y + listTop},
} {
before := name(m)
if inside, hit := m.Click(c.x, c.y, 100, 30); !inside || hit || name(m) != before {
t.Errorf("click on the %s: inside=%v hit=%v selected %q", c.name, inside, hit, name(m))
}
}
if inside, hit := m.Click(0, 0, 100, 30); inside || hit {
t.Errorf("click outside: inside=%v hit=%v", inside, hit)
}
}
func TestClickBelowTheLastBranchHitsNothing(t *testing.T) {
m := New(theme.Default(), "x")
m.SetBranches(list()[:1]) // 1 branch, the list is padded to 3 rows
m.View(100, 30)
x, y := m.Origin(100, 30)
if inside, hit := m.Click(x+3, y+listTop+2, 100, 30); !inside || hit {
t.Errorf("inside=%v hit=%v on an empty row", inside, hit)
}
}
+7 -8
View File
@@ -7,28 +7,27 @@ import (
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
// overlay draws fg centered over bg, which is w x h cells. bg must be the // overlay draws fg over bg with its top-left cell at (x, y). bg must be the
// final string (after zone.Scan): its lines are cut at the popup's edges. // final string (after zone.Scan): its lines are cut at the popup's edges.
// The backdrop loses its colors and is drawn in dim, so the popup stands out. // The backdrop loses its colors and is drawn in dim, so the popup stands out.
func overlay(bg, fg string, w, h int, dim lipgloss.Style) string { func overlay(bg, fg string, x, y int, dim lipgloss.Style) string {
lines := strings.Split(bg, "\n") lines := strings.Split(bg, "\n")
for i, l := range lines { for i, l := range lines {
lines[i] = dim.Render(ansi.Strip(l)) lines[i] = dim.Render(ansi.Strip(l))
} }
pop := strings.Split(fg, "\n") pop := strings.Split(fg, "\n")
pw := lipgloss.Width(fg) pw := lipgloss.Width(fg)
top := max((h-len(pop))/2, 0) left := x
left := max((w-pw)/2, 0)
for i, p := range pop { for i, p := range pop {
y := top + i row := y + i
if y >= len(lines) { if row >= len(lines) {
break break
} }
line := lines[y] line := lines[row]
if lw := ansi.StringWidth(line); lw < left { if lw := ansi.StringWidth(line); lw < left {
line += strings.Repeat(" ", left-lw) line += strings.Repeat(" ", left-lw)
} }
lines[y] = ansi.Truncate(line, left, "") + "\x1b[0m" + p + "\x1b[0m" + ansi.TruncateLeft(line, left+pw, "") lines[row] = ansi.Truncate(line, left, "") + "\x1b[0m" + p + "\x1b[0m" + ansi.TruncateLeft(line, left+pw, "")
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
+2 -2
View File
@@ -11,7 +11,7 @@ import (
func TestOverlayCentersThePopup(t *testing.T) { func TestOverlayCentersThePopup(t *testing.T) {
bg := strings.Repeat(strings.Repeat(".", 10)+"\n", 4) + strings.Repeat(".", 10) bg := strings.Repeat(strings.Repeat(".", 10)+"\n", 4) + strings.Repeat(".", 10)
got := strings.Split(ansi.Strip(overlay(bg, "ab\ncd", 10, 5, lipgloss.NewStyle())), "\n") got := strings.Split(ansi.Strip(overlay(bg, "ab\ncd", 4, 1, lipgloss.NewStyle())), "\n")
want := []string{"..........", "....ab....", "....cd....", "..........", ".........."} want := []string{"..........", "....ab....", "....cd....", "..........", ".........."}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("got %d lines", len(got)) t.Fatalf("got %d lines", len(got))
@@ -26,7 +26,7 @@ func TestOverlayCentersThePopup(t *testing.T) {
func TestOverlayDimsTheBackdrop(t *testing.T) { func TestOverlayDimsTheBackdrop(t *testing.T) {
bg := "\x1b[31mredredred\x1b[0m" bg := "\x1b[31mredredred\x1b[0m"
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
got := overlay(bg, "X", 9, 1, dim) got := overlay(bg, "X", 4, 0, dim)
if ansi.StringWidth(got) != 9 || ansi.Strip(got) != "redrXdred" { if ansi.StringWidth(got) != 9 || ansi.Strip(got) != "redrXdred" {
t.Errorf("got %q", ansi.Strip(got)) t.Errorf("got %q", ansi.Strip(got))
} }
+32 -2
View File
@@ -77,6 +77,16 @@ func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyPgDown: case tea.KeyPgDown:
m.picker.PageDown() m.picker.PageDown()
case tea.KeyEnter: case tea.KeyEnter:
return m.confirmPick()
default:
return m, m.picker.Update(msg)
}
return m, nil
}
// confirmPick closes the picker and switches to the selected branch, with
// the same check and question as a restore.
func (m Model) confirmPick() (tea.Model, tea.Cmd) {
b := m.picker.Selected() b := m.picker.Selected()
if b == nil { if b == nil {
return m, nil return m, nil
@@ -88,8 +98,28 @@ func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b} m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
return m.checkRestore() return m.checkRestore()
default: }
return m, m.picker.Update(msg)
// mousePicker handles the mouse while the picker is open: the wheel moves
// the selection, a click on a branch switches to it, a click outside the
// popup closes it.
func (m Model) mousePicker(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.picker.Up()
case tea.MouseButtonWheelDown:
m.picker.Down()
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
return m, nil
}
inside, hit := m.picker.Click(msg.X, msg.Y, m.width, m.height)
switch {
case hit:
return m.confirmPick()
case !inside:
m.picker = nil
}
} }
return m, nil return m, nil
} }
+42
View File
@@ -180,3 +180,45 @@ func TestSwitchReportsADetachedHead(t *testing.T) {
t.Errorf("flash lacks the detached note:\n%s", v) t.Errorf("flash lacks the detached note:\n%s", v)
} }
} }
// clickRow clicks the i-th visible branch of the open picker.
func clickRow(m tea.Model, i int) (tea.Model, tea.Cmd) {
mm := m.(Model)
x, y := mm.picker.Origin(mm.width, mm.height)
return m.Update(tea.MouseMsg{X: x + 3, Y: y + 4 + i, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
}
func TestSwitchClickOnABranchSwitches(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m.View() // the popup learns where it is drawn
m, cmd := clickRow(m, 1)
if m.(Model).picker != nil {
t.Error("the click must close the picker")
}
settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q, want the clicked branch", f.switched)
}
}
func TestSwitchClickOutsideClosesAndWheelMoves(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m.View()
m, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress})
if b := m.(Model).picker.Selected(); b == nil || b.Name != "origin/feat" {
t.Errorf("wheel down selected %v", b)
}
// a click that releases or sits inside the popup's frame does nothing
mm := m.(Model)
x, y := mm.picker.Origin(mm.width, mm.height)
m, _ = m.Update(tea.MouseMsg{X: x + 3, Y: y + 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
if m.(Model).picker == nil {
t.Fatal("a click on the title must keep the popup open")
}
m, _ = m.Update(tea.MouseMsg{X: 0, Y: 0, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
if m.(Model).picker != nil || len(f.switched) != 0 {
t.Fatal("a click outside must close without switching")
}
}