Archived
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:
+4
-2
@@ -218,8 +218,10 @@ branches.
|
||||
| `enter` | Switch to the selected branch |
|
||||
| `esc` | Close |
|
||||
|
||||
Mouse and all other keys are ignored while the popup is open. A header
|
||||
selection switches the superproject.
|
||||
Mouse: a click on a branch switches to it (like `enter`), the wheel moves the
|
||||
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
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ internal/tui/ Bubble Tea application
|
||||
app.go root model: layout, message routing, loading
|
||||
keys.go key bindings (also feed the help view)
|
||||
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
|
||||
refresh.go reloader: one load per key, coalesced reruns
|
||||
theme/ colors, glyphs, issue words, change letters, level styles
|
||||
|
||||
+3
-1
@@ -376,7 +376,9 @@ restores below, without staging.
|
||||
list itself uses none; `f` refreshes remote branches.
|
||||
- *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
|
||||
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`
|
||||
(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,
|
||||
|
||||
@@ -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 `▸` / `▾`: select and toggle.
|
||||
- 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
|
||||
|
||||
|
||||
+4
-2
@@ -244,7 +244,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.picker != nil {
|
||||
return m, nil
|
||||
return m.mousePicker(msg)
|
||||
}
|
||||
m.tree.Update(msg)
|
||||
return m, nil
|
||||
@@ -563,7 +563,9 @@ func (m Model) View() string {
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type Model struct {
|
||||
loading bool
|
||||
err error
|
||||
spinner string
|
||||
w, h int // size of the popup as last rendered, for hit tests
|
||||
}
|
||||
|
||||
// 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, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…")))
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
out := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(m.theme.Border).
|
||||
Padding(0, 1).
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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,28 +7,27 @@ import (
|
||||
"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.
|
||||
// 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")
|
||||
for i, l := range lines {
|
||||
lines[i] = dim.Render(ansi.Strip(l))
|
||||
}
|
||||
pop := strings.Split(fg, "\n")
|
||||
pw := lipgloss.Width(fg)
|
||||
top := max((h-len(pop))/2, 0)
|
||||
left := max((w-pw)/2, 0)
|
||||
left := x
|
||||
for i, p := range pop {
|
||||
y := top + i
|
||||
if y >= len(lines) {
|
||||
row := y + i
|
||||
if row >= len(lines) {
|
||||
break
|
||||
}
|
||||
line := lines[y]
|
||||
line := lines[row]
|
||||
if lw := ansi.StringWidth(line); lw < left {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func TestOverlayCentersThePopup(t *testing.T) {
|
||||
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....", "..........", ".........."}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d lines", len(got))
|
||||
@@ -26,7 +26,7 @@ func TestOverlayCentersThePopup(t *testing.T) {
|
||||
func TestOverlayDimsTheBackdrop(t *testing.T) {
|
||||
bg := "\x1b[31mredredred\x1b[0m"
|
||||
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" {
|
||||
t.Errorf("got %q", ansi.Strip(got))
|
||||
}
|
||||
|
||||
+41
-11
@@ -77,19 +77,49 @@ func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyPgDown:
|
||||
m.picker.PageDown()
|
||||
case tea.KeyEnter:
|
||||
b := m.picker.Selected()
|
||||
if b == nil {
|
||||
return m, nil
|
||||
}
|
||||
s := m.byPath[m.pickerPath]
|
||||
m.picker = nil
|
||||
if s == nil || m.restore != nil {
|
||||
return m, nil
|
||||
}
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
|
||||
return m.checkRestore()
|
||||
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()
|
||||
if b == nil {
|
||||
return m, nil
|
||||
}
|
||||
s := m.byPath[m.pickerPath]
|
||||
m.picker = nil
|
||||
if s == nil || m.restore != nil {
|
||||
return m, nil
|
||||
}
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
|
||||
return m.checkRestore()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -180,3 +180,45 @@ func TestSwitchReportsADetachedHead(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user