fix(picker): keep the cursor on the previous row when the selected row leaves
- commit
- 417e0242e4556db3b08da8f5deb65d7d8dcbe31e
- author
- brice · · 2026-09-23 19:59 UTC
- parent
- ba2c6f3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +56 -0
M internal/usecase/picker/model.go +32 -0
| @@ -204,11 +204,43 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker | |||
| 204 | 204 | return | |
| 205 | 205 | } | |
| 206 | 206 | key, hadKey := m.cursorKey() | |
| 207 | + | above := m.keysAboveCursor() | |
| 207 | 208 | m.lines = append(m.lines[:0], lines...) | |
| 208 | 209 | if !hadKey { | |
| 209 | 210 | key, hadKey = cursor.Key, cursor.Key != "" | |
| 210 | 211 | } | |
| 211 | 212 | m.rebuild(key, hadKey, cursor.Index) | |
| 213 | + | if hadKey { | |
| 214 | + | if current, ok := m.cursorKey(); !ok || current != key { | |
| 215 | + | // The selected row left the publication (for example it was | |
| 216 | + | // killed): rest on the nearest surviving row above it instead of | |
| 217 | + | // jumping back to the top. | |
| 218 | + | m.selectFirstKey(above) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // keysAboveCursor lists the eligible row keys above the cursor, nearest first. | |
| 224 | + | func (m *Model) keysAboveCursor() []string { | |
| 225 | + | var keys []string | |
| 226 | + | for i := m.selected - 1; i >= 0; i-- { | |
| 227 | + | if m.eligible(i) { | |
| 228 | + | keys = append(keys, m.rows[i].key()) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | return keys | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // selectFirstKey moves the cursor to the first key still present, if any. | |
| 235 | + | func (m *Model) selectFirstKey(keys []string) { | |
| 236 | + | for _, key := range keys { | |
| 237 | + | for idx, candidate := range m.rows { | |
| 238 | + | if m.eligible(idx) && candidate.key() == key { | |
| 239 | + | m.selected = idx | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } | |
| 212 | 244 | } | |
| 213 | 245 | ||
| 214 | 246 | // ReplaceProjection atomically changes the daemon-authored projection and its | |
| @@ -204,11 +204,43 @@ func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.Picker | |||||
| 204 | return | 204 | return | ||
| 205 | } | 205 | } | ||
| 206 | key, hadKey := m.cursorKey() | 206 | key, hadKey := m.cursorKey() | ||
| 207 | + | above := m.keysAboveCursor() | |||
| 207 | m.lines = append(m.lines[:0], lines...) | 208 | m.lines = append(m.lines[:0], lines...) | ||
| 208 | if !hadKey { | 209 | if !hadKey { | ||
| 209 | key, hadKey = cursor.Key, cursor.Key != "" | 210 | key, hadKey = cursor.Key, cursor.Key != "" | ||
| 210 | } | 211 | } | ||
| 211 | m.rebuild(key, hadKey, cursor.Index) | 212 | m.rebuild(key, hadKey, cursor.Index) | ||
| 213 | + | if hadKey { | |||
| 214 | + | if current, ok := m.cursorKey(); !ok || current != key { | |||
| 215 | + | // The selected row left the publication (for example it was | |||
| 216 | + | // killed): rest on the nearest surviving row above it instead of | |||
| 217 | + | // jumping back to the top. | |||
| 218 | + | m.selectFirstKey(above) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // keysAboveCursor lists the eligible row keys above the cursor, nearest first. | |||
| 224 | + | func (m *Model) keysAboveCursor() []string { | |||
| 225 | + | var keys []string | |||
| 226 | + | for i := m.selected - 1; i >= 0; i-- { | |||
| 227 | + | if m.eligible(i) { | |||
| 228 | + | keys = append(keys, m.rows[i].key()) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | return keys | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // selectFirstKey moves the cursor to the first key still present, if any. | |||
| 235 | + | func (m *Model) selectFirstKey(keys []string) { | |||
| 236 | + | for _, key := range keys { | |||
| 237 | + | for idx, candidate := range m.rows { | |||
| 238 | + | if m.eligible(idx) && candidate.key() == key { | |||
| 239 | + | m.selected = idx | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 212 | } | 244 | } | ||
| 213 | 245 | ||||
| 214 | // ReplaceProjection atomically changes the daemon-authored projection and its | 246 | // ReplaceProjection atomically changes the daemon-authored projection and its | ||
M internal/usecase/picker/model_test.go +24 -0
| @@ -435,3 +435,27 @@ func rowText(row []renderer.Cell) string { | |||
| 435 | 435 | } | |
| 436 | 436 | return string(out) | |
| 437 | 437 | } | |
| 438 | + | ||
| 439 | + | func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) { | |
| 440 | + | all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")} | |
| 441 | + | tests := []struct { | |
| 442 | + | name string | |
| 443 | + | cursor string | |
| 444 | + | next []protocol.PickerLine | |
| 445 | + | want string | |
| 446 | + | }{ | |
| 447 | + | {name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"}, | |
| 448 | + | {name: "last row killed", cursor: "d", next: all[:3], want: "c"}, | |
| 449 | + | {name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"}, | |
| 450 | + | {name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"}, | |
| 451 | + | } | |
| 452 | + | for _, tt := range tests { | |
| 453 | + | t.Run(tt.name, func(t *testing.T) { | |
| 454 | + | m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}}) | |
| 455 | + | m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1}) | |
| 456 | + | selected, ok := m.Selected() | |
| 457 | + | require.True(t, ok) | |
| 458 | + | require.Equal(t, tt.want, selected.Key) | |
| 459 | + | }) | |
| 460 | + | } | |
| 461 | + | } | |
| @@ -435,3 +435,27 @@ func rowText(row []renderer.Cell) string { | |||||
| 435 | } | 435 | } | ||
| 436 | return string(out) | 436 | return string(out) | ||
| 437 | } | 437 | } | ||
| 438 | + | ||||
| 439 | + | func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) { | |||
| 440 | + | all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")} | |||
| 441 | + | tests := []struct { | |||
| 442 | + | name string | |||
| 443 | + | cursor string | |||
| 444 | + | next []protocol.PickerLine | |||
| 445 | + | want string | |||
| 446 | + | }{ | |||
| 447 | + | {name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"}, | |||
| 448 | + | {name: "last row killed", cursor: "d", next: all[:3], want: "c"}, | |||
| 449 | + | {name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"}, | |||
| 450 | + | {name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"}, | |||
| 451 | + | } | |||
| 452 | + | for _, tt := range tests { | |||
| 453 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 454 | + | m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}}) | |||
| 455 | + | m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1}) | |||
| 456 | + | selected, ok := m.Selected() | |||
| 457 | + | require.True(t, ok) | |||
| 458 | + | require.Equal(t, tt.want, selected.Key) | |||
| 459 | + | }) | |||
| 460 | + | } | |||
| 461 | + | } | |||