@@ -179,6 +179,10 @@ contains |
| 179 | integer :: prev_selected, prev_viewport | 179 | integer :: prev_selected, prev_viewport |
| 180 | logical :: needs_full_redraw | 180 | logical :: needs_full_redraw |
| 181 | character(len=10) :: mode ! "normal" or "git" mode | 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 | type(tree_node), pointer :: tree_root | 186 | type(tree_node), pointer :: tree_root |
| 183 | | 187 | |
| 184 | ! Initialize tree pointer | 188 | ! Initialize tree pointer |
@@ -246,6 +250,11 @@ contains |
| 246 | running = .true. | 250 | running = .true. |
| 247 | mode = 'normal' ! Start in normal mode | 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 | ! Partial redraw optimization: initialize tracking state | 258 | ! Partial redraw optimization: initialize tracking state |
| 250 | prev_selected = 0 ! Force initial draw | 259 | prev_selected = 0 ! Force initial draw |
| 251 | prev_viewport = 0 | 260 | prev_viewport = 0 |
@@ -273,20 +282,32 @@ contains |
| 273 | ! Full redraw needed: viewport scrolled or forced refresh | 282 | ! Full redraw needed: viewport scrolled or forced refresh |
| 274 | call clear_screen() | 283 | call clear_screen() |
| 275 | call draw_interactive_tree(tree_root, items, n_items, selected, & | 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 | needs_full_redraw = .false. | 287 | needs_full_redraw = .false. |
| 278 | else if (selected /= prev_selected) then | 288 | else if (selected /= prev_selected) then |
| 279 | ! Only selection changed within same viewport - still need full redraw for now | 289 | ! Only selection changed within same viewport - still need full redraw for now |
| 280 | ! TODO: Could optimize this with partial line updates in the future | 290 | ! TODO: Could optimize this with partial line updates in the future |
| 281 | call clear_screen() | 291 | call clear_screen() |
| 282 | call draw_interactive_tree(tree_root, items, n_items, selected, & | 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 | end if | 295 | end if |
| 285 | | 296 | |
| 286 | ! Update tracking state | 297 | ! Update tracking state |
| 287 | prev_selected = selected | 298 | prev_selected = selected |
| 288 | prev_viewport = viewport_offset | 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 | ! Read key | 311 | ! Read key |
| 291 | call read_key(key) | 312 | call read_key(key) |
| 292 | | 313 | |
@@ -303,13 +324,14 @@ contains |
| 303 | call execute_command_line('stty sane < /dev/tty') | 324 | call execute_command_line('stty sane < /dev/tty') |
| 304 | call clear_screen() | 325 | call clear_screen() |
| 305 | call draw_interactive_tree(tree_root, items, n_items, selected, & | 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 | ! Restore cbreak mode | 329 | ! Restore cbreak mode |
| 308 | call enable_raw_mode() | 330 | call enable_raw_mode() |
| 309 | cycle ! Skip rest of key handling | 331 | cycle ! Skip rest of key handling |
| 310 | end if | 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 | if (key == achar(27)) then | 335 | if (key == achar(27)) then |
| 314 | if (mode == 'git') then | 336 | if (mode == 'git') then |
| 315 | mode = 'normal' | 337 | mode = 'normal' |
@@ -317,10 +339,17 @@ contains |
| 317 | call execute_command_line('stty sane < /dev/tty') | 339 | call execute_command_line('stty sane < /dev/tty') |
| 318 | call clear_screen() | 340 | call clear_screen() |
| 319 | call draw_interactive_tree(tree_root, items, n_items, selected, & | 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 | ! Restore cbreak mode | 344 | ! Restore cbreak mode |
| 322 | call enable_raw_mode() | 345 | call enable_raw_mode() |
| 323 | cycle | 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 | end if | 353 | end if |
| 325 | ! In normal mode, ESC does nothing for now | 354 | ! In normal mode, ESC does nothing for now |
| 326 | cycle | 355 | cycle |
@@ -589,6 +618,39 @@ contains |
| 589 | ! In normal mode: q quits the application | 618 | ! In normal mode: q quits the application |
| 590 | running = .false. | 619 | running = .false. |
| 591 | end if | 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 | end select | 654 | end select |
| 593 | end do | 655 | end do |
| 594 | | 656 | |
@@ -1410,4 +1472,90 @@ contains |
| 1410 | end if | 1472 | end if |
| 1411 | end subroutine refresh_and_rebuild | 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 | end program fuss | 1561 | end program fuss |