diff --git a/docs/actions.md b/docs/actions.md index a21478e..4e5fdeb 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index c39fff6..e5d27db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index 11b23d2..b63214c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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, diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 4e49242..bf00608 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -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 diff --git a/internal/tui/app.go b/internal/tui/app.go index d63f341..1f7ab0f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 } diff --git a/internal/tui/branches/branches.go b/internal/tui/branches/branches.go index 59dbd0d..bbc7a39 100644 --- a/internal/tui/branches/branches.go +++ b/internal/tui/branches/branches.go @@ -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. diff --git a/internal/tui/branches/branches_test.go b/internal/tui/branches/branches_test.go index 0a38e00..3fed7d6 100644 --- a/internal/tui/branches/branches_test.go +++ b/internal/tui/branches/branches_test.go @@ -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) + } +} diff --git a/internal/tui/overlay.go b/internal/tui/overlay.go index 9bbbe05..6cab5de 100644 --- a/internal/tui/overlay.go +++ b/internal/tui/overlay.go @@ -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") } diff --git a/internal/tui/overlay_test.go b/internal/tui/overlay_test.go index 3cbecda..acbaa0d 100644 --- a/internal/tui/overlay_test.go +++ b/internal/tui/overlay_test.go @@ -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)) } diff --git a/internal/tui/switch.go b/internal/tui/switch.go index 3a595e3..6bae2e6 100644 --- a/internal/tui/switch.go +++ b/internal/tui/switch.go @@ -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 +} diff --git a/internal/tui/switch_test.go b/internal/tui/switch_test.go index afa6bc3..51c24fe 100644 --- a/internal/tui/switch_test.go +++ b/internal/tui/switch_test.go @@ -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") + } +}