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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.0
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a
github.com/sahilm/fuzzy v0.1.1
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.11.1
Expand All @@ -32,7 +33,6 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
Expand Down
4 changes: 4 additions & 0 deletions internal/ui/tui/wizard/frames_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func key(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyDown}
case "up":
return tea.KeyMsg{Type: tea.KeyUp}
case "left":
return tea.KeyMsg{Type: tea.KeyLeft}
case "right":
return tea.KeyMsg{Type: tea.KeyRight}
case "backspace":
return tea.KeyMsg{Type: tea.KeyBackspace}
}
Expand Down
164 changes: 142 additions & 22 deletions internal/ui/tui/wizard/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,27 +136,25 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy
m.quit = true
return m, tea.Quit
case "/":
m.typing, m.query, m.rowCur, m.scroll = true, "", 0, 0
// Filtering searches across categories, so it's a list operation —
// pull focus to the list so ↑↓ behaves predictably after it clears.
m.typing, m.query, m.rowCur, m.scroll, m.selFocus = true, "", 0, 0, focusList
case "esc":
m.query, m.rowCur, m.scroll = "", 0, 0
case "up", "k":
if m.rowCur > 0 {
m.rowCur--
case "left", "h":
m.selFocus = focusCats
case "right", "l":
m.selFocus = focusList
case "tab", "shift+tab":
if m.selFocus == focusCats {
m.selFocus = focusList
} else {
m.selFocus = focusCats
}
case "up", "k":
m = m.selMoveUp()
case "down", "j":
if m.rowCur < last {
m.rowCur++
}
case "tab", "right":
if m.query == "" && len(m.cats) > 0 {
m.catCur = (m.catCur + 1) % len(m.cats)
m.rowCur, m.scroll = 0, 0
}
case "shift+tab", "left":
if m.query == "" && len(m.cats) > 0 {
m.catCur = (m.catCur - 1 + len(m.cats)) % len(m.cats)
m.rowCur, m.scroll = 0, 0
}
m = m.selMoveDown()
case " ":
if len(pool) > 0 {
p := pool[clamp(m.rowCur, 0, last)]
Expand All @@ -178,6 +176,114 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy
return m.clampSelScroll(), nil
}

// selMoveUp/selMoveDown move the cursor within the focused pane: the category
// sidebar when it has focus (right pane live-previews the new category), the
// package list otherwise.
func (m Model) selMoveUp() Model {
if m.selFocus == focusCats && m.query == "" {
if m.catCur > 0 {
m.catCur--
m.rowCur, m.scroll = 0, 0
}
return m
}
if m.rowCur > 0 {
m.rowCur--
}
return m
}

func (m Model) selMoveDown() Model {
if m.selFocus == focusCats && m.query == "" {
if m.catCur < len(m.cats)-1 {
m.catCur++
m.rowCur, m.scroll = 0, 0
}
return m
}
if m.rowCur < len(m.pool())-1 {
m.rowCur++
}
return m
}

// ── mouse ──

type selHit int

const (
hitNone selHit = iota
hitCat
hitPkg
)

// updateSelectMouse handles clicks and wheel scrolls on the select screen:
// left-click a category to switch to it, left-click a package to toggle it,
// wheel to scroll the package list.
func (m Model) updateSelectMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.selFocus = focusList
if m.rowCur > 0 {
m.rowCur--
}
case tea.MouseButtonWheelDown:
m.selFocus = focusList
if m.rowCur < len(m.pool())-1 {
m.rowCur++
}
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
return m, nil
}
switch kind, idx := m.selectHitTest(msg.X, msg.Y); kind {
case hitCat:
m.catCur, m.query, m.typing = idx, "", false
m.selFocus = focusCats
m.rowCur, m.scroll = 0, 0
case hitPkg:
m.rowCur = idx
m.selFocus = focusList
pool := m.pool()
if idx < len(pool) && !m.isInstalled(pool[idx].Name) {
m.toggle(pool[idx].Name)
}
case hitNone:
// click landed on chrome or blank space — nothing to do
}
default:
// other buttons (middle, right, drag motion) aren't actionable here
return m, nil
}
return m.clampSelScroll(), nil
}

// selectHitTest maps a screen coordinate to a category or package row. The
// geometry mirrors View + selectBody exactly: screen row 0 is the title bar so
// body row = y-1; the sidebar lists categories from body row 3, and the package
// list starts at body row 2 offset by the scroll. Kept a pure, testable
// function so the mapping is verified rather than eyeballed.
func (m Model) selectHitTest(x, y int) (selHit, int) {
bodyRow := y - 1
if bodyRow < 0 {
return hitNone, -1
}
if x < sidebarW {
if ci := bodyRow - 3; ci >= 0 && ci < len(m.cats) {
return hitCat, ci
}
return hitNone, -1
}
if bodyRow < 2 {
return hitNone, -1
}
pool := m.pool()
if pj := m.scroll + bodyRow - 2; pj >= 0 && pj < len(pool) {
return hitPkg, pj
}
return hitNone, -1
}

func (m Model) tryInstall() (tea.Model, tea.Cmd) {
if m.toInstallCount() == 0 {
return m, nil // nothing to install — stay put
Expand Down Expand Up @@ -233,8 +339,15 @@ func (m Model) selectSidebar(h int) []string {
nameStyle := fg(cDim)
countStyle := fg(cDim4)
if active {
edge = fg(cAccent).Render("▎")
nameStyle = fg(cTextHi)
// Bright marker when the sidebar holds focus; muted when the
// package list does, so the active pane reads at a glance.
if m.selFocus == focusCats {
edge = fg(cAccent).Render("▎")
nameStyle = fg(cTextHi)
} else {
edge = fg(cDim2).Render("▎")
nameStyle = fg(cText)
}
}
if selN > 0 {
countStyle = fg(cAccentHi)
Expand Down Expand Up @@ -317,13 +430,14 @@ func (m Model) renderRow(p config.Package, cursor bool, w int) string {
box = fg(cAccent).Render("◉")
}

listFocused := m.selFocus == focusList
nameStyle := fg(cMuted)
switch {
case installed:
nameStyle = fg(cDim2)
case cursor:
case cursor && listFocused:
nameStyle = fg(cWhite).Bold(true)
case on:
case cursor, on:
nameStyle = fg(cText)
}

Expand All @@ -335,7 +449,13 @@ func (m Model) renderRow(p config.Package, cursor bool, w int) string {
name := padTo(nameStyle.Render(p.Name), 20)
rowPrefix := " "
if cursor {
rowPrefix = fg(cAccent).Render("› ")
// Dim the cursor when focus is on the sidebar, so the two panes'
// cursors don't compete for attention.
if listFocused {
rowPrefix = fg(cAccent).Render("› ")
} else {
rowPrefix = fg(cDim3).Render("› ")
}
}
left := rowPrefix + box + " " + name + " " + fg(cDim).Render(p.Description)
return bar(truncCell(left, w-12), tail+" ", w)
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/tui/wizard/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s

case scrSelect:
return "SELECT", cAccent,
"↑↓/jk move · space toggle · ⇥ category · / filter · a all · x clear · ↵ install",
"←→ pane · ↑↓ move · space toggle · / filter · a all · x clear · ↵ install",
fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin())

case scrGit:
Expand Down
18 changes: 17 additions & 1 deletion internal/ui/tui/wizard/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ const (
scrInstall
)

// focusPane is which of the two select-screen columns holds keyboard focus.
// ← → (and tab) move focus between them; ↑ ↓ act on the focused one.
type focusPane int

const (
focusList focusPane = iota // package list (right column) — the default
focusCats // category sidebar (left column)
)

// ErrAborted is returned by Run when the user cancels a running install with
// ctrl+c. It distinguishes a deliberate abort from install failures.
var ErrAborted = errors.New("installation aborted")
Expand Down Expand Up @@ -66,6 +75,7 @@ type Model struct {
scroll int
query string
typing bool
selFocus focusPane
selected map[string]bool

// ── git identity (captured only when none is configured) ──
Expand Down Expand Up @@ -169,6 +179,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateInstall(msg)
}

case tea.MouseMsg:
if m.screen == scrSelect {
return m.updateSelectMouse(msg)
}
return m, nil

case probeDoneMsg:
return m.onProbeDone(msg)

Expand Down Expand Up @@ -326,7 +342,7 @@ func Run(version string, opts *config.InstallOptions) (plan installer.InstallPla
}()
}

p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithOutput(realOut), tea.WithInput(os.Stdin))
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithOutput(realOut), tea.WithInput(os.Stdin))
final, runErr := p.Run()
if runErr != nil {
return installer.InstallPlan{}, false, fmt.Errorf("run wizard: %w", runErr)
Expand Down
Loading
Loading