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 |
|
| `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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-11
@@ -77,19 +77,49 @@ 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:
|
||||||
b := m.picker.Selected()
|
return m.confirmPick()
|
||||||
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()
|
|
||||||
default:
|
default:
|
||||||
return m, m.picker.Update(msg)
|
return m, m.picker.Update(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
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)
|
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