@@ -179,6 +179,10 @@ contains |
| 179 | 179 | integer :: prev_selected, prev_viewport |
| 180 | 180 | logical :: needs_full_redraw |
| 181 | 181 | character(len=10) :: mode ! "normal" or "git" mode |
| 182 | + ! Search state for fuzzy jump |
| 183 | + character(len=32) :: search_buffer |
| 184 | + integer :: search_length |
| 185 | + real(8) :: last_search_time, current_time |
| 182 | 186 | type(tree_node), pointer :: tree_root |
| 183 | 187 | |
| 184 | 188 | ! Initialize tree pointer |
@@ -246,6 +250,11 @@ contains |
| 246 | 250 | running = .true. |
| 247 | 251 | mode = 'normal' ! Start in normal mode |
| 248 | 252 | |
| 253 | + ! Initialize search state |
| 254 | + search_buffer = '' |
| 255 | + search_length = 0 |
| 256 | + last_search_time = 0.0d0 |
| 257 | + |
| 249 | 258 | ! Partial redraw optimization: initialize tracking state |
| 250 | 259 | prev_selected = 0 ! Force initial draw |
| 251 | 260 | prev_viewport = 0 |
@@ -273,20 +282,32 @@ contains |
| 273 | 282 | ! Full redraw needed: viewport scrolled or forced refresh |
| 274 | 283 | call clear_screen() |
| 275 | 284 | call draw_interactive_tree(tree_root, items, n_items, selected, & |
| 276 | | - repo_name, branch_name, viewport_offset, visible_items, top_padding, mode) |
| 285 | + repo_name, branch_name, viewport_offset, visible_items, top_padding, mode, & |
| 286 | + search_buffer, search_length) |
| 277 | 287 | needs_full_redraw = .false. |
| 278 | 288 | else if (selected /= prev_selected) then |
| 279 | 289 | ! Only selection changed within same viewport - still need full redraw for now |
| 280 | 290 | ! TODO: Could optimize this with partial line updates in the future |
| 281 | 291 | call clear_screen() |
| 282 | 292 | call draw_interactive_tree(tree_root, items, n_items, selected, & |
| 283 | | - repo_name, branch_name, viewport_offset, visible_items, top_padding, mode) |
| 293 | + repo_name, branch_name, viewport_offset, visible_items, top_padding, mode, & |
| 294 | + search_buffer, search_length) |
| 284 | 295 | end if |
| 285 | 296 | |
| 286 | 297 | ! Update tracking state |
| 287 | 298 | prev_selected = selected |
| 288 | 299 | prev_viewport = viewport_offset |
| 289 | 300 | |
| 301 | + ! Check search timeout (1 second) |
| 302 | + if (search_length > 0) then |
| 303 | + current_time = get_wall_time() |
| 304 | + if (current_time - last_search_time > 1.0d0) then |
| 305 | + search_length = 0 |
| 306 | + search_buffer = '' |
| 307 | + needs_full_redraw = .true. |
| 308 | + end if |
| 309 | + end if |
| 310 | + |
| 290 | 311 | ! Read key |
| 291 | 312 | call read_key(key) |
| 292 | 313 | |
@@ -303,13 +324,14 @@ contains |
| 303 | 324 | call execute_command_line('stty sane < /dev/tty') |
| 304 | 325 | call clear_screen() |
| 305 | 326 | call draw_interactive_tree(tree_root, items, n_items, selected, & |
| 306 | | - repo_name, branch_name, viewport_offset, visible_items, top_padding, mode) |
| 327 | + repo_name, branch_name, viewport_offset, visible_items, top_padding, mode, & |
| 328 | + search_buffer, search_length) |
| 307 | 329 | ! Restore cbreak mode |
| 308 | 330 | call enable_raw_mode() |
| 309 | 331 | cycle ! Skip rest of key handling |
| 310 | 332 | end if |
| 311 | 333 | |
| 312 | | - ! Handle ESC key - exit git mode if active |
| 334 | + ! Handle ESC key - exit git mode or clear search |
| 313 | 335 | if (key == achar(27)) then |
| 314 | 336 | if (mode == 'git') then |
| 315 | 337 | mode = 'normal' |
@@ -317,10 +339,17 @@ contains |
| 317 | 339 | call execute_command_line('stty sane < /dev/tty') |
| 318 | 340 | call clear_screen() |
| 319 | 341 | call draw_interactive_tree(tree_root, items, n_items, selected, & |
| 320 | | - repo_name, branch_name, viewport_offset, visible_items, top_padding, mode) |
| 342 | + repo_name, branch_name, viewport_offset, visible_items, top_padding, mode, & |
| 343 | + search_buffer, search_length) |
| 321 | 344 | ! Restore cbreak mode |
| 322 | 345 | call enable_raw_mode() |
| 323 | 346 | cycle |
| 347 | + else if (search_length > 0) then |
| 348 | + ! Clear search in normal mode |
| 349 | + search_length = 0 |
| 350 | + search_buffer = '' |
| 351 | + needs_full_redraw = .true. |
| 352 | + cycle |
| 324 | 353 | end if |
| 325 | 354 | ! In normal mode, ESC does nothing for now |
| 326 | 355 | cycle |
@@ -589,6 +618,39 @@ contains |
| 589 | 618 | ! In normal mode: q quits the application |
| 590 | 619 | running = .false. |
| 591 | 620 | end if |
| 621 | + case default ! Handle fuzzy search in normal mode |
| 622 | + if (mode == 'normal') then |
| 623 | + ! Check if it's a printable letter/number for search |
| 624 | + if ((key >= 'a' .and. key <= 'z') .or. (key >= 'A' .and. key <= 'Z') .or. & |
| 625 | + (key >= '0' .and. key <= '9') .or. key == '_' .or. key == '-' .or. key == '.') then |
| 626 | + ! Add to search buffer |
| 627 | + if (search_length < 32) then |
| 628 | + search_length = search_length + 1 |
| 629 | + search_buffer(search_length:search_length) = key |
| 630 | + last_search_time = get_wall_time() |
| 631 | + |
| 632 | + ! Find first matching item and jump immediately |
| 633 | + call fuzzy_jump_to_match(items, n_items, search_buffer(1:search_length), selected) |
| 634 | + |
| 635 | + ! Redraw will happen at top of loop |
| 636 | + needs_full_redraw = .true. |
| 637 | + end if |
| 638 | + else if (key == achar(127) .or. key == achar(8)) then |
| 639 | + ! Backspace - remove last character |
| 640 | + if (search_length > 0) then |
| 641 | + search_length = search_length - 1 |
| 642 | + last_search_time = get_wall_time() |
| 643 | + |
| 644 | + ! Re-search with shorter pattern |
| 645 | + if (search_length > 0) then |
| 646 | + call fuzzy_jump_to_match(items, n_items, search_buffer(1:search_length), selected) |
| 647 | + end if |
| 648 | + |
| 649 | + ! Redraw will happen at top of loop |
| 650 | + needs_full_redraw = .true. |
| 651 | + end if |
| 652 | + end if |
| 653 | + end if |
| 592 | 654 | end select |
| 593 | 655 | end do |
| 594 | 656 | |
@@ -1410,4 +1472,90 @@ contains |
| 1410 | 1472 | end if |
| 1411 | 1473 | end subroutine refresh_and_rebuild |
| 1412 | 1474 | |
| 1475 | + subroutine fuzzy_jump_to_match(items, n_items, pattern, selected) |
| 1476 | + ! Jump to first item that fuzzy matches the pattern |
| 1477 | + type(selectable_item), intent(in) :: items(:) |
| 1478 | + integer, intent(in) :: n_items |
| 1479 | + character(len=*), intent(in) :: pattern |
| 1480 | + integer, intent(inout) :: selected |
| 1481 | + integer :: i |
| 1482 | + |
| 1483 | + ! Search from current position forward |
| 1484 | + do i = selected, n_items |
| 1485 | + if (fuzzy_match(pattern, items(i)%path)) then |
| 1486 | + selected = i |
| 1487 | + return |
| 1488 | + end if |
| 1489 | + end do |
| 1490 | + |
| 1491 | + ! Wrap around: search from beginning to current position |
| 1492 | + do i = 1, selected - 1 |
| 1493 | + if (fuzzy_match(pattern, items(i)%path)) then |
| 1494 | + selected = i |
| 1495 | + return |
| 1496 | + end if |
| 1497 | + end do |
| 1498 | + |
| 1499 | + ! No match found - stay at current position |
| 1500 | + end subroutine fuzzy_jump_to_match |
| 1501 | + |
| 1502 | + function fuzzy_match(pattern, text) result(matches) |
| 1503 | + ! Fuzzy matching like fzf: pattern chars must appear in order in text |
| 1504 | + ! Case-insensitive matching |
| 1505 | + ! Returns .true. if all pattern chars found in sequence |
| 1506 | + character(len=*), intent(in) :: pattern, text |
| 1507 | + logical :: matches |
| 1508 | + integer :: pattern_idx, text_idx |
| 1509 | + character(len=1) :: pattern_char, text_char |
| 1510 | + |
| 1511 | + matches = .false. |
| 1512 | + |
| 1513 | + ! Empty pattern matches everything |
| 1514 | + if (len_trim(pattern) == 0) then |
| 1515 | + matches = .true. |
| 1516 | + return |
| 1517 | + end if |
| 1518 | + |
| 1519 | + pattern_idx = 1 |
| 1520 | + |
| 1521 | + ! Scan through text looking for each pattern character in order |
| 1522 | + do text_idx = 1, len_trim(text) |
| 1523 | + if (pattern_idx > len_trim(pattern)) exit |
| 1524 | + |
| 1525 | + ! Case-insensitive comparison |
| 1526 | + pattern_char = pattern(pattern_idx:pattern_idx) |
| 1527 | + text_char = text(text_idx:text_idx) |
| 1528 | + |
| 1529 | + ! Convert to lowercase for comparison |
| 1530 | + if (pattern_char >= 'A' .and. pattern_char <= 'Z') then |
| 1531 | + pattern_char = achar(ichar(pattern_char) + 32) |
| 1532 | + end if |
| 1533 | + if (text_char >= 'A' .and. text_char <= 'Z') then |
| 1534 | + text_char = achar(ichar(text_char) + 32) |
| 1535 | + end if |
| 1536 | + |
| 1537 | + if (pattern_char == text_char) then |
| 1538 | + pattern_idx = pattern_idx + 1 |
| 1539 | + end if |
| 1540 | + end do |
| 1541 | + |
| 1542 | + ! Match succeeds if we found all pattern characters |
| 1543 | + matches = (pattern_idx > len_trim(pattern)) |
| 1544 | + end function fuzzy_match |
| 1545 | + |
| 1546 | + function get_wall_time() result(time_seconds) |
| 1547 | + ! Get wall-clock time in seconds (for timeouts) |
| 1548 | + ! Uses system date_and_time which provides millisecond precision |
| 1549 | + real(8) :: time_seconds |
| 1550 | + integer :: values(8) |
| 1551 | + |
| 1552 | + call date_and_time(values=values) |
| 1553 | + |
| 1554 | + ! Convert to seconds: hours*3600 + minutes*60 + seconds + milliseconds/1000 |
| 1555 | + time_seconds = real(values(5), 8) * 3600.0d0 + & |
| 1556 | + real(values(6), 8) * 60.0d0 + & |
| 1557 | + real(values(7), 8) + & |
| 1558 | + real(values(8), 8) / 1000.0d0 |
| 1559 | + end function get_wall_time |
| 1560 | + |
| 1413 | 1561 | end program fuss |