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
28 changes: 10 additions & 18 deletions ui/bar_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,29 +84,21 @@ func handleSearchInput(app *appstate.State) {
// Only clear selections if the search query actually changed
// This prevents clearing NeedScrollToSel during tab switches when text hasn't changed
if queryChanged {
// Save current selections before clearing them (only if we have a selection)
if app.Commands.SelectedIndex >= 0 {
app.StoreMu.RLock()
// Save current selections before clearing them (single lock for consistent snapshot)
app.StoreMu.RLock()

if app.Commands.SelectedIndex < len(app.Commands.DisplayCommands) {
app.Commands.LastSelectedIndex = app.Commands.SelectedIndex
app.Commands.LastSelectedCmd = app.Commands.DisplayCommands[app.Commands.SelectedIndex].Command
}

app.StoreMu.RUnlock()
if app.Commands.SelectedIndex >= 0 && app.Commands.SelectedIndex < len(app.Commands.DisplayCommands) {
app.Commands.LastSelectedIndex = app.Commands.SelectedIndex
app.Commands.LastSelectedCmd = app.Commands.DisplayCommands[app.Commands.SelectedIndex].Command
}

if app.Tree.SelectedNode >= 0 {
app.StoreMu.RLock()

if app.Tree.SelectedNode < len(app.Tree.Nodes) {
app.Tree.LastSelectedNode = app.Tree.SelectedNode
app.Tree.LastSelectedPath = app.Tree.Nodes[app.Tree.SelectedNode].Path
}

app.StoreMu.RUnlock()
if app.Tree.SelectedNode >= 0 && app.Tree.SelectedNode < len(app.Tree.Nodes) {
app.Tree.LastSelectedNode = app.Tree.SelectedNode
app.Tree.LastSelectedPath = app.Tree.Nodes[app.Tree.SelectedNode].Path
}

app.StoreMu.RUnlock()

// Reset selection when user types (return to search mode)
app.Commands.SelectedIndex = -1 // UI-only state
app.NeedScrollToSel = false
Expand Down
2 changes: 1 addition & 1 deletion ui/keyboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ func findNextWordBoundary(runes []rune, pos int) int {
// total is the number of items. pageSize is items to jump.
// up=true moves toward 0; up=false moves toward total-1.
// Returns current unchanged when total == 0.
func pageJump(current, total, pageSize int, up bool) int { //nolint:unparam
func pageJump(current, total, pageSize int, up bool) int {
if total == 0 {
return current
}
Expand Down
41 changes: 41 additions & 0 deletions ui/keyboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package ui

import "testing"

func TestPageJump(t *testing.T) {
tests := []struct {
name string
current, total, pageSize int
up bool
want int
}{
// No selection
{"no selection page up", -1, 20, 10, true, 19},
{"no selection page down", -1, 20, 10, false, 0},
// Normal movement
{"page down from middle", 5, 20, 10, false, 15},
{"page up from middle", 15, 20, 10, true, 5},
// Clamp at boundaries
{"page up clamps at 0", 3, 20, 10, true, 0},
{"page down clamps at last", 18, 20, 10, false, 19},
// Empty list
{"empty list page up", -1, 0, 10, true, -1},
{"empty list page down", 0, 0, 10, false, 0},
// Single item
{"single item page up", -1, 1, 10, true, 0},
{"single item page down", 0, 1, 10, false, 0},
// Already at boundary
{"at first page up", 0, 10, 5, true, 0},
{"at last page down", 9, 10, 5, false, 9},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pageJump(tt.current, tt.total, tt.pageSize, tt.up)
if got != tt.want {
t.Errorf("pageJump(%d, %d, %d, %v) = %d, want %d",
tt.current, tt.total, tt.pageSize, tt.up, got, tt.want)
}
})
}
}
17 changes: 12 additions & 5 deletions ui/scroll_selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,24 @@ func syncCommandToTreeSelection(app *appstate.State) {

// findCommandMatch searches for a command that matches the given node path
// Returns the index of the best match, or -1 if not found
// matchType: "exact" for exact/prefix matching, "fuzzy" for contains matching
// matchType: matchExact for exact/prefix matching, matchFuzzy for contains matching
//
// NOTE: Linear scan is acceptable for current data sizes
func findCommandMatch(searchList []*model.CommandEntry, nodePath, matchType string) int {
type matchType int

const (
matchExact matchType = iota
matchFuzzy
)

func findCommandMatch(searchList []*model.CommandEntry, nodePath string, mt matchType) int {
foundIndex := -1
bestMatchLen := 0

for i, cmd := range searchList {
var matched bool

if matchType == "fuzzy" {
if mt == matchFuzzy {
// Fuzzy match: command contains node path
matched = strings.Contains(cmd.Command, nodePath)
if matched {
Expand Down Expand Up @@ -260,7 +267,7 @@ func syncTreeToCommandSelection(app *appstate.State) {
searchList := app.Commands.DisplayCommands

// Find the best matching command (prefer exact match or longest prefix)
foundIndex := findCommandMatch(searchList, nodePath, "exact")
foundIndex := findCommandMatch(searchList, nodePath, matchExact)

app.StoreMu.RUnlock()

Expand All @@ -278,7 +285,7 @@ func syncTreeToCommandSelection(app *appstate.State) {

app.StoreMu.RLock()

fuzzyFoundIndex := findCommandMatch(app.Commands.DisplayCommands, nodePath, "fuzzy")
fuzzyFoundIndex := findCommandMatch(app.Commands.DisplayCommands, nodePath, matchFuzzy)

app.StoreMu.RUnlock()

Expand Down
141 changes: 141 additions & 0 deletions ui/scroll_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package ui

import (
"image"
"testing"

"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
)

// makeGtx creates a minimal layout.Context with the given viewport height.
// Uses 1:1 dp-to-px ratio for test simplicity.
func makeGtx(viewportHeight int) C {
var ops op.Ops

return layout.Context{
Ops: &ops,
Constraints: layout.Constraints{
Max: image.Pt(800, viewportHeight),
},
Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1},
}
}

func TestCalculateSmartScrollPositionVariable_AlreadyVisible(t *testing.T) {
gtx := makeGtx(300)
heights := []int{50, 50, 50, 50, 50, 50}

// Item 2 is visible when first=0 (items 0-4 fit in 300px with 20px margin)
newFirst, shouldScroll := calculateSmartScrollPositionVariable(gtx, 0, 2, heights)
if shouldScroll {
t.Errorf("Expected no scroll needed, got newFirst=%d", newFirst)
}
}

func TestCalculateSmartScrollPositionVariable_AboveViewport(t *testing.T) {
gtx := makeGtx(300)
heights := []int{50, 50, 50, 50, 50, 50, 50, 50, 50, 50}

// currentFirst=5, selected=2 (above viewport)
newFirst, shouldScroll := calculateSmartScrollPositionVariable(gtx, 5, 2, heights)
if !shouldScroll {
t.Fatal("Expected scroll needed")
}

if newFirst != 2 {
t.Errorf("Expected newFirst=2, got %d", newFirst)
}
}

func TestCalculateSmartScrollPositionVariable_BelowViewport(t *testing.T) {
gtx := makeGtx(200)
heights := []int{50, 50, 50, 50, 50, 50, 50, 50, 50, 50}

// currentFirst=0, selected=8 (below viewport - only ~3 items visible in 200-20=180px)
newFirst, shouldScroll := calculateSmartScrollPositionVariable(gtx, 0, 8, heights)
if !shouldScroll {
t.Fatal("Expected scroll needed")
}
// Should scroll so item 8 is near the bottom
if newFirst > 8 || newFirst < 5 {
t.Errorf("Expected newFirst between 5 and 8, got %d", newFirst)
}
}

func TestCalculateSmartScrollPositionVariable_EmptyHeights(t *testing.T) {
gtx := makeGtx(300)

newFirst, shouldScroll := calculateSmartScrollPositionVariable(gtx, 0, 0, []int{})
if shouldScroll {
t.Errorf("Expected no scroll for empty heights, got newFirst=%d", newFirst)
}
}

func TestCalculateSmartScrollPositionVariable_NegativeSelected(t *testing.T) {
gtx := makeGtx(300)
heights := []int{50, 50, 50}

newFirst, shouldScroll := calculateSmartScrollPositionVariable(gtx, 0, -1, heights)
if shouldScroll {
t.Errorf("Expected no scroll for negative index, got newFirst=%d", newFirst)
}
}

func TestCalculateSmartScrollPositionVariable_LargeItemExceedsViewport(t *testing.T) {
gtx := makeGtx(100)
heights := []int{50, 50, 200, 50} // item 2 is larger than viewport

newFirst, shouldScroll := calculateSmartScrollPositionVariable(gtx, 0, 2, heights)
if !shouldScroll {
t.Fatal("Expected scroll needed for large item")
}
// When item exceeds viewport, it should be placed at the top
if newFirst != 2 {
t.Errorf("Expected newFirst=2 for large item, got %d", newFirst)
}
}

func TestCalculateSmartScrollPositionVariable_ZeroViewport(t *testing.T) {
gtx := makeGtx(0)
heights := []int{50, 50, 50}

_, shouldScroll := calculateSmartScrollPositionVariable(gtx, 0, 1, heights)
if shouldScroll {
t.Error("Expected no scroll for zero viewport")
}
}

func TestCalculateSmartScrollPositionVariable_UnmeasuredItems(t *testing.T) {
gtx := makeGtx(300)
// Some items unmeasured (0 height) — should use fallback
heights := []int{50, 0, 0, 50, 0, 50, 50, 50, 50, 50}

// Should not panic and should produce valid result
newFirst, _ := calculateSmartScrollPositionVariable(gtx, 0, 8, heights)
if newFirst < 0 || newFirst > 8 {
t.Errorf("Expected valid newFirst, got %d", newFirst)
}
}

func TestEstimateFallbackHeight_AllMeasured(t *testing.T) {
gtx := makeGtx(300)
heights := []int{40, 60, 50}
got := estimateFallbackHeight(gtx, heights)

want := 50 // (40+60+50)/3
if got != want {
t.Errorf("estimateFallbackHeight = %d, want %d", got, want)
}
}

func TestEstimateFallbackHeight_NoneMeasured(t *testing.T) {
gtx := makeGtx(300)
heights := []int{0, 0, 0}
got := estimateFallbackHeight(gtx, heights)
// Should use TreeRowHeight + TreeRowInsetHeight via gtx.Dp
if got <= 0 {
t.Errorf("estimateFallbackHeight = %d, want > 0", got)
}
}
9 changes: 8 additions & 1 deletion ui/tab_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func renderHotkeyMessages(gtx C, app *appstate.State, theme *material.Theme) D {
})
}

if app.Hotkeys.Success {
if app.Hotkeys.Success && app.Hotkeys.SelectedPresetID >= 0 && app.Hotkeys.SelectedPresetID < len(app.Hotkeys.Presets) {
return layout.Inset{Bottom: SpacingMedium}.Layout(gtx, func(gtx C) D {
selectedPreset := app.Hotkeys.Presets[app.Hotkeys.SelectedPresetID]
message := HotKeyCardSuccess + selectedPreset.DisplayName
Expand Down Expand Up @@ -227,6 +227,13 @@ func saveHotkeyPreset(app *appstate.State) {
// ProcessHotkeyUpdate performs the actual hotkey registration and config save.
// Must be called after ev.Frame to avoid a dispatch_sync deadlock on macOS.
func ProcessHotkeyUpdate(app *appstate.State) {
if app.Hotkeys.SelectedPresetID < 0 || app.Hotkeys.SelectedPresetID >= len(app.Hotkeys.Presets) {
app.Hotkeys.Error = "Invalid preset selection"
app.Window.Invalidate()

return
}

preset := app.Hotkeys.Presets[app.Hotkeys.SelectedPresetID]

mods, key, err := hotkey.ConvertStrings(preset.Modifiers, preset.Key)
Expand Down
2 changes: 1 addition & 1 deletion ui/tab_treeview.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ func handleTreeNodePointerEvents(gtx C, app *appstate.State, node *model.TreeDis
if app.Tree.SuppressHover {
app.Tree.SuppressHover = false
needsInvalidate = true
} else {
} else if app.Tree.HoveredNode != index {
app.Tree.HoveredNode = index
needsInvalidate = true
}
Expand Down
Loading