feat(picker): fold host sections and filter search to matches
Section headers are cursor stops in the client picker: h/Left climbs to the section and collapses it, l/Right expands it, Enter toggles it. Folds are local presentation state kept across publications. A search query now draws only matching rows with their section and session headers, in tree order, and puts the cursor on the first match instead of jumping to the best-scored row.
- commit
- 1ccf6c758be7caa744fc3637c33b4ea5b3c9634c
- author
- brice · · 2026-10-08 17:19 UTC
- parent
- a054d45
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +444 -112
M internal/usecase/client/picker_controller_test.go +7 -2
| @@ -245,7 +245,12 @@ func TestPickerControllerInputRouting(t *testing.T) { | |||
| 245 | 245 | wantOp pickerOp | |
| 246 | 246 | }{ | |
| 247 | 247 | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | |
| 248 | - | {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"}, | |
| 248 | + | {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true}, | |
| 249 | + | {name: "left climbs to the section header", chunk: []byte("\x1b[D"), wantNoSelection: true}, | |
| 250 | + | {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true}, | |
| 251 | + | {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"}, | |
| 252 | + | {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"}, | |
| 253 | + | {name: "h and l are literal in search", chunk: []byte("/hl"), wantNoSelection: true, wantSearch: true, wantQuery: "hl"}, | |
| 249 | 254 | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | |
| 250 | 255 | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | |
| 251 | 256 | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | |
| @@ -280,7 +285,7 @@ func TestPickerControllerInputRouting(t *testing.T) { | |||
| 280 | 285 | } | |
| 281 | 286 | if tt.wantNoSelection { | |
| 282 | 287 | _, ok := controller.CursorKey() | |
| 283 | - | require.False(t, ok, "the host row is a cursor destination but not a selection") | |
| 288 | + | require.False(t, ok, "the cursor row is not a selection") | |
| 284 | 289 | return | |
| 285 | 290 | } | |
| 286 | 291 | want := alphaKey | |
| @@ -245,7 +245,12 @@ func TestPickerControllerInputRouting(t *testing.T) { | |||||
| 245 | wantOp pickerOp | 245 | wantOp pickerOp | ||
| 246 | }{ | 246 | }{ | ||
| 247 | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | 247 | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | ||
| 248 | - | {name: "up skips informational host row", chunk: []byte("\x1b[A"), wantCursor: "alpha"}, | 248 | + | {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true}, |
| 249 | + | {name: "left climbs to the section header", chunk: []byte("\x1b[D"), wantNoSelection: true}, | |||
| 250 | + | {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true}, | |||
| 251 | + | {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"}, | |||
| 252 | + | {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"}, | |||
| 253 | + | {name: "h and l are literal in search", chunk: []byte("/hl"), wantNoSelection: true, wantSearch: true, wantQuery: "hl"}, | |||
| 249 | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | 254 | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | ||
| 250 | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | 255 | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | ||
| 251 | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | 256 | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | ||
| @@ -280,7 +285,7 @@ func TestPickerControllerInputRouting(t *testing.T) { | |||||
| 280 | } | 285 | } | ||
| 281 | if tt.wantNoSelection { | 286 | if tt.wantNoSelection { | ||
| 282 | _, ok := controller.CursorKey() | 287 | _, ok := controller.CursorKey() | ||
| 283 | - | require.False(t, ok, "the host row is a cursor destination but not a selection") | 288 | + | require.False(t, ok, "the cursor row is not a selection") |
| 284 | return | 289 | return | ||
| 285 | } | 290 | } | ||
| 286 | want := alphaKey | 291 | want := alphaKey | ||
M internal/usecase/client/picker_input.go +6 -0
| @@ -292,6 +292,12 @@ func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byt | |||
| 292 | 292 | case 'B': | |
| 293 | 293 | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"}) | |
| 294 | 294 | return 3, nil, false | |
| 295 | + | case 'C': | |
| 296 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Right"}) | |
| 297 | + | return 3, nil, false | |
| 298 | + | case 'D': | |
| 299 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Left"}) | |
| 300 | + | return 3, nil, false | |
| 295 | 301 | } | |
| 296 | 302 | // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is | |
| 297 | 303 | // consumed through its final byte and dropped. | |
| @@ -292,6 +292,12 @@ func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byt | |||||
| 292 | case 'B': | 292 | case 'B': | ||
| 293 | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"}) | 293 | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"}) | ||
| 294 | return 3, nil, false | 294 | return 3, nil, false | ||
| 295 | + | case 'C': | |||
| 296 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Right"}) | |||
| 297 | + | return 3, nil, false | |||
| 298 | + | case 'D': | |||
| 299 | + | batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Left"}) | |||
| 300 | + | return 3, nil, false | |||
| 295 | } | 301 | } | ||
| 296 | // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is | 302 | // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is | ||
| 297 | // consumed through its final byte and dropped. | 303 | // consumed through its final byte and dropped. | ||
M internal/usecase/client/picker_interaction.go +24 -1
| @@ -523,7 +523,9 @@ type pickerOp struct { | |||
| 523 | 523 | ||
| 524 | 524 | // drive applies one ui-driver operation to an open loop. Keys | |
| 525 | 525 | // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are | |
| 526 | - | // literal in search), `/` enters search, `s` reorders locally, `x` asks to | |
| 526 | + | // literal in search), Left/Right and h/l fold the section header under the | |
| 527 | + | // cursor (Left/h from a row first climbs to its section), Enter on a section | |
| 528 | + | // header toggles it, `/` enters search, `s` reorders locally, `x` asks to | |
| 527 | 529 | // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape | |
| 528 | 530 | // exits search first), Backspace edits the query. Text runes insert only | |
| 529 | 531 | // while search is active; normal-mode typing is ignored exactly like the | |
| @@ -558,7 +560,28 @@ func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed | |||
| 558 | 560 | } else { | |
| 559 | 561 | loop.up() | |
| 560 | 562 | } | |
| 563 | + | case "Left": | |
| 564 | + | changed = loop.model.Left() || changed | |
| 565 | + | case "Right": | |
| 566 | + | changed = loop.model.Right() || changed | |
| 567 | + | case "h": | |
| 568 | + | if active { | |
| 569 | + | loop.insert('h') | |
| 570 | + | } else { | |
| 571 | + | changed = loop.model.Left() || changed | |
| 572 | + | } | |
| 573 | + | case "l": | |
| 574 | + | if active { | |
| 575 | + | loop.insert('l') | |
| 576 | + | } else { | |
| 577 | + | changed = loop.model.Right() || changed | |
| 578 | + | } | |
| 561 | 579 | case "Enter": | |
| 580 | + | // Enter on a section header folds it; it names no destination. | |
| 581 | + | if loop.model.CursorOnSection() { | |
| 582 | + | changed = loop.model.ToggleSection() || changed | |
| 583 | + | continue | |
| 584 | + | } | |
| 562 | 585 | // Commit is typed at the call site via selection; the | |
| 563 | 586 | // key itself sends zero bytes toward the PTY. | |
| 564 | 587 | op.commit = true | |
| @@ -523,7 +523,9 @@ type pickerOp struct { | |||||
| 523 | 523 | ||||
| 524 | // drive applies one ui-driver operation to an open loop. Keys | 524 | // drive applies one ui-driver operation to an open loop. Keys | ||
| 525 | // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are | 525 | // drive cursor/search/exit: arrows and j/k move in normal mode (j/k are | ||
| 526 | - | // literal in search), `/` enters search, `s` reorders locally, `x` asks to | 526 | + | // literal in search), Left/Right and h/l fold the section header under the |
| 527 | + | // cursor (Left/h from a row first climbs to its section), Enter on a section | |||
| 528 | + | // header toggles it, `/` enters search, `s` reorders locally, `x` asks to | |||
| 527 | // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape | 529 | // destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape | ||
| 528 | // exits search first), Backspace edits the query. Text runes insert only | 530 | // exits search first), Backspace edits the query. Text runes insert only | ||
| 529 | // while search is active; normal-mode typing is ignored exactly like the | 531 | // while search is active; normal-mode typing is ignored exactly like the | ||
| @@ -558,7 +560,28 @@ func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed | |||||
| 558 | } else { | 560 | } else { | ||
| 559 | loop.up() | 561 | loop.up() | ||
| 560 | } | 562 | } | ||
| 563 | + | case "Left": | |||
| 564 | + | changed = loop.model.Left() || changed | |||
| 565 | + | case "Right": | |||
| 566 | + | changed = loop.model.Right() || changed | |||
| 567 | + | case "h": | |||
| 568 | + | if active { | |||
| 569 | + | loop.insert('h') | |||
| 570 | + | } else { | |||
| 571 | + | changed = loop.model.Left() || changed | |||
| 572 | + | } | |||
| 573 | + | case "l": | |||
| 574 | + | if active { | |||
| 575 | + | loop.insert('l') | |||
| 576 | + | } else { | |||
| 577 | + | changed = loop.model.Right() || changed | |||
| 578 | + | } | |||
| 561 | case "Enter": | 579 | case "Enter": | ||
| 580 | + | // Enter on a section header folds it; it names no destination. | |||
| 581 | + | if loop.model.CursorOnSection() { | |||
| 582 | + | changed = loop.model.ToggleSection() || changed | |||
| 583 | + | continue | |||
| 584 | + | } | |||
| 562 | // Commit is typed at the call site via selection; the | 585 | // Commit is typed at the call site via selection; the | ||
| 563 | // key itself sends zero bytes toward the PTY. | 586 | // key itself sends zero bytes toward the PTY. | ||
| 564 | op.commit = true | 587 | op.commit = true | ||
M internal/usecase/picker/model.go +238 -52
| @@ -2,6 +2,7 @@ package picker | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "slices" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 7 | 8 | renderer "github.com/bnema/vev-vt" | |
| @@ -142,6 +143,14 @@ type Model struct { | |||
| 142 | 143 | query ui.TextInput | |
| 143 | 144 | searchMatches map[int]searchMatch | |
| 144 | 145 | matchRows []int | |
| 146 | + | // folded names the section headers the user collapsed. It is local | |
| 147 | + | // presentation state, kept across publications by section label. | |
| 148 | + | folded map[string]bool | |
| 149 | + | // hidden and view describe which rows are drawn: a collapsed section hides | |
| 150 | + | // its rows, and a non-empty query shows only matches and their ancestors. | |
| 151 | + | // Both are replaced, never mutated, so clones may share them. | |
| 152 | + | hidden []bool | |
| 153 | + | view []int | |
| 145 | 154 | } | |
| 146 | 155 | ||
| 147 | 156 | // row is one rendered line plus its case-folded search fields. | |
| @@ -167,11 +176,21 @@ func (r row) selectable() bool { return r.line.Actions != 0 } | |||
| 167 | 176 | // focusable reports the source's authorisation to rest the cursor on this row. | |
| 168 | 177 | // A section header is never a destination; every other shape follows the | |
| 169 | 178 | // published flag, so a row the source kept for inspection stays reachable while | |
| 170 | - | // a row it skipped is passed over. | |
| 179 | + | // a row it skipped is passed over. The model lets the cursor rest on a section | |
| 180 | + | // header on its own, only to fold it (see eligible). | |
| 171 | 181 | func (r row) focusable() bool { | |
| 172 | 182 | return r.line.Kind != protocol.PickerLineSection && r.line.Focusable | |
| 173 | 183 | } | |
| 174 | 184 | ||
| 185 | + | // id names the row for cursor restoration. A section has no source key, so | |
| 186 | + | // it is named by its label in a namespace no source key can use. | |
| 187 | + | func (r row) id() string { | |
| 188 | + | if r.section() { | |
| 189 | + | return "\x00section:" + r.line.Label | |
| 190 | + | } | |
| 191 | + | return r.line.Key | |
| 192 | + | } | |
| 193 | + | ||
| 175 | 194 | func (r row) rendersAsHeader() bool { | |
| 176 | 195 | return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost | |
| 177 | 196 | } | |
| @@ -248,7 +267,7 @@ func (m *Model) keysAboveCursor() []string { | |||
| 248 | 267 | var keys []string | |
| 249 | 268 | for i := m.selected - 1; i >= 0; i-- { | |
| 250 | 269 | if m.eligible(i) { | |
| 251 | - | keys = append(keys, m.rows[i].key()) | |
| 270 | + | keys = append(keys, m.rows[i].id()) | |
| 252 | 271 | } | |
| 253 | 272 | } | |
| 254 | 273 | return keys | |
| @@ -259,7 +278,7 @@ func (m *Model) selectFirstKey(keys []string) { | |||
| 259 | 278 | index := make(map[string]int, len(m.rows)) | |
| 260 | 279 | for idx, candidate := range m.rows { | |
| 261 | 280 | if m.eligible(idx) { | |
| 262 | - | index[candidate.key()] = idx | |
| 281 | + | index[candidate.id()] = idx | |
| 263 | 282 | } | |
| 264 | 283 | } | |
| 265 | 284 | for _, key := range keys { | |
| @@ -319,30 +338,159 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) { | |||
| 319 | 338 | session = "" | |
| 320 | 339 | } | |
| 321 | 340 | } | |
| 322 | - | m.assignTreePrefixes() | |
| 323 | 341 | m.searchMatches = nil | |
| 324 | 342 | m.matchRows = nil | |
| 325 | 343 | if m.searchActive { | |
| 326 | 344 | m.refreshSearch(false) | |
| 345 | + | } else { | |
| 346 | + | m.refreshView() | |
| 327 | 347 | } | |
| 328 | 348 | m.restoreSelection(key, hadKey, fallbackIndex) | |
| 329 | 349 | } | |
| 330 | 350 | ||
| 331 | - | // assignTreePrefixes gives each row its tree branch: sessions and hosts hang | |
| 332 | - | // off their section, tabs off their session. The last sibling closes its | |
| 333 | - | // branch with └─ and its children no longer continue the parent's │. | |
| 351 | + | // refreshView recomputes which rows are drawn. A non-empty query shows only | |
| 352 | + | // the matching rows plus their section and session headers, in tree order, and | |
| 353 | + | // ignores folds. Otherwise every row is drawn except those under a collapsed | |
| 354 | + | // section. | |
| 355 | + | func (m *Model) refreshView() { | |
| 356 | + | hidden := make([]bool, len(m.rows)) | |
| 357 | + | if m.searchRestricted() { | |
| 358 | + | section, session := -1, -1 | |
| 359 | + | for i, r := range m.rows { | |
| 360 | + | hidden[i] = true | |
| 361 | + | switch r.kind() { | |
| 362 | + | case protocol.PickerLineSection: | |
| 363 | + | section, session = i, -1 | |
| 364 | + | continue | |
| 365 | + | case protocol.PickerLineSession: | |
| 366 | + | session = i | |
| 367 | + | case protocol.PickerLineTab: | |
| 368 | + | default: | |
| 369 | + | session = -1 | |
| 370 | + | } | |
| 371 | + | if _, ok := m.searchMatches[i]; !ok { | |
| 372 | + | continue | |
| 373 | + | } | |
| 374 | + | hidden[i] = false | |
| 375 | + | if section >= 0 { | |
| 376 | + | hidden[section] = false | |
| 377 | + | } | |
| 378 | + | if r.kind() == protocol.PickerLineTab && session >= 0 { | |
| 379 | + | hidden[session] = false | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | } else { | |
| 383 | + | folded := false | |
| 384 | + | for i, r := range m.rows { | |
| 385 | + | if r.section() { | |
| 386 | + | folded = m.folded[r.line.Label] | |
| 387 | + | continue | |
| 388 | + | } | |
| 389 | + | hidden[i] = folded | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | view := make([]int, 0, len(m.rows)) | |
| 393 | + | for i := range m.rows { | |
| 394 | + | if !hidden[i] { | |
| 395 | + | view = append(view, i) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | m.hidden, m.view = hidden, view | |
| 399 | + | m.assignTreePrefixes() | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // sectionFolded reports whether section row i is drawn collapsed. A query | |
| 403 | + | // shows matches regardless of folds, so it never draws a section collapsed. | |
| 404 | + | func (m *Model) sectionFolded(i int) bool { | |
| 405 | + | return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].line.Label] | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // sectionOf reports the section header row i belongs to, or -1. | |
| 409 | + | func (m *Model) sectionOf(i int) int { | |
| 410 | + | for j := i; j >= 0; j-- { | |
| 411 | + | if m.rows[j].section() { | |
| 412 | + | return j | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | return -1 | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // setFold collapses or expands the section under the cursor and reports | |
| 419 | + | // whether anything changed. It never applies while a query filters the list. | |
| 420 | + | func (m *Model) setFold(fold bool) bool { | |
| 421 | + | if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() { | |
| 422 | + | return false | |
| 423 | + | } | |
| 424 | + | label := m.rows[m.selected].line.Label | |
| 425 | + | if m.folded[label] == fold { | |
| 426 | + | return false | |
| 427 | + | } | |
| 428 | + | folded := make(map[string]bool, len(m.folded)+1) | |
| 429 | + | for k, v := range m.folded { | |
| 430 | + | folded[k] = v | |
| 431 | + | } | |
| 432 | + | if fold { | |
| 433 | + | folded[label] = true | |
| 434 | + | } else { | |
| 435 | + | delete(folded, label) | |
| 436 | + | } | |
| 437 | + | m.folded = folded | |
| 438 | + | m.refreshView() | |
| 439 | + | return true | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // CursorOnSection reports whether the cursor rests on a section header. | |
| 443 | + | func (m *Model) CursorOnSection() bool { | |
| 444 | + | return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section() | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // ToggleSection collapses or expands the section header under the cursor and | |
| 448 | + | // reports whether it did. | |
| 449 | + | func (m *Model) ToggleSection() bool { | |
| 450 | + | if !m.CursorOnSection() { | |
| 451 | + | return false | |
| 452 | + | } | |
| 453 | + | return m.setFold(!m.folded[m.rows[m.selected].line.Label]) | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | // Left collapses the section under the cursor, or moves the cursor from a row | |
| 457 | + | // up to its section header. It reports whether anything changed. | |
| 458 | + | func (m *Model) Left() bool { | |
| 459 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |
| 460 | + | return false | |
| 461 | + | } | |
| 462 | + | if m.rows[m.selected].section() { | |
| 463 | + | return m.setFold(true) | |
| 464 | + | } | |
| 465 | + | if section := m.sectionOf(m.selected); m.eligible(section) { | |
| 466 | + | m.selected = section | |
| 467 | + | return true | |
| 468 | + | } | |
| 469 | + | return false | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // Right expands the section under the cursor and reports whether it did. | |
| 473 | + | func (m *Model) Right() bool { | |
| 474 | + | return m.setFold(false) | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | // assignTreePrefixes gives each drawn row its tree branch: sessions and hosts | |
| 478 | + | // hang off their section, tabs off their session. The last drawn sibling | |
| 479 | + | // closes its branch with └─ and its children no longer continue the parent's │. | |
| 334 | 480 | func (m *Model) assignTreePrefixes() { | |
| 335 | - | isHead := func(r row) bool { return r.rendersAsHeader() } | |
| 336 | 481 | parentLast := true | |
| 337 | - | for i := range m.rows { | |
| 482 | + | for p, i := range m.view { | |
| 338 | 483 | r := &m.rows[i] | |
| 339 | 484 | switch { | |
| 340 | 485 | case r.section(): | |
| 341 | 486 | r.tree = "" | |
| 342 | - | case isHead(*r): | |
| 487 | + | case r.rendersAsHeader(): | |
| 343 | 488 | last := true | |
| 344 | - | for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ { | |
| 345 | - | if isHead(m.rows[j]) { | |
| 489 | + | for _, j := range m.view[p+1:] { | |
| 490 | + | if m.rows[j].section() { | |
| 491 | + | break | |
| 492 | + | } | |
| 493 | + | if m.rows[j].rendersAsHeader() { | |
| 346 | 494 | last = false | |
| 347 | 495 | break | |
| 348 | 496 | } | |
| @@ -350,7 +498,7 @@ func (m *Model) assignTreePrefixes() { | |||
| 350 | 498 | parentLast = last | |
| 351 | 499 | r.tree = treeBranch(last) | |
| 352 | 500 | case r.kind() == protocol.PickerLineTab: | |
| 353 | - | last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab | |
| 501 | + | last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab | |
| 354 | 502 | stem := "│ " | |
| 355 | 503 | if parentLast { | |
| 356 | 504 | stem = " " | |
| @@ -391,11 +539,20 @@ func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine { | |||
| 391 | 539 | func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) { | |
| 392 | 540 | if hadKey { | |
| 393 | 541 | for idx, candidate := range m.rows { | |
| 394 | - | if m.eligible(idx) && candidate.key() == key { | |
| 542 | + | if m.eligible(idx) && candidate.id() == key { | |
| 395 | 543 | m.selected = idx | |
| 396 | 544 | return | |
| 397 | 545 | } | |
| 398 | 546 | } | |
| 547 | + | // A row inside a collapsed section keeps the cursor on that section. | |
| 548 | + | for idx, candidate := range m.rows { | |
| 549 | + | if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] { | |
| 550 | + | if section := m.sectionOf(idx); m.eligible(section) { | |
| 551 | + | m.selected = section | |
| 552 | + | return | |
| 553 | + | } | |
| 554 | + | } | |
| 555 | + | } | |
| 399 | 556 | } | |
| 400 | 557 | m.selected = -1 | |
| 401 | 558 | m.SelectNearestRow(fallbackIndex) | |
| @@ -410,15 +567,28 @@ func (m *Model) searchRestricted() bool { | |||
| 410 | 567 | return m != nil && m.searchActive && m.query.Value() != "" | |
| 411 | 568 | } | |
| 412 | 569 | ||
| 413 | - | // eligible reports whether the cursor may rest on row i: the source authorised | |
| 414 | - | // the row as a destination and the typed query, when there is one, matches it. | |
| 570 | + | // eligible reports whether the cursor may rest on row i: the row is drawn, | |
| 571 | + | // and either the source authorised it as a destination and the typed query, | |
| 572 | + | // when there is one, matches it, or it is a section header outside a query. | |
| 415 | 573 | func (m *Model) eligible(i int) bool { | |
| 416 | - | if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() { | |
| 574 | + | if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] { | |
| 575 | + | return false | |
| 576 | + | } | |
| 577 | + | if m.rows[i].section() { | |
| 578 | + | return !m.searchRestricted() | |
| 579 | + | } | |
| 580 | + | if !m.rows[i].focusable() { | |
| 417 | 581 | return false | |
| 418 | 582 | } | |
| 419 | 583 | return !m.searchRestricted() || m.rowMatches(i) | |
| 420 | 584 | } | |
| 421 | 585 | ||
| 586 | + | // target reports whether the cursor may fall back to row i on its own: an | |
| 587 | + | // eligible row that is not a section header. | |
| 588 | + | func (m *Model) target(i int) bool { | |
| 589 | + | return m.eligible(i) && !m.rows[i].section() | |
| 590 | + | } | |
| 591 | + | ||
| 422 | 592 | // committable reports whether row i may be committed: it admits an action and | |
| 423 | 593 | // the typed query, when there is one, does not exclude it. | |
| 424 | 594 | func (m *Model) committable(i int) bool { | |
| @@ -432,24 +602,12 @@ func (m *Model) cursorKey() (string, bool) { | |||
| 432 | 602 | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |
| 433 | 603 | return "", false | |
| 434 | 604 | } | |
| 435 | - | return m.rows[m.selected].key(), true | |
| 605 | + | return m.rows[m.selected].id(), true | |
| 436 | 606 | } | |
| 437 | 607 | ||
| 438 | - | func (m *Model) Up() { | |
| 439 | - | if m != nil && m.searchActive { | |
| 440 | - | m.moveSearch(-1) | |
| 441 | - | return | |
| 442 | - | } | |
| 443 | - | m.move(-1) | |
| 444 | - | } | |
| 608 | + | func (m *Model) Up() { m.move(-1) } | |
| 445 | 609 | ||
| 446 | - | func (m *Model) Down() { | |
| 447 | - | if m != nil && m.searchActive { | |
| 448 | - | m.moveSearch(1) | |
| 449 | - | return | |
| 450 | - | } | |
| 451 | - | m.move(1) | |
| 452 | - | } | |
| 610 | + | func (m *Model) Down() { m.move(1) } | |
| 453 | 611 | ||
| 454 | 612 | // Cursor reports the line under the picker cursor independently from whether | |
| 455 | 613 | // that row may be activated, so a refresh can preserve an anchor row. | |
| @@ -481,22 +639,25 @@ func (m *Model) SelectedIndex() int { | |||
| 481 | 639 | } | |
| 482 | 640 | ||
| 483 | 641 | // SelectNearestRow selects the nearest eligible row at or after idx, falling | |
| 484 | - | // back to the last eligible row before it. | |
| 642 | + | // back to the last eligible row before it. Section headers are taken only when | |
| 643 | + | // no other row qualifies. | |
| 485 | 644 | func (m *Model) SelectNearestRow(idx int) { | |
| 486 | 645 | if m == nil || len(m.rows) == 0 { | |
| 487 | 646 | return | |
| 488 | 647 | } | |
| 489 | 648 | idx = clamp(idx, 0, len(m.rows)-1) | |
| 490 | - | for i := idx; i < len(m.rows); i++ { | |
| 491 | - | if m.eligible(i) { | |
| 492 | - | m.selected = i | |
| 493 | - | return | |
| 649 | + | for _, ok := range []func(int) bool{m.target, m.eligible} { | |
| 650 | + | for i := idx; i < len(m.rows); i++ { | |
| 651 | + | if ok(i) { | |
| 652 | + | m.selected = i | |
| 653 | + | return | |
| 654 | + | } | |
| 494 | 655 | } | |
| 495 | - | } | |
| 496 | - | for i := idx - 1; i >= 0; i-- { | |
| 497 | - | if m.eligible(i) { | |
| 498 | - | m.selected = i | |
| 499 | - | return | |
| 656 | + | for i := idx - 1; i >= 0; i-- { | |
| 657 | + | if ok(i) { | |
| 658 | + | m.selected = i | |
| 659 | + | return | |
| 660 | + | } | |
| 500 | 661 | } | |
| 501 | 662 | } | |
| 502 | 663 | } | |
| @@ -552,10 +713,14 @@ func (m *Model) move(delta int) { | |||
| 552 | 713 | } | |
| 553 | 714 | } | |
| 554 | 715 | ||
| 716 | + | // firstEligible reports the first row the cursor may rest on, preferring any | |
| 717 | + | // row over a section header. | |
| 555 | 718 | func (m *Model) firstEligible() int { | |
| 556 | - | for i := range m.rows { | |
| 557 | - | if m.eligible(i) { | |
| 558 | - | return i | |
| 719 | + | for _, ok := range []func(int) bool{m.target, m.eligible} { | |
| 720 | + | for i := range m.rows { | |
| 721 | + | if ok(i) { | |
| 722 | + | return i | |
| 723 | + | } | |
| 559 | 724 | } | |
| 560 | 725 | } | |
| 561 | 726 | return -1 | |
| @@ -634,10 +799,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 634 | 799 | offset := m.scrollOffset(visible) | |
| 635 | 800 | clipX := rect.X + rect.Width | |
| 636 | 801 | for y := range visible { | |
| 637 | - | idx := offset + y | |
| 638 | - | if idx >= len(m.rows) { | |
| 802 | + | if offset+y >= len(m.view) { | |
| 639 | 803 | break | |
| 640 | 804 | } | |
| 805 | + | idx := m.view[offset+y] | |
| 641 | 806 | r := m.rows[idx] | |
| 642 | 807 | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | |
| 643 | 808 | if r.section() { | |
| @@ -649,7 +814,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 649 | 814 | if r.line.Stopped && idx != m.selected { | |
| 650 | 815 | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | |
| 651 | 816 | } | |
| 652 | - | if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) { | |
| 817 | + | if r.line.Dim && !(r.section() && idx == m.selected) { | |
| 653 | 818 | base.Attrs |= renderer.AttrDim | |
| 654 | 819 | nameStyle.Attrs |= renderer.AttrDim | |
| 655 | 820 | detailStyle.Attrs |= renderer.AttrDim | |
| @@ -659,6 +824,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 659 | 824 | name := r.line.Label | |
| 660 | 825 | if r.section() { | |
| 661 | 826 | nameStyle.Bold = true | |
| 827 | + | marker := "▾ " | |
| 828 | + | if m.sectionFolded(idx) { | |
| 829 | + | marker = "▸ " | |
| 830 | + | name += fmt.Sprintf(" (%d)", m.sectionSessions(idx)) | |
| 831 | + | } | |
| 832 | + | name = marker + name | |
| 662 | 833 | } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped { | |
| 663 | 834 | nameStyle.Bold = true | |
| 664 | 835 | } | |
| @@ -796,6 +967,9 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende | |||
| 796 | 967 | if action == "" { | |
| 797 | 968 | enter = "Enter unavailable" | |
| 798 | 969 | } | |
| 970 | + | if m != nil && m.CursorOnSection() { | |
| 971 | + | enter = "Enter/h/l fold" | |
| 972 | + | } | |
| 799 | 973 | var groups []string | |
| 800 | 974 | if m != nil && m.searchActive { | |
| 801 | 975 | groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"} | |
| @@ -844,15 +1018,27 @@ func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string { | |||
| 844 | 1018 | return "open" | |
| 845 | 1019 | } | |
| 846 | 1020 | ||
| 1021 | + | // scrollOffset is the first drawn position so the cursor row stays visible. | |
| 847 | 1022 | func (m *Model) scrollOffset(visible int) int { | |
| 848 | - | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | |
| 1023 | + | if visible <= 0 || len(m.view) <= visible || m.selected < 0 { | |
| 849 | 1024 | return 0 | |
| 850 | 1025 | } | |
| 851 | - | if m.selected < visible { | |
| 1026 | + | position := slices.Index(m.view, m.selected) | |
| 1027 | + | if position < visible { | |
| 852 | 1028 | return 0 | |
| 853 | 1029 | } | |
| 854 | - | offset := m.selected - visible + 1 | |
| 855 | - | return min(offset, len(m.rows)-visible) | |
| 1030 | + | return min(position-visible+1, len(m.view)-visible) | |
| 1031 | + | } | |
| 1032 | + | ||
| 1033 | + | // sectionSessions counts the session and host rows under section row i. | |
| 1034 | + | func (m *Model) sectionSessions(i int) int { | |
| 1035 | + | count := 0 | |
| 1036 | + | for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ { | |
| 1037 | + | if m.rows[j].rendersAsHeader() { | |
| 1038 | + | count++ | |
| 1039 | + | } | |
| 1040 | + | } | |
| 1041 | + | return count | |
| 856 | 1042 | } | |
| 857 | 1043 | ||
| 858 | 1044 | func clamp(n, low, high int) int { | |
| @@ -2,6 +2,7 @@ package picker | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "slices" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | 7 | ||||
| 7 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| @@ -142,6 +143,14 @@ type Model struct { | |||||
| 142 | query ui.TextInput | 143 | query ui.TextInput | ||
| 143 | searchMatches map[int]searchMatch | 144 | searchMatches map[int]searchMatch | ||
| 144 | matchRows []int | 145 | matchRows []int | ||
| 146 | + | // folded names the section headers the user collapsed. It is local | |||
| 147 | + | // presentation state, kept across publications by section label. | |||
| 148 | + | folded map[string]bool | |||
| 149 | + | // hidden and view describe which rows are drawn: a collapsed section hides | |||
| 150 | + | // its rows, and a non-empty query shows only matches and their ancestors. | |||
| 151 | + | // Both are replaced, never mutated, so clones may share them. | |||
| 152 | + | hidden []bool | |||
| 153 | + | view []int | |||
| 145 | } | 154 | } | ||
| 146 | 155 | ||||
| 147 | // row is one rendered line plus its case-folded search fields. | 156 | // row is one rendered line plus its case-folded search fields. | ||
| @@ -167,11 +176,21 @@ func (r row) selectable() bool { return r.line.Actions != 0 } | |||||
| 167 | // focusable reports the source's authorisation to rest the cursor on this row. | 176 | // focusable reports the source's authorisation to rest the cursor on this row. | ||
| 168 | // A section header is never a destination; every other shape follows the | 177 | // A section header is never a destination; every other shape follows the | ||
| 169 | // published flag, so a row the source kept for inspection stays reachable while | 178 | // published flag, so a row the source kept for inspection stays reachable while | ||
| 170 | - | // a row it skipped is passed over. | 179 | + | // a row it skipped is passed over. The model lets the cursor rest on a section |
| 180 | + | // header on its own, only to fold it (see eligible). | |||
| 171 | func (r row) focusable() bool { | 181 | func (r row) focusable() bool { | ||
| 172 | return r.line.Kind != protocol.PickerLineSection && r.line.Focusable | 182 | return r.line.Kind != protocol.PickerLineSection && r.line.Focusable | ||
| 173 | } | 183 | } | ||
| 174 | 184 | ||||
| 185 | + | // id names the row for cursor restoration. A section has no source key, so | |||
| 186 | + | // it is named by its label in a namespace no source key can use. | |||
| 187 | + | func (r row) id() string { | |||
| 188 | + | if r.section() { | |||
| 189 | + | return "\x00section:" + r.line.Label | |||
| 190 | + | } | |||
| 191 | + | return r.line.Key | |||
| 192 | + | } | |||
| 193 | + | ||||
| 175 | func (r row) rendersAsHeader() bool { | 194 | func (r row) rendersAsHeader() bool { | ||
| 176 | return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost | 195 | return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost | ||
| 177 | } | 196 | } | ||
| @@ -248,7 +267,7 @@ func (m *Model) keysAboveCursor() []string { | |||||
| 248 | var keys []string | 267 | var keys []string | ||
| 249 | for i := m.selected - 1; i >= 0; i-- { | 268 | for i := m.selected - 1; i >= 0; i-- { | ||
| 250 | if m.eligible(i) { | 269 | if m.eligible(i) { | ||
| 251 | - | keys = append(keys, m.rows[i].key()) | 270 | + | keys = append(keys, m.rows[i].id()) |
| 252 | } | 271 | } | ||
| 253 | } | 272 | } | ||
| 254 | return keys | 273 | return keys | ||
| @@ -259,7 +278,7 @@ func (m *Model) selectFirstKey(keys []string) { | |||||
| 259 | index := make(map[string]int, len(m.rows)) | 278 | index := make(map[string]int, len(m.rows)) | ||
| 260 | for idx, candidate := range m.rows { | 279 | for idx, candidate := range m.rows { | ||
| 261 | if m.eligible(idx) { | 280 | if m.eligible(idx) { | ||
| 262 | - | index[candidate.key()] = idx | 281 | + | index[candidate.id()] = idx |
| 263 | } | 282 | } | ||
| 264 | } | 283 | } | ||
| 265 | for _, key := range keys { | 284 | for _, key := range keys { | ||
| @@ -319,30 +338,159 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) { | |||||
| 319 | session = "" | 338 | session = "" | ||
| 320 | } | 339 | } | ||
| 321 | } | 340 | } | ||
| 322 | - | m.assignTreePrefixes() | |||
| 323 | m.searchMatches = nil | 341 | m.searchMatches = nil | ||
| 324 | m.matchRows = nil | 342 | m.matchRows = nil | ||
| 325 | if m.searchActive { | 343 | if m.searchActive { | ||
| 326 | m.refreshSearch(false) | 344 | m.refreshSearch(false) | ||
| 345 | + | } else { | |||
| 346 | + | m.refreshView() | |||
| 327 | } | 347 | } | ||
| 328 | m.restoreSelection(key, hadKey, fallbackIndex) | 348 | m.restoreSelection(key, hadKey, fallbackIndex) | ||
| 329 | } | 349 | } | ||
| 330 | 350 | ||||
| 331 | - | // assignTreePrefixes gives each row its tree branch: sessions and hosts hang | 351 | + | // refreshView recomputes which rows are drawn. A non-empty query shows only |
| 332 | - | // off their section, tabs off their session. The last sibling closes its | 352 | + | // the matching rows plus their section and session headers, in tree order, and |
| 333 | - | // branch with └─ and its children no longer continue the parent's │. | 353 | + | // ignores folds. Otherwise every row is drawn except those under a collapsed |
| 354 | + | // section. | |||
| 355 | + | func (m *Model) refreshView() { | |||
| 356 | + | hidden := make([]bool, len(m.rows)) | |||
| 357 | + | if m.searchRestricted() { | |||
| 358 | + | section, session := -1, -1 | |||
| 359 | + | for i, r := range m.rows { | |||
| 360 | + | hidden[i] = true | |||
| 361 | + | switch r.kind() { | |||
| 362 | + | case protocol.PickerLineSection: | |||
| 363 | + | section, session = i, -1 | |||
| 364 | + | continue | |||
| 365 | + | case protocol.PickerLineSession: | |||
| 366 | + | session = i | |||
| 367 | + | case protocol.PickerLineTab: | |||
| 368 | + | default: | |||
| 369 | + | session = -1 | |||
| 370 | + | } | |||
| 371 | + | if _, ok := m.searchMatches[i]; !ok { | |||
| 372 | + | continue | |||
| 373 | + | } | |||
| 374 | + | hidden[i] = false | |||
| 375 | + | if section >= 0 { | |||
| 376 | + | hidden[section] = false | |||
| 377 | + | } | |||
| 378 | + | if r.kind() == protocol.PickerLineTab && session >= 0 { | |||
| 379 | + | hidden[session] = false | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | } else { | |||
| 383 | + | folded := false | |||
| 384 | + | for i, r := range m.rows { | |||
| 385 | + | if r.section() { | |||
| 386 | + | folded = m.folded[r.line.Label] | |||
| 387 | + | continue | |||
| 388 | + | } | |||
| 389 | + | hidden[i] = folded | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | view := make([]int, 0, len(m.rows)) | |||
| 393 | + | for i := range m.rows { | |||
| 394 | + | if !hidden[i] { | |||
| 395 | + | view = append(view, i) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | m.hidden, m.view = hidden, view | |||
| 399 | + | m.assignTreePrefixes() | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // sectionFolded reports whether section row i is drawn collapsed. A query | |||
| 403 | + | // shows matches regardless of folds, so it never draws a section collapsed. | |||
| 404 | + | func (m *Model) sectionFolded(i int) bool { | |||
| 405 | + | return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].line.Label] | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // sectionOf reports the section header row i belongs to, or -1. | |||
| 409 | + | func (m *Model) sectionOf(i int) int { | |||
| 410 | + | for j := i; j >= 0; j-- { | |||
| 411 | + | if m.rows[j].section() { | |||
| 412 | + | return j | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | return -1 | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // setFold collapses or expands the section under the cursor and reports | |||
| 419 | + | // whether anything changed. It never applies while a query filters the list. | |||
| 420 | + | func (m *Model) setFold(fold bool) bool { | |||
| 421 | + | if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() { | |||
| 422 | + | return false | |||
| 423 | + | } | |||
| 424 | + | label := m.rows[m.selected].line.Label | |||
| 425 | + | if m.folded[label] == fold { | |||
| 426 | + | return false | |||
| 427 | + | } | |||
| 428 | + | folded := make(map[string]bool, len(m.folded)+1) | |||
| 429 | + | for k, v := range m.folded { | |||
| 430 | + | folded[k] = v | |||
| 431 | + | } | |||
| 432 | + | if fold { | |||
| 433 | + | folded[label] = true | |||
| 434 | + | } else { | |||
| 435 | + | delete(folded, label) | |||
| 436 | + | } | |||
| 437 | + | m.folded = folded | |||
| 438 | + | m.refreshView() | |||
| 439 | + | return true | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // CursorOnSection reports whether the cursor rests on a section header. | |||
| 443 | + | func (m *Model) CursorOnSection() bool { | |||
| 444 | + | return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section() | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | // ToggleSection collapses or expands the section header under the cursor and | |||
| 448 | + | // reports whether it did. | |||
| 449 | + | func (m *Model) ToggleSection() bool { | |||
| 450 | + | if !m.CursorOnSection() { | |||
| 451 | + | return false | |||
| 452 | + | } | |||
| 453 | + | return m.setFold(!m.folded[m.rows[m.selected].line.Label]) | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | // Left collapses the section under the cursor, or moves the cursor from a row | |||
| 457 | + | // up to its section header. It reports whether anything changed. | |||
| 458 | + | func (m *Model) Left() bool { | |||
| 459 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |||
| 460 | + | return false | |||
| 461 | + | } | |||
| 462 | + | if m.rows[m.selected].section() { | |||
| 463 | + | return m.setFold(true) | |||
| 464 | + | } | |||
| 465 | + | if section := m.sectionOf(m.selected); m.eligible(section) { | |||
| 466 | + | m.selected = section | |||
| 467 | + | return true | |||
| 468 | + | } | |||
| 469 | + | return false | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | // Right expands the section under the cursor and reports whether it did. | |||
| 473 | + | func (m *Model) Right() bool { | |||
| 474 | + | return m.setFold(false) | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | // assignTreePrefixes gives each drawn row its tree branch: sessions and hosts | |||
| 478 | + | // hang off their section, tabs off their session. The last drawn sibling | |||
| 479 | + | // closes its branch with └─ and its children no longer continue the parent's │. | |||
| 334 | func (m *Model) assignTreePrefixes() { | 480 | func (m *Model) assignTreePrefixes() { | ||
| 335 | - | isHead := func(r row) bool { return r.rendersAsHeader() } | |||
| 336 | parentLast := true | 481 | parentLast := true | ||
| 337 | - | for i := range m.rows { | 482 | + | for p, i := range m.view { |
| 338 | r := &m.rows[i] | 483 | r := &m.rows[i] | ||
| 339 | switch { | 484 | switch { | ||
| 340 | case r.section(): | 485 | case r.section(): | ||
| 341 | r.tree = "" | 486 | r.tree = "" | ||
| 342 | - | case isHead(*r): | 487 | + | case r.rendersAsHeader(): |
| 343 | last := true | 488 | last := true | ||
| 344 | - | for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ { | 489 | + | for _, j := range m.view[p+1:] { |
| 345 | - | if isHead(m.rows[j]) { | 490 | + | if m.rows[j].section() { |
| 491 | + | break | |||
| 492 | + | } | |||
| 493 | + | if m.rows[j].rendersAsHeader() { | |||
| 346 | last = false | 494 | last = false | ||
| 347 | break | 495 | break | ||
| 348 | } | 496 | } | ||
| @@ -350,7 +498,7 @@ func (m *Model) assignTreePrefixes() { | |||||
| 350 | parentLast = last | 498 | parentLast = last | ||
| 351 | r.tree = treeBranch(last) | 499 | r.tree = treeBranch(last) | ||
| 352 | case r.kind() == protocol.PickerLineTab: | 500 | case r.kind() == protocol.PickerLineTab: | ||
| 353 | - | last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab | 501 | + | last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab |
| 354 | stem := "│ " | 502 | stem := "│ " | ||
| 355 | if parentLast { | 503 | if parentLast { | ||
| 356 | stem = " " | 504 | stem = " " | ||
| @@ -391,11 +539,20 @@ func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine { | |||||
| 391 | func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) { | 539 | func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) { | ||
| 392 | if hadKey { | 540 | if hadKey { | ||
| 393 | for idx, candidate := range m.rows { | 541 | for idx, candidate := range m.rows { | ||
| 394 | - | if m.eligible(idx) && candidate.key() == key { | 542 | + | if m.eligible(idx) && candidate.id() == key { |
| 395 | m.selected = idx | 543 | m.selected = idx | ||
| 396 | return | 544 | return | ||
| 397 | } | 545 | } | ||
| 398 | } | 546 | } | ||
| 547 | + | // A row inside a collapsed section keeps the cursor on that section. | |||
| 548 | + | for idx, candidate := range m.rows { | |||
| 549 | + | if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] { | |||
| 550 | + | if section := m.sectionOf(idx); m.eligible(section) { | |||
| 551 | + | m.selected = section | |||
| 552 | + | return | |||
| 553 | + | } | |||
| 554 | + | } | |||
| 555 | + | } | |||
| 399 | } | 556 | } | ||
| 400 | m.selected = -1 | 557 | m.selected = -1 | ||
| 401 | m.SelectNearestRow(fallbackIndex) | 558 | m.SelectNearestRow(fallbackIndex) | ||
| @@ -410,15 +567,28 @@ func (m *Model) searchRestricted() bool { | |||||
| 410 | return m != nil && m.searchActive && m.query.Value() != "" | 567 | return m != nil && m.searchActive && m.query.Value() != "" | ||
| 411 | } | 568 | } | ||
| 412 | 569 | ||||
| 413 | - | // eligible reports whether the cursor may rest on row i: the source authorised | 570 | + | // eligible reports whether the cursor may rest on row i: the row is drawn, |
| 414 | - | // the row as a destination and the typed query, when there is one, matches it. | 571 | + | // and either the source authorised it as a destination and the typed query, |
| 572 | + | // when there is one, matches it, or it is a section header outside a query. | |||
| 415 | func (m *Model) eligible(i int) bool { | 573 | func (m *Model) eligible(i int) bool { | ||
| 416 | - | if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].focusable() { | 574 | + | if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] { |
| 575 | + | return false | |||
| 576 | + | } | |||
| 577 | + | if m.rows[i].section() { | |||
| 578 | + | return !m.searchRestricted() | |||
| 579 | + | } | |||
| 580 | + | if !m.rows[i].focusable() { | |||
| 417 | return false | 581 | return false | ||
| 418 | } | 582 | } | ||
| 419 | return !m.searchRestricted() || m.rowMatches(i) | 583 | return !m.searchRestricted() || m.rowMatches(i) | ||
| 420 | } | 584 | } | ||
| 421 | 585 | ||||
| 586 | + | // target reports whether the cursor may fall back to row i on its own: an | |||
| 587 | + | // eligible row that is not a section header. | |||
| 588 | + | func (m *Model) target(i int) bool { | |||
| 589 | + | return m.eligible(i) && !m.rows[i].section() | |||
| 590 | + | } | |||
| 591 | + | ||||
| 422 | // committable reports whether row i may be committed: it admits an action and | 592 | // committable reports whether row i may be committed: it admits an action and | ||
| 423 | // the typed query, when there is one, does not exclude it. | 593 | // the typed query, when there is one, does not exclude it. | ||
| 424 | func (m *Model) committable(i int) bool { | 594 | func (m *Model) committable(i int) bool { | ||
| @@ -432,24 +602,12 @@ func (m *Model) cursorKey() (string, bool) { | |||||
| 432 | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | 602 | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | ||
| 433 | return "", false | 603 | return "", false | ||
| 434 | } | 604 | } | ||
| 435 | - | return m.rows[m.selected].key(), true | 605 | + | return m.rows[m.selected].id(), true |
| 436 | } | 606 | } | ||
| 437 | 607 | ||||
| 438 | - | func (m *Model) Up() { | 608 | + | func (m *Model) Up() { m.move(-1) } |
| 439 | - | if m != nil && m.searchActive { | |||
| 440 | - | m.moveSearch(-1) | |||
| 441 | - | return | |||
| 442 | - | } | |||
| 443 | - | m.move(-1) | |||
| 444 | - | } | |||
| 445 | 609 | ||||
| 446 | - | func (m *Model) Down() { | 610 | + | func (m *Model) Down() { m.move(1) } |
| 447 | - | if m != nil && m.searchActive { | |||
| 448 | - | m.moveSearch(1) | |||
| 449 | - | return | |||
| 450 | - | } | |||
| 451 | - | m.move(1) | |||
| 452 | - | } | |||
| 453 | 611 | ||||
| 454 | // Cursor reports the line under the picker cursor independently from whether | 612 | // Cursor reports the line under the picker cursor independently from whether | ||
| 455 | // that row may be activated, so a refresh can preserve an anchor row. | 613 | // that row may be activated, so a refresh can preserve an anchor row. | ||
| @@ -481,22 +639,25 @@ func (m *Model) SelectedIndex() int { | |||||
| 481 | } | 639 | } | ||
| 482 | 640 | ||||
| 483 | // SelectNearestRow selects the nearest eligible row at or after idx, falling | 641 | // SelectNearestRow selects the nearest eligible row at or after idx, falling | ||
| 484 | - | // back to the last eligible row before it. | 642 | + | // back to the last eligible row before it. Section headers are taken only when |
| 643 | + | // no other row qualifies. | |||
| 485 | func (m *Model) SelectNearestRow(idx int) { | 644 | func (m *Model) SelectNearestRow(idx int) { | ||
| 486 | if m == nil || len(m.rows) == 0 { | 645 | if m == nil || len(m.rows) == 0 { | ||
| 487 | return | 646 | return | ||
| 488 | } | 647 | } | ||
| 489 | idx = clamp(idx, 0, len(m.rows)-1) | 648 | idx = clamp(idx, 0, len(m.rows)-1) | ||
| 490 | - | for i := idx; i < len(m.rows); i++ { | 649 | + | for _, ok := range []func(int) bool{m.target, m.eligible} { |
| 491 | - | if m.eligible(i) { | 650 | + | for i := idx; i < len(m.rows); i++ { |
| 492 | - | m.selected = i | 651 | + | if ok(i) { |
| 493 | - | return | 652 | + | m.selected = i |
| 653 | + | return | |||
| 654 | + | } | |||
| 494 | } | 655 | } | ||
| 495 | - | } | 656 | + | for i := idx - 1; i >= 0; i-- { |
| 496 | - | for i := idx - 1; i >= 0; i-- { | 657 | + | if ok(i) { |
| 497 | - | if m.eligible(i) { | 658 | + | m.selected = i |
| 498 | - | m.selected = i | 659 | + | return |
| 499 | - | return | 660 | + | } |
| 500 | } | 661 | } | ||
| 501 | } | 662 | } | ||
| 502 | } | 663 | } | ||
| @@ -552,10 +713,14 @@ func (m *Model) move(delta int) { | |||||
| 552 | } | 713 | } | ||
| 553 | } | 714 | } | ||
| 554 | 715 | ||||
| 716 | + | // firstEligible reports the first row the cursor may rest on, preferring any | |||
| 717 | + | // row over a section header. | |||
| 555 | func (m *Model) firstEligible() int { | 718 | func (m *Model) firstEligible() int { | ||
| 556 | - | for i := range m.rows { | 719 | + | for _, ok := range []func(int) bool{m.target, m.eligible} { |
| 557 | - | if m.eligible(i) { | 720 | + | for i := range m.rows { |
| 558 | - | return i | 721 | + | if ok(i) { |
| 722 | + | return i | |||
| 723 | + | } | |||
| 559 | } | 724 | } | ||
| 560 | } | 725 | } | ||
| 561 | return -1 | 726 | return -1 | ||
| @@ -634,10 +799,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 634 | offset := m.scrollOffset(visible) | 799 | offset := m.scrollOffset(visible) | ||
| 635 | clipX := rect.X + rect.Width | 800 | clipX := rect.X + rect.Width | ||
| 636 | for y := range visible { | 801 | for y := range visible { | ||
| 637 | - | idx := offset + y | 802 | + | if offset+y >= len(m.view) { |
| 638 | - | if idx >= len(m.rows) { | |||
| 639 | break | 803 | break | ||
| 640 | } | 804 | } | ||
| 805 | + | idx := m.view[offset+y] | |||
| 641 | r := m.rows[idx] | 806 | r := m.rows[idx] | ||
| 642 | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | 807 | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | ||
| 643 | if r.section() { | 808 | if r.section() { | ||
| @@ -649,7 +814,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 649 | if r.line.Stopped && idx != m.selected { | 814 | if r.line.Stopped && idx != m.selected { | ||
| 650 | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | 815 | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | ||
| 651 | } | 816 | } | ||
| 652 | - | if r.line.Dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) { | 817 | + | if r.line.Dim && !(r.section() && idx == m.selected) { |
| 653 | base.Attrs |= renderer.AttrDim | 818 | base.Attrs |= renderer.AttrDim | ||
| 654 | nameStyle.Attrs |= renderer.AttrDim | 819 | nameStyle.Attrs |= renderer.AttrDim | ||
| 655 | detailStyle.Attrs |= renderer.AttrDim | 820 | detailStyle.Attrs |= renderer.AttrDim | ||
| @@ -659,6 +824,12 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 659 | name := r.line.Label | 824 | name := r.line.Label | ||
| 660 | if r.section() { | 825 | if r.section() { | ||
| 661 | nameStyle.Bold = true | 826 | nameStyle.Bold = true | ||
| 827 | + | marker := "▾ " | |||
| 828 | + | if m.sectionFolded(idx) { | |||
| 829 | + | marker = "▸ " | |||
| 830 | + | name += fmt.Sprintf(" (%d)", m.sectionSessions(idx)) | |||
| 831 | + | } | |||
| 832 | + | name = marker + name | |||
| 662 | } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped { | 833 | } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped { | ||
| 663 | nameStyle.Bold = true | 834 | nameStyle.Bold = true | ||
| 664 | } | 835 | } | ||
| @@ -796,6 +967,9 @@ func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style rende | |||||
| 796 | if action == "" { | 967 | if action == "" { | ||
| 797 | enter = "Enter unavailable" | 968 | enter = "Enter unavailable" | ||
| 798 | } | 969 | } | ||
| 970 | + | if m != nil && m.CursorOnSection() { | |||
| 971 | + | enter = "Enter/h/l fold" | |||
| 972 | + | } | |||
| 799 | var groups []string | 973 | var groups []string | ||
| 800 | if m != nil && m.searchActive { | 974 | if m != nil && m.searchActive { | ||
| 801 | groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"} | 975 | groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"} | ||
| @@ -844,15 +1018,27 @@ func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string { | |||||
| 844 | return "open" | 1018 | return "open" | ||
| 845 | } | 1019 | } | ||
| 846 | 1020 | ||||
| 1021 | + | // scrollOffset is the first drawn position so the cursor row stays visible. | |||
| 847 | func (m *Model) scrollOffset(visible int) int { | 1022 | func (m *Model) scrollOffset(visible int) int { | ||
| 848 | - | if visible <= 0 || len(m.rows) <= visible || m.selected < 0 { | 1023 | + | if visible <= 0 || len(m.view) <= visible || m.selected < 0 { |
| 849 | return 0 | 1024 | return 0 | ||
| 850 | } | 1025 | } | ||
| 851 | - | if m.selected < visible { | 1026 | + | position := slices.Index(m.view, m.selected) |
| 1027 | + | if position < visible { | |||
| 852 | return 0 | 1028 | return 0 | ||
| 853 | } | 1029 | } | ||
| 854 | - | offset := m.selected - visible + 1 | 1030 | + | return min(position-visible+1, len(m.view)-visible) |
| 855 | - | return min(offset, len(m.rows)-visible) | 1031 | + | } |
| 1032 | + | ||||
| 1033 | + | // sectionSessions counts the session and host rows under section row i. | |||
| 1034 | + | func (m *Model) sectionSessions(i int) int { | |||
| 1035 | + | count := 0 | |||
| 1036 | + | for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ { | |||
| 1037 | + | if m.rows[j].rendersAsHeader() { | |||
| 1038 | + | count++ | |||
| 1039 | + | } | |||
| 1040 | + | } | |||
| 1041 | + | return count | |||
| 856 | } | 1042 | } | ||
| 857 | 1043 | ||||
| 858 | func clamp(n, low, high int) int { | 1044 | func clamp(n, low, high int) int { | ||
M internal/usecase/picker/model_test.go +139 -1
| @@ -518,7 +518,6 @@ func TestTreeStyleKeepsRowDimming(t *testing.T) { | |||
| 518 | 518 | dim.Dim = true | |
| 519 | 519 | return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim} | |
| 520 | 520 | }()}, | |
| 521 | - | {name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"}, | |
| 522 | 521 | } | |
| 523 | 522 | for _, tt := range tests { | |
| 524 | 523 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -567,3 +566,142 @@ func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) { | |||
| 567 | 566 | require.True(t, ok) | |
| 568 | 567 | require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one") | |
| 569 | 568 | } | |
| 569 | + | ||
| 570 | + | // drawnRows reports the tree prefix and label of every drawn row. | |
| 571 | + | func drawnRows(m *Model) []string { | |
| 572 | + | got := make([]string, 0, len(m.view)) | |
| 573 | + | for _, i := range m.view { | |
| 574 | + | got = append(got, m.rows[i].tree+m.rows[i].line.Label) | |
| 575 | + | } | |
| 576 | + | return got | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | func foldFixture() []protocol.PickerLine { | |
| 580 | + | session := func(key, label string) protocol.PickerLine { | |
| 581 | + | return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label} | |
| 582 | + | } | |
| 583 | + | return []protocol.PickerLine{ | |
| 584 | + | section("LOCAL"), | |
| 585 | + | session("w", "work"), tabLine("w1", "shell", protocol.PickerCanNavigate), tabLine("w2", "logs", protocol.PickerCanNavigate), | |
| 586 | + | session("s", "scratch"), tabLine("s1", "apply-migrations", protocol.PickerCanNavigate), | |
| 587 | + | section("devbox"), | |
| 588 | + | session("a", "api"), tabLine("a1", "server", protocol.PickerCanNavigate), tabLine("a2", "db", protocol.PickerCanNavigate), | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | func TestSectionFolding(t *testing.T) { | |
| 593 | + | tests := []struct { | |
| 594 | + | name string | |
| 595 | + | keys func(m *Model) | |
| 596 | + | wantRows []string | |
| 597 | + | wantCursor string | |
| 598 | + | }{ | |
| 599 | + | { | |
| 600 | + | name: "left from a tab climbs to its section", | |
| 601 | + | keys: func(m *Model) { m.Left() }, | |
| 602 | + | wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, | |
| 603 | + | wantCursor: "\x00section:LOCAL", | |
| 604 | + | }, | |
| 605 | + | { | |
| 606 | + | name: "left on a section collapses it", | |
| 607 | + | keys: func(m *Model) { m.Left(); m.Left() }, | |
| 608 | + | wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, | |
| 609 | + | wantCursor: "\x00section:LOCAL", | |
| 610 | + | }, | |
| 611 | + | { | |
| 612 | + | name: "down from a collapsed section skips its rows", | |
| 613 | + | keys: func(m *Model) { m.Left(); m.Left(); m.Down() }, | |
| 614 | + | wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, | |
| 615 | + | wantCursor: "\x00section:devbox", | |
| 616 | + | }, | |
| 617 | + | { | |
| 618 | + | name: "right expands it again", | |
| 619 | + | keys: func(m *Model) { m.Left(); m.Left(); m.Right() }, | |
| 620 | + | wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, | |
| 621 | + | wantCursor: "\x00section:LOCAL", | |
| 622 | + | }, | |
| 623 | + | { | |
| 624 | + | name: "toggle collapses and expands", | |
| 625 | + | keys: func(m *Model) { m.Left(); m.ToggleSection() }, | |
| 626 | + | wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, | |
| 627 | + | wantCursor: "\x00section:LOCAL", | |
| 628 | + | }, | |
| 629 | + | { | |
| 630 | + | name: "right off a section does nothing", | |
| 631 | + | keys: func(m *Model) { m.Right() }, | |
| 632 | + | wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, | |
| 633 | + | wantCursor: "w1", | |
| 634 | + | }, | |
| 635 | + | } | |
| 636 | + | for _, tt := range tests { | |
| 637 | + | t.Run(tt.name, func(t *testing.T) { | |
| 638 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |
| 639 | + | require.Equal(t, "w1", cursorKey(t, m), "a section header never takes the initial cursor") | |
| 640 | + | tt.keys(m) | |
| 641 | + | require.Equal(t, tt.wantRows, drawnRows(m)) | |
| 642 | + | key, _ := m.cursorKey() | |
| 643 | + | require.Equal(t, tt.wantCursor, key) | |
| 644 | + | }) | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | func TestSectionFoldSurvivesRepublication(t *testing.T) { | |
| 649 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |
| 650 | + | m.Left() | |
| 651 | + | m.Left() | |
| 652 | + | m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1}) | |
| 653 | + | require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m)) | |
| 654 | + | key, _ := m.cursorKey() | |
| 655 | + | require.Equal(t, "\x00section:LOCAL", key) | |
| 656 | + | _, ok := m.Selected() | |
| 657 | + | require.False(t, ok, "a section header is never committable") | |
| 658 | + | ||
| 659 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, Preview{}) | |
| 660 | + | require.True(t, strings.HasPrefix(rowText(frame.Row(0)), "▸ LOCAL (2)"), "collapsed header %q", rowText(frame.Row(0))) | |
| 661 | + | require.True(t, strings.HasPrefix(rowText(frame.Row(1)), "▾ devbox"), "expanded header %q", rowText(frame.Row(1))) | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | func TestSearchShowsOnlyMatchesInTreeOrder(t *testing.T) { | |
| 665 | + | tests := []struct { | |
| 666 | + | name string | |
| 667 | + | query string | |
| 668 | + | wantRows []string | |
| 669 | + | wantCursor string | |
| 670 | + | }{ | |
| 671 | + | {name: "matches keep their ancestors", query: "ap", wantRows: []string{"LOCAL", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "s1"}, | |
| 672 | + | {name: "one tab match", query: "serv", wantRows: []string{"devbox", "└─ api", " └─ server"}, wantCursor: "a1"}, | |
| 673 | + | {name: "no match draws nothing", query: "zzz", wantRows: []string{}}, | |
| 674 | + | } | |
| 675 | + | for _, tt := range tests { | |
| 676 | + | t.Run(tt.name, func(t *testing.T) { | |
| 677 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |
| 678 | + | m.Down() | |
| 679 | + | m.Down() // somewhere in the middle: typing still starts on the first match | |
| 680 | + | // A collapsed section never hides a match. | |
| 681 | + | m.Left() | |
| 682 | + | m.Left() | |
| 683 | + | m.EnterSearch() | |
| 684 | + | for _, r := range tt.query { | |
| 685 | + | m.InsertSearch(r) | |
| 686 | + | } | |
| 687 | + | require.Equal(t, tt.wantRows, drawnRows(m)) | |
| 688 | + | if tt.wantCursor == "" { | |
| 689 | + | require.Equal(t, -1, m.SelectedIndex()) | |
| 690 | + | return | |
| 691 | + | } | |
| 692 | + | require.Equal(t, tt.wantCursor, mustSelectedKey(t, m)) | |
| 693 | + | }) | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | func TestExitSearchRestoresFolds(t *testing.T) { | |
| 698 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |
| 699 | + | m.Left() | |
| 700 | + | m.Left() | |
| 701 | + | m.EnterSearch() | |
| 702 | + | m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL | |
| 703 | + | m.ExitSearch() | |
| 704 | + | require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m)) | |
| 705 | + | key, _ := m.cursorKey() | |
| 706 | + | require.Equal(t, "\x00section:LOCAL", key, "a match a fold hides leaves the cursor on its section") | |
| 707 | + | } | |
| @@ -518,7 +518,6 @@ func TestTreeStyleKeepsRowDimming(t *testing.T) { | |||||
| 518 | dim.Dim = true | 518 | dim.Dim = true | ||
| 519 | return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim} | 519 | return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim} | ||
| 520 | }()}, | 520 | }()}, | ||
| 521 | - | {name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"}, | |||
| 522 | } | 521 | } | ||
| 523 | for _, tt := range tests { | 522 | for _, tt := range tests { | ||
| 524 | t.Run(tt.name, func(t *testing.T) { | 523 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -567,3 +566,142 @@ func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) { | |||||
| 567 | require.True(t, ok) | 566 | require.True(t, ok) | ||
| 568 | require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one") | 567 | require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one") | ||
| 569 | } | 568 | } | ||
| 569 | + | ||||
| 570 | + | // drawnRows reports the tree prefix and label of every drawn row. | |||
| 571 | + | func drawnRows(m *Model) []string { | |||
| 572 | + | got := make([]string, 0, len(m.view)) | |||
| 573 | + | for _, i := range m.view { | |||
| 574 | + | got = append(got, m.rows[i].tree+m.rows[i].line.Label) | |||
| 575 | + | } | |||
| 576 | + | return got | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | func foldFixture() []protocol.PickerLine { | |||
| 580 | + | session := func(key, label string) protocol.PickerLine { | |||
| 581 | + | return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label} | |||
| 582 | + | } | |||
| 583 | + | return []protocol.PickerLine{ | |||
| 584 | + | section("LOCAL"), | |||
| 585 | + | session("w", "work"), tabLine("w1", "shell", protocol.PickerCanNavigate), tabLine("w2", "logs", protocol.PickerCanNavigate), | |||
| 586 | + | session("s", "scratch"), tabLine("s1", "apply-migrations", protocol.PickerCanNavigate), | |||
| 587 | + | section("devbox"), | |||
| 588 | + | session("a", "api"), tabLine("a1", "server", protocol.PickerCanNavigate), tabLine("a2", "db", protocol.PickerCanNavigate), | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func TestSectionFolding(t *testing.T) { | |||
| 593 | + | tests := []struct { | |||
| 594 | + | name string | |||
| 595 | + | keys func(m *Model) | |||
| 596 | + | wantRows []string | |||
| 597 | + | wantCursor string | |||
| 598 | + | }{ | |||
| 599 | + | { | |||
| 600 | + | name: "left from a tab climbs to its section", | |||
| 601 | + | keys: func(m *Model) { m.Left() }, | |||
| 602 | + | wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, | |||
| 603 | + | wantCursor: "\x00section:LOCAL", | |||
| 604 | + | }, | |||
| 605 | + | { | |||
| 606 | + | name: "left on a section collapses it", | |||
| 607 | + | keys: func(m *Model) { m.Left(); m.Left() }, | |||
| 608 | + | wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, | |||
| 609 | + | wantCursor: "\x00section:LOCAL", | |||
| 610 | + | }, | |||
| 611 | + | { | |||
| 612 | + | name: "down from a collapsed section skips its rows", | |||
| 613 | + | keys: func(m *Model) { m.Left(); m.Left(); m.Down() }, | |||
| 614 | + | wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, | |||
| 615 | + | wantCursor: "\x00section:devbox", | |||
| 616 | + | }, | |||
| 617 | + | { | |||
| 618 | + | name: "right expands it again", | |||
| 619 | + | keys: func(m *Model) { m.Left(); m.Left(); m.Right() }, | |||
| 620 | + | wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, | |||
| 621 | + | wantCursor: "\x00section:LOCAL", | |||
| 622 | + | }, | |||
| 623 | + | { | |||
| 624 | + | name: "toggle collapses and expands", | |||
| 625 | + | keys: func(m *Model) { m.Left(); m.ToggleSection() }, | |||
| 626 | + | wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, | |||
| 627 | + | wantCursor: "\x00section:LOCAL", | |||
| 628 | + | }, | |||
| 629 | + | { | |||
| 630 | + | name: "right off a section does nothing", | |||
| 631 | + | keys: func(m *Model) { m.Right() }, | |||
| 632 | + | wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, | |||
| 633 | + | wantCursor: "w1", | |||
| 634 | + | }, | |||
| 635 | + | } | |||
| 636 | + | for _, tt := range tests { | |||
| 637 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 638 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |||
| 639 | + | require.Equal(t, "w1", cursorKey(t, m), "a section header never takes the initial cursor") | |||
| 640 | + | tt.keys(m) | |||
| 641 | + | require.Equal(t, tt.wantRows, drawnRows(m)) | |||
| 642 | + | key, _ := m.cursorKey() | |||
| 643 | + | require.Equal(t, tt.wantCursor, key) | |||
| 644 | + | }) | |||
| 645 | + | } | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | func TestSectionFoldSurvivesRepublication(t *testing.T) { | |||
| 649 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |||
| 650 | + | m.Left() | |||
| 651 | + | m.Left() | |||
| 652 | + | m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1}) | |||
| 653 | + | require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m)) | |||
| 654 | + | key, _ := m.cursorKey() | |||
| 655 | + | require.Equal(t, "\x00section:LOCAL", key) | |||
| 656 | + | _, ok := m.Selected() | |||
| 657 | + | require.False(t, ok, "a section header is never committable") | |||
| 658 | + | ||||
| 659 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 10}, Preview{}) | |||
| 660 | + | require.True(t, strings.HasPrefix(rowText(frame.Row(0)), "▸ LOCAL (2)"), "collapsed header %q", rowText(frame.Row(0))) | |||
| 661 | + | require.True(t, strings.HasPrefix(rowText(frame.Row(1)), "▾ devbox"), "expanded header %q", rowText(frame.Row(1))) | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | func TestSearchShowsOnlyMatchesInTreeOrder(t *testing.T) { | |||
| 665 | + | tests := []struct { | |||
| 666 | + | name string | |||
| 667 | + | query string | |||
| 668 | + | wantRows []string | |||
| 669 | + | wantCursor string | |||
| 670 | + | }{ | |||
| 671 | + | {name: "matches keep their ancestors", query: "ap", wantRows: []string{"LOCAL", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "s1"}, | |||
| 672 | + | {name: "one tab match", query: "serv", wantRows: []string{"devbox", "└─ api", " └─ server"}, wantCursor: "a1"}, | |||
| 673 | + | {name: "no match draws nothing", query: "zzz", wantRows: []string{}}, | |||
| 674 | + | } | |||
| 675 | + | for _, tt := range tests { | |||
| 676 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 677 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |||
| 678 | + | m.Down() | |||
| 679 | + | m.Down() // somewhere in the middle: typing still starts on the first match | |||
| 680 | + | // A collapsed section never hides a match. | |||
| 681 | + | m.Left() | |||
| 682 | + | m.Left() | |||
| 683 | + | m.EnterSearch() | |||
| 684 | + | for _, r := range tt.query { | |||
| 685 | + | m.InsertSearch(r) | |||
| 686 | + | } | |||
| 687 | + | require.Equal(t, tt.wantRows, drawnRows(m)) | |||
| 688 | + | if tt.wantCursor == "" { | |||
| 689 | + | require.Equal(t, -1, m.SelectedIndex()) | |||
| 690 | + | return | |||
| 691 | + | } | |||
| 692 | + | require.Equal(t, tt.wantCursor, mustSelectedKey(t, m)) | |||
| 693 | + | }) | |||
| 694 | + | } | |||
| 695 | + | } | |||
| 696 | + | ||||
| 697 | + | func TestExitSearchRestoresFolds(t *testing.T) { | |||
| 698 | + | m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) | |||
| 699 | + | m.Left() | |||
| 700 | + | m.Left() | |||
| 701 | + | m.EnterSearch() | |||
| 702 | + | m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL | |||
| 703 | + | m.ExitSearch() | |||
| 704 | + | require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m)) | |||
| 705 | + | key, _ := m.cursorKey() | |||
| 706 | + | require.Equal(t, "\x00section:LOCAL", key, "a match a fold hides leaves the cursor on its section") | |||
| 707 | + | } | |||
M internal/usecase/picker/search.go +30 -56
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "slices" | |
| 5 | 4 | "strings" | |
| 6 | 5 | "unicode/utf8" | |
| 7 | 6 | ||
| @@ -22,14 +21,10 @@ const ( | |||
| 22 | 21 | type fieldMatch struct { | |
| 23 | 22 | field matchField | |
| 24 | 23 | positions []int | |
| 25 | - | rank int | |
| 26 | - | span int | |
| 27 | - | first int | |
| 28 | 24 | } | |
| 29 | 25 | ||
| 30 | 26 | type searchMatch struct { | |
| 31 | 27 | fieldMatches []fieldMatch | |
| 32 | - | best fieldMatch | |
| 33 | 28 | } | |
| 34 | 29 | ||
| 35 | 30 | func (m searchMatch) positions(field matchField) []int { | |
| @@ -58,6 +53,8 @@ func (m *Model) ExitSearch() { | |||
| 58 | 53 | m.query.SetValue("") | |
| 59 | 54 | m.searchMatches = nil | |
| 60 | 55 | m.matchRows = nil | |
| 56 | + | m.refreshView() | |
| 57 | + | m.normalizeCursor(-1) | |
| 61 | 58 | } | |
| 62 | 59 | ||
| 63 | 60 | func (m *Model) SearchActive() bool { return m != nil && m.searchActive } | |
| @@ -168,33 +165,11 @@ func (m *Model) rowMatches(idx int) bool { | |||
| 168 | 165 | return ok | |
| 169 | 166 | } | |
| 170 | 167 | ||
| 171 | - | func (m *Model) moveSearch(delta int) { | |
| 172 | - | if m == nil { | |
| 173 | - | return | |
| 174 | - | } | |
| 175 | - | if len(m.matchRows) == 0 { | |
| 176 | - | // The query shows nothing, so no row may hold the cursor. | |
| 177 | - | m.selected = -1 | |
| 178 | - | return | |
| 179 | - | } | |
| 180 | - | position := slices.Index(m.matchRows, m.selected) | |
| 181 | - | if position < 0 { | |
| 182 | - | if delta > 0 { | |
| 183 | - | m.selected = m.matchRows[0] | |
| 184 | - | } else { | |
| 185 | - | m.selected = m.matchRows[len(m.matchRows)-1] | |
| 186 | - | } | |
| 187 | - | return | |
| 188 | - | } | |
| 189 | - | next := position + delta | |
| 190 | - | if next >= 0 && next < len(m.matchRows) { | |
| 191 | - | m.selected = m.matchRows[next] | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | 168 | // refreshSearch recomputes the rows the active query shows and re-places the | |
| 196 | - | // cursor: the editor never leaves it on a row the query hides. | |
| 197 | - | func (m *Model) refreshSearch(selectBest bool) int { | |
| 169 | + | // cursor: the editor never leaves it on a row the query hides. Matches keep | |
| 170 | + | // the list's tree order, so typing narrows the list without reordering it; | |
| 171 | + | // when the query changed (selectFirst) the cursor rests on the first match. | |
| 172 | + | func (m *Model) refreshSearch(selectFirst bool) { | |
| 198 | 173 | m.searchMatches = make(map[int]searchMatch) | |
| 199 | 174 | m.matchRows = make([]int, 0, len(m.rows)) | |
| 200 | 175 | query := strings.ToLower(m.query.Value()) | |
| @@ -204,15 +179,14 @@ func (m *Model) refreshSearch(selectBest bool) int { | |||
| 204 | 179 | m.matchRows = append(m.matchRows, idx) | |
| 205 | 180 | } | |
| 206 | 181 | } | |
| 207 | - | if selectBest { | |
| 182 | + | m.refreshView() | |
| 183 | + | if selectFirst { | |
| 208 | 184 | m.normalizeCursor(-1) | |
| 209 | 185 | } | |
| 210 | - | return -1 | |
| 186 | + | return | |
| 211 | 187 | } | |
| 212 | 188 | ||
| 213 | 189 | needleRunes := []rune(query) | |
| 214 | - | bestIdx := -1 | |
| 215 | - | var best fieldMatch | |
| 216 | 190 | for idx, row := range m.rows { | |
| 217 | 191 | if !row.focusable() { | |
| 218 | 192 | continue | |
| @@ -223,19 +197,14 @@ func (m *Model) refreshSearch(selectBest bool) int { | |||
| 223 | 197 | } | |
| 224 | 198 | m.searchMatches[idx] = matched | |
| 225 | 199 | m.matchRows = append(m.matchRows, idx) | |
| 226 | - | if bestIdx < 0 || lessFieldMatch(matched.best, best) { | |
| 227 | - | bestIdx, best = idx, matched.best | |
| 228 | - | } | |
| 229 | 200 | } | |
| 230 | - | if selectBest { | |
| 231 | - | if bestIdx >= 0 { | |
| 232 | - | m.selected = bestIdx | |
| 233 | - | } else { | |
| 234 | - | m.selected = -1 | |
| 201 | + | m.refreshView() | |
| 202 | + | if selectFirst { | |
| 203 | + | m.selected = -1 | |
| 204 | + | if len(m.matchRows) > 0 { | |
| 205 | + | m.selected = m.matchRows[0] | |
| 235 | 206 | } | |
| 236 | - | m.normalizeCursor(bestIdx) | |
| 237 | 207 | } | |
| 238 | - | return bestIdx | |
| 239 | 208 | } | |
| 240 | 209 | ||
| 241 | 210 | // normalizeCursor places the cursor on a row the active query still shows. It | |
| @@ -250,6 +219,21 @@ func (m *Model) normalizeCursor(best int) { | |||
| 250 | 219 | m.selected = best | |
| 251 | 220 | return | |
| 252 | 221 | } | |
| 222 | + | if m.searchRestricted() { | |
| 223 | + | // The query shows nothing the cursor may rest on. | |
| 224 | + | m.selected = -1 | |
| 225 | + | if len(m.matchRows) > 0 { | |
| 226 | + | m.selected = m.matchRows[0] | |
| 227 | + | } | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] { | |
| 231 | + | // A row a collapsed section hides leaves the cursor on that section. | |
| 232 | + | if section := m.sectionOf(m.selected); m.eligible(section) { | |
| 233 | + | m.selected = section | |
| 234 | + | return | |
| 235 | + | } | |
| 236 | + | } | |
| 253 | 237 | hint := m.selected | |
| 254 | 238 | m.selected = -1 | |
| 255 | 239 | m.SelectNearestRow(hint) | |
| @@ -275,9 +259,6 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |||
| 275 | 259 | continue | |
| 276 | 260 | } | |
| 277 | 261 | result.fieldMatches = append(result.fieldMatches, match) | |
| 278 | - | if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) { | |
| 279 | - | result.best = match | |
| 280 | - | } | |
| 281 | 262 | } | |
| 282 | 263 | return result, len(result.fieldMatches) != 0 | |
| 283 | 264 | } | |
| @@ -287,12 +268,5 @@ func scoreField(field matchField, text, query string, needle []rune) (fieldMatch | |||
| 287 | 268 | if !ok { | |
| 288 | 269 | return fieldMatch{}, false | |
| 289 | 270 | } | |
| 290 | - | return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | func lessFieldMatch(left, right fieldMatch) bool { | |
| 294 | - | if left.rank != right.rank || left.span != right.span || left.first != right.first { | |
| 295 | - | return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first}) | |
| 296 | - | } | |
| 297 | - | return left.field < right.field | |
| 271 | + | return fieldMatch{field: field, positions: matched.Positions}, true | |
| 298 | 272 | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "slices" | |||
| 5 | "strings" | 4 | "strings" | ||
| 6 | "unicode/utf8" | 5 | "unicode/utf8" | ||
| 7 | 6 | ||||
| @@ -22,14 +21,10 @@ const ( | |||||
| 22 | type fieldMatch struct { | 21 | type fieldMatch struct { | ||
| 23 | field matchField | 22 | field matchField | ||
| 24 | positions []int | 23 | positions []int | ||
| 25 | - | rank int | |||
| 26 | - | span int | |||
| 27 | - | first int | |||
| 28 | } | 24 | } | ||
| 29 | 25 | ||||
| 30 | type searchMatch struct { | 26 | type searchMatch struct { | ||
| 31 | fieldMatches []fieldMatch | 27 | fieldMatches []fieldMatch | ||
| 32 | - | best fieldMatch | |||
| 33 | } | 28 | } | ||
| 34 | 29 | ||||
| 35 | func (m searchMatch) positions(field matchField) []int { | 30 | func (m searchMatch) positions(field matchField) []int { | ||
| @@ -58,6 +53,8 @@ func (m *Model) ExitSearch() { | |||||
| 58 | m.query.SetValue("") | 53 | m.query.SetValue("") | ||
| 59 | m.searchMatches = nil | 54 | m.searchMatches = nil | ||
| 60 | m.matchRows = nil | 55 | m.matchRows = nil | ||
| 56 | + | m.refreshView() | |||
| 57 | + | m.normalizeCursor(-1) | |||
| 61 | } | 58 | } | ||
| 62 | 59 | ||||
| 63 | func (m *Model) SearchActive() bool { return m != nil && m.searchActive } | 60 | func (m *Model) SearchActive() bool { return m != nil && m.searchActive } | ||
| @@ -168,33 +165,11 @@ func (m *Model) rowMatches(idx int) bool { | |||||
| 168 | return ok | 165 | return ok | ||
| 169 | } | 166 | } | ||
| 170 | 167 | ||||
| 171 | - | func (m *Model) moveSearch(delta int) { | |||
| 172 | - | if m == nil { | |||
| 173 | - | return | |||
| 174 | - | } | |||
| 175 | - | if len(m.matchRows) == 0 { | |||
| 176 | - | // The query shows nothing, so no row may hold the cursor. | |||
| 177 | - | m.selected = -1 | |||
| 178 | - | return | |||
| 179 | - | } | |||
| 180 | - | position := slices.Index(m.matchRows, m.selected) | |||
| 181 | - | if position < 0 { | |||
| 182 | - | if delta > 0 { | |||
| 183 | - | m.selected = m.matchRows[0] | |||
| 184 | - | } else { | |||
| 185 | - | m.selected = m.matchRows[len(m.matchRows)-1] | |||
| 186 | - | } | |||
| 187 | - | return | |||
| 188 | - | } | |||
| 189 | - | next := position + delta | |||
| 190 | - | if next >= 0 && next < len(m.matchRows) { | |||
| 191 | - | m.selected = m.matchRows[next] | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | // refreshSearch recomputes the rows the active query shows and re-places the | 168 | // refreshSearch recomputes the rows the active query shows and re-places the | ||
| 196 | - | // cursor: the editor never leaves it on a row the query hides. | 169 | + | // cursor: the editor never leaves it on a row the query hides. Matches keep |
| 197 | - | func (m *Model) refreshSearch(selectBest bool) int { | 170 | + | // the list's tree order, so typing narrows the list without reordering it; |
| 171 | + | // when the query changed (selectFirst) the cursor rests on the first match. | |||
| 172 | + | func (m *Model) refreshSearch(selectFirst bool) { | |||
| 198 | m.searchMatches = make(map[int]searchMatch) | 173 | m.searchMatches = make(map[int]searchMatch) | ||
| 199 | m.matchRows = make([]int, 0, len(m.rows)) | 174 | m.matchRows = make([]int, 0, len(m.rows)) | ||
| 200 | query := strings.ToLower(m.query.Value()) | 175 | query := strings.ToLower(m.query.Value()) | ||
| @@ -204,15 +179,14 @@ func (m *Model) refreshSearch(selectBest bool) int { | |||||
| 204 | m.matchRows = append(m.matchRows, idx) | 179 | m.matchRows = append(m.matchRows, idx) | ||
| 205 | } | 180 | } | ||
| 206 | } | 181 | } | ||
| 207 | - | if selectBest { | 182 | + | m.refreshView() |
| 183 | + | if selectFirst { | |||
| 208 | m.normalizeCursor(-1) | 184 | m.normalizeCursor(-1) | ||
| 209 | } | 185 | } | ||
| 210 | - | return -1 | 186 | + | return |
| 211 | } | 187 | } | ||
| 212 | 188 | ||||
| 213 | needleRunes := []rune(query) | 189 | needleRunes := []rune(query) | ||
| 214 | - | bestIdx := -1 | |||
| 215 | - | var best fieldMatch | |||
| 216 | for idx, row := range m.rows { | 190 | for idx, row := range m.rows { | ||
| 217 | if !row.focusable() { | 191 | if !row.focusable() { | ||
| 218 | continue | 192 | continue | ||
| @@ -223,19 +197,14 @@ func (m *Model) refreshSearch(selectBest bool) int { | |||||
| 223 | } | 197 | } | ||
| 224 | m.searchMatches[idx] = matched | 198 | m.searchMatches[idx] = matched | ||
| 225 | m.matchRows = append(m.matchRows, idx) | 199 | m.matchRows = append(m.matchRows, idx) | ||
| 226 | - | if bestIdx < 0 || lessFieldMatch(matched.best, best) { | |||
| 227 | - | bestIdx, best = idx, matched.best | |||
| 228 | - | } | |||
| 229 | } | 200 | } | ||
| 230 | - | if selectBest { | 201 | + | m.refreshView() |
| 231 | - | if bestIdx >= 0 { | 202 | + | if selectFirst { |
| 232 | - | m.selected = bestIdx | 203 | + | m.selected = -1 |
| 233 | - | } else { | 204 | + | if len(m.matchRows) > 0 { |
| 234 | - | m.selected = -1 | 205 | + | m.selected = m.matchRows[0] |
| 235 | } | 206 | } | ||
| 236 | - | m.normalizeCursor(bestIdx) | |||
| 237 | } | 207 | } | ||
| 238 | - | return bestIdx | |||
| 239 | } | 208 | } | ||
| 240 | 209 | ||||
| 241 | // normalizeCursor places the cursor on a row the active query still shows. It | 210 | // normalizeCursor places the cursor on a row the active query still shows. It | ||
| @@ -250,6 +219,21 @@ func (m *Model) normalizeCursor(best int) { | |||||
| 250 | m.selected = best | 219 | m.selected = best | ||
| 251 | return | 220 | return | ||
| 252 | } | 221 | } | ||
| 222 | + | if m.searchRestricted() { | |||
| 223 | + | // The query shows nothing the cursor may rest on. | |||
| 224 | + | m.selected = -1 | |||
| 225 | + | if len(m.matchRows) > 0 { | |||
| 226 | + | m.selected = m.matchRows[0] | |||
| 227 | + | } | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | if m.selected >= 0 && m.selected < len(m.hidden) && m.hidden[m.selected] { | |||
| 231 | + | // A row a collapsed section hides leaves the cursor on that section. | |||
| 232 | + | if section := m.sectionOf(m.selected); m.eligible(section) { | |||
| 233 | + | m.selected = section | |||
| 234 | + | return | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 253 | hint := m.selected | 237 | hint := m.selected | ||
| 254 | m.selected = -1 | 238 | m.selected = -1 | ||
| 255 | m.SelectNearestRow(hint) | 239 | m.SelectNearestRow(hint) | ||
| @@ -275,9 +259,6 @@ func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |||||
| 275 | continue | 259 | continue | ||
| 276 | } | 260 | } | ||
| 277 | result.fieldMatches = append(result.fieldMatches, match) | 261 | result.fieldMatches = append(result.fieldMatches, match) | ||
| 278 | - | if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) { | |||
| 279 | - | result.best = match | |||
| 280 | - | } | |||
| 281 | } | 262 | } | ||
| 282 | return result, len(result.fieldMatches) != 0 | 263 | return result, len(result.fieldMatches) != 0 | ||
| 283 | } | 264 | } | ||
| @@ -287,12 +268,5 @@ func scoreField(field matchField, text, query string, needle []rune) (fieldMatch | |||||
| 287 | if !ok { | 268 | if !ok { | ||
| 288 | return fieldMatch{}, false | 269 | return fieldMatch{}, false | ||
| 289 | } | 270 | } | ||
| 290 | - | return fieldMatch{field: field, positions: matched.Positions, rank: int(matched.Kind), span: matched.Span, first: matched.First}, true | 271 | + | return fieldMatch{field: field, positions: matched.Positions}, true |
| 291 | - | } | |||
| 292 | - | ||||
| 293 | - | func lessFieldMatch(left, right fieldMatch) bool { | |||
| 294 | - | if left.rank != right.rank || left.span != right.span || left.first != right.first { | |||
| 295 | - | return fuzzy.Less(left.rank, fuzzy.Score{Span: left.span, First: left.first}, right.rank, fuzzy.Score{Span: right.span, First: right.first}) | |||
| 296 | - | } | |||
| 297 | - | return left.field < right.field | |||
| 298 | } | 272 | } | ||