@@ -313,6 +313,22 @@ contains |
| 313 | ! Always use fast blocking read - timeouts are too slow | 313 | ! Always use fast blocking read - timeouts are too slow |
| 314 | call read_key(key) | 314 | call read_key(key) |
| 315 | | 315 | |
| | 316 | + ! DEBUG: Log all control characters to see what we're getting |
| | 317 | + if (ichar(key) < 32) then |
| | 318 | + open(99, file='/tmp/fuss_debug.log', position='append') |
| | 319 | + write(99, '(A,I0)') 'Control char received: ', ichar(key) |
| | 320 | + close(99) |
| | 321 | + end if |
| | 322 | + |
| | 323 | + ! Check for ctrl-q to quit (priority over everything) |
| | 324 | + if (key == achar(17)) then |
| | 325 | + open(99, file='/tmp/fuss_debug.log', position='append') |
| | 326 | + write(99, '(A)') 'CTRL-Q detected - quitting!' |
| | 327 | + close(99) |
| | 328 | + running = .false. |
| | 329 | + cycle |
| | 330 | + end if |
| | 331 | + |
| 316 | ! Check for alt-g to toggle git mode | 332 | ! Check for alt-g to toggle git mode |
| 317 | ! alt-g is encoded as achar(1 + ichar('g') - ichar('a')) = achar(7) | 333 | ! alt-g is encoded as achar(1 + ichar('g') - ichar('a')) = achar(7) |
| 318 | if (key == achar(7)) then | 334 | if (key == achar(7)) then |
@@ -391,11 +407,6 @@ contains |
| 391 | | 407 | |
| 392 | call fuzzy_jump_to_match(items, n_items, search_buffer(1:search_length), selected) | 408 | call fuzzy_jump_to_match(items, n_items, search_buffer(1:search_length), selected) |
| 393 | | 409 | |
| 394 | - ! DEBUG | | |
| 395 | - open(99, file='/tmp/fuss_debug.log', position='append') | | |
| 396 | - write(99, '(A,I0,A,A)') ' Result: selected=', selected, ' path=', trim(items(selected)%path) | | |
| 397 | - close(99) | | |
| 398 | - | | |
| 399 | needs_full_redraw = .true. | 410 | needs_full_redraw = .true. |
| 400 | end if | 411 | end if |
| 401 | cycle ! Skip case statement | 412 | cycle ! Skip case statement |
@@ -692,17 +703,14 @@ contains |
| 692 | visible_items = term_height - top_padding - 6 | 703 | visible_items = term_height - top_padding - 6 |
| 693 | if (visible_items < 3) visible_items = 3 | 704 | if (visible_items < 3) visible_items = 3 |
| 694 | if (visible_items > n_items) visible_items = n_items | 705 | if (visible_items > n_items) visible_items = n_items |
| 695 | - case ('q', 'Q') ! Quit or exit git mode | 706 | + case ('q', 'Q') ! Exit git mode |
| 696 | if (mode == 'git') then | 707 | if (mode == 'git') then |
| 697 | ! In git mode: q exits to normal mode | 708 | ! In git mode: q exits to normal mode |
| 698 | mode = 'normal' | 709 | mode = 'normal' |
| 699 | needs_full_redraw = .true. | 710 | needs_full_redraw = .true. |
| 700 | - else | | |
| 701 | - ! In normal mode: q quits the application | | |
| 702 | - running = .false. | | |
| 703 | end if | 711 | end if |
| 704 | - case (achar(17)) ! Ctrl-Q - force quit from any mode | 712 | + ! Note: In normal mode, 'q' is used for fuzzy search |
| 705 | - running = .false. | 713 | + ! Use ctrl-q to quit from normal mode |
| 706 | case default | 714 | case default |
| 707 | ! Unhandled keys - do nothing | 715 | ! Unhandled keys - do nothing |
| 708 | continue | 716 | continue |
@@ -1528,102 +1536,190 @@ contains |
| 1528 | end subroutine refresh_and_rebuild | 1536 | end subroutine refresh_and_rebuild |
| 1529 | | 1537 | |
| 1530 | subroutine fuzzy_jump_to_match(items, n_items, pattern, selected) | 1538 | subroutine fuzzy_jump_to_match(items, n_items, pattern, selected) |
| 1531 | - ! Jump to first item that fuzzy matches the pattern | 1539 | + ! Jump to BEST matching item using fzf-style scoring |
| 1532 | - ! Prioritizes matching item names (basename) over full paths | 1540 | + ! Two-pass approach: basename matches first, then path matches |
| 1533 | - ! Searches from NEXT position (skips current item to allow cycling) | 1541 | + ! This ensures "src" matches "src/" directory before "src/file.f90" |
| 1534 | type(selectable_item), intent(in) :: items(:) | 1542 | type(selectable_item), intent(in) :: items(:) |
| 1535 | integer, intent(in) :: n_items | 1543 | integer, intent(in) :: n_items |
| 1536 | character(len=*), intent(in) :: pattern | 1544 | character(len=*), intent(in) :: pattern |
| 1537 | integer, intent(inout) :: selected | 1545 | integer, intent(inout) :: selected |
| 1538 | - integer :: i, start_pos | 1546 | + integer :: i, best_idx, best_score, score, current_score |
| | 1547 | + |
| | 1548 | + best_idx = selected ! Stay at current if no matches |
| | 1549 | + best_score = 0 |
| | 1550 | + |
| | 1551 | + ! Check current item's basename first - if it's a perfect match, stay on it! |
| | 1552 | + if (associated(items(selected)%node)) then |
| | 1553 | + current_score = fuzzy_match_score(pattern, items(selected)%node%name) |
| | 1554 | + if (current_score >= 10000) then ! Exact match - stay here! |
| | 1555 | + ! DEBUG |
| | 1556 | + open(99, file='/tmp/fuss_debug.log', position='append') |
| | 1557 | + write(99, '(A,I0,A,A,A,I0,A)') ' EXACT MATCH (current): item=', selected, ' path=', & |
| | 1558 | + trim(items(selected)%path), ' score=', current_score, ' (basename)' |
| | 1559 | + close(99) |
| | 1560 | + return |
| | 1561 | + end if |
| | 1562 | + best_score = current_score |
| | 1563 | + best_idx = selected |
| | 1564 | + end if |
| 1539 | | 1565 | |
| 1540 | - ! Start from next item (so repeated searches cycle through matches) | 1566 | + ! PASS 1: Search for basename matches (directories, file names) |
| 1541 | - start_pos = selected + 1 | 1567 | + do i = 1, n_items |
| 1542 | - if (start_pos > n_items) start_pos = 1 | 1568 | + if (i == selected) cycle ! Already checked current above |
| 1543 | | 1569 | |
| 1544 | - ! PASS 1: Try to match item NAME first (e.g., "src" matches "src/" before "src/file.f90") | | |
| 1545 | - ! Search from next position forward | | |
| 1546 | - do i = start_pos, n_items | | |
| 1547 | if (associated(items(i)%node)) then | 1570 | if (associated(items(i)%node)) then |
| 1548 | - if (fuzzy_match(pattern, items(i)%node%name)) then | 1571 | + score = fuzzy_match_score(pattern, items(i)%node%name) |
| 1549 | - selected = i | 1572 | + if (score > best_score) then |
| 1550 | - return | 1573 | + best_score = score |
| | 1574 | + best_idx = i |
| 1551 | end if | 1575 | end if |
| 1552 | end if | 1576 | end if |
| 1553 | end do | 1577 | end do |
| 1554 | | 1578 | |
| 1555 | - ! Wrap around: search from beginning to current position (inclusive) | 1579 | + ! If we found a good basename match, use it |
| 1556 | - do i = 1, selected | 1580 | + if (best_score >= 5000) then ! Prefix or exact match |
| 1557 | - if (associated(items(i)%node)) then | 1581 | + selected = best_idx |
| 1558 | - if (fuzzy_match(pattern, items(i)%node%name)) then | 1582 | + ! DEBUG |
| 1559 | - selected = i | 1583 | + open(99, file='/tmp/fuss_debug.log', position='append') |
| 1560 | - return | 1584 | + write(99, '(A,I0,A,A,A,I0,A)') ' BASENAME MATCH: item=', best_idx, ' path=', & |
| 1561 | - end if | 1585 | + trim(items(best_idx)%path), ' score=', best_score, ' (basename)' |
| 1562 | - end if | 1586 | + close(99) |
| 1563 | - end do | 1587 | + return |
| | 1588 | + end if |
| 1564 | | 1589 | |
| 1565 | - ! PASS 2: If no name match, try matching full path | 1590 | + ! PASS 2: Search full paths if no good basename match |
| 1566 | - ! Search from next position forward | 1591 | + do i = 1, n_items |
| 1567 | - do i = start_pos, n_items | 1592 | + if (i == selected) cycle |
| 1568 | - if (fuzzy_match(pattern, items(i)%path)) then | | |
| 1569 | - selected = i | | |
| 1570 | - return | | |
| 1571 | - end if | | |
| 1572 | - end do | | |
| 1573 | | 1593 | |
| 1574 | - ! Wrap around: search from beginning to current position (inclusive) | 1594 | + score = fuzzy_match_score(pattern, items(i)%path) |
| 1575 | - do i = 1, selected | 1595 | + if (score > best_score) then |
| 1576 | - if (fuzzy_match(pattern, items(i)%path)) then | 1596 | + best_score = score |
| 1577 | - selected = i | 1597 | + best_idx = i |
| 1578 | - return | | |
| 1579 | end if | 1598 | end if |
| 1580 | end do | 1599 | end do |
| 1581 | | 1600 | |
| 1582 | - ! No match found - stay at current position | 1601 | + ! Jump to best match if any was found |
| | 1602 | + if (best_score > 0) then |
| | 1603 | + selected = best_idx |
| | 1604 | + ! DEBUG |
| | 1605 | + open(99, file='/tmp/fuss_debug.log', position='append') |
| | 1606 | + write(99, '(A,I0,A,A,A,I0,A)') ' PATH MATCH: item=', best_idx, ' path=', & |
| | 1607 | + trim(items(best_idx)%path), ' score=', best_score, ' (fullpath)' |
| | 1608 | + close(99) |
| | 1609 | + end if |
| 1583 | end subroutine fuzzy_jump_to_match | 1610 | end subroutine fuzzy_jump_to_match |
| 1584 | | 1611 | |
| 1585 | - function fuzzy_match(pattern, text) result(matches) | 1612 | + function fuzzy_match_score(pattern, text) result(score) |
| 1586 | - ! Fuzzy matching like fzf: pattern chars must appear in order in text | 1613 | + ! Fuzzy matching with fzf-style scoring |
| 1587 | - ! Case-insensitive matching | 1614 | + ! Returns a score (higher is better), 0 means no match |
| 1588 | - ! Returns .true. if all pattern chars found in sequence | | |
| 1589 | character(len=*), intent(in) :: pattern, text | 1615 | character(len=*), intent(in) :: pattern, text |
| 1590 | - logical :: matches | 1616 | + integer :: score |
| 1591 | - integer :: pattern_idx, text_idx | 1617 | + integer :: pattern_idx, text_idx, match_start, consecutive_bonus |
| 1592 | - character(len=1) :: pattern_char, text_char | 1618 | + character(len=256) :: pattern_lower, text_lower |
| | 1619 | + logical :: is_consecutive |
| 1593 | | 1620 | |
| 1594 | - matches = .false. | 1621 | + score = 0 |
| 1595 | | 1622 | |
| 1596 | - ! Empty pattern matches everything | 1623 | + ! Empty pattern matches everything with score 1 |
| 1597 | if (len_trim(pattern) == 0) then | 1624 | if (len_trim(pattern) == 0) then |
| 1598 | - matches = .true. | 1625 | + score = 1 |
| 1599 | return | 1626 | return |
| 1600 | end if | 1627 | end if |
| 1601 | | 1628 | |
| | 1629 | + ! Convert to lowercase once |
| | 1630 | + pattern_lower = pattern |
| | 1631 | + text_lower = text |
| | 1632 | + call to_lowercase(pattern_lower) |
| | 1633 | + call to_lowercase(text_lower) |
| | 1634 | + |
| | 1635 | + ! Check for exact match first (highest score) |
| | 1636 | + if (trim(pattern_lower) == trim(text_lower)) then |
| | 1637 | + score = 10000 |
| | 1638 | + return |
| | 1639 | + end if |
| | 1640 | + |
| | 1641 | + ! Check for prefix match (very high score) |
| | 1642 | + if (len_trim(pattern_lower) <= len_trim(text_lower)) then |
| | 1643 | + if (text_lower(1:len_trim(pattern_lower)) == trim(pattern_lower)) then |
| | 1644 | + score = 5000 |
| | 1645 | + return |
| | 1646 | + end if |
| | 1647 | + end if |
| | 1648 | + |
| | 1649 | + ! Fuzzy match with scoring |
| 1602 | pattern_idx = 1 | 1650 | pattern_idx = 1 |
| | 1651 | + consecutive_bonus = 0 |
| | 1652 | + is_consecutive = .false. |
| | 1653 | + match_start = -1 |
| 1603 | | 1654 | |
| 1604 | - ! Scan through text looking for each pattern character in order | 1655 | + do text_idx = 1, len_trim(text_lower) |
| 1605 | - do text_idx = 1, len_trim(text) | 1656 | + if (pattern_idx > len_trim(pattern_lower)) exit |
| 1606 | - if (pattern_idx > len_trim(pattern)) exit | | |
| 1607 | | 1657 | |
| 1608 | - ! Case-insensitive comparison | 1658 | + if (pattern_lower(pattern_idx:pattern_idx) == text_lower(text_idx:text_idx)) then |
| 1609 | - pattern_char = pattern(pattern_idx:pattern_idx) | 1659 | + if (match_start == -1) match_start = text_idx |
| 1610 | - text_char = text(text_idx:text_idx) | | |
| 1611 | | 1660 | |
| 1612 | - ! Convert to lowercase for comparison | 1661 | + ! Base score for each matched character |
| 1613 | - if (pattern_char >= 'A' .and. pattern_char <= 'Z') then | 1662 | + score = score + 100 |
| 1614 | - pattern_char = achar(ichar(pattern_char) + 32) | 1663 | + |
| 1615 | - end if | 1664 | + ! Bonus for consecutive characters |
| 1616 | - if (text_char >= 'A' .and. text_char <= 'Z') then | 1665 | + if (is_consecutive) then |
| 1617 | - text_char = achar(ichar(text_char) + 32) | 1666 | + consecutive_bonus = consecutive_bonus + 1 |
| 1618 | - end if | 1667 | + score = score + consecutive_bonus * 50 |
| | 1668 | + else |
| | 1669 | + consecutive_bonus = 1 |
| | 1670 | + is_consecutive = .true. |
| | 1671 | + end if |
| | 1672 | + |
| | 1673 | + ! Bonus for matching at start of text |
| | 1674 | + if (text_idx == 1) then |
| | 1675 | + score = score + 200 |
| | 1676 | + end if |
| | 1677 | + |
| | 1678 | + ! Bonus for matching after separator (word boundary) |
| | 1679 | + if (text_idx > 1) then |
| | 1680 | + if (text_lower(text_idx-1:text_idx-1) == '/' .or. & |
| | 1681 | + text_lower(text_idx-1:text_idx-1) == '_' .or. & |
| | 1682 | + text_lower(text_idx-1:text_idx-1) == '-' .or. & |
| | 1683 | + text_lower(text_idx-1:text_idx-1) == '.') then |
| | 1684 | + score = score + 150 |
| | 1685 | + end if |
| | 1686 | + end if |
| 1619 | | 1687 | |
| 1620 | - if (pattern_char == text_char) then | | |
| 1621 | pattern_idx = pattern_idx + 1 | 1688 | pattern_idx = pattern_idx + 1 |
| | 1689 | + else |
| | 1690 | + ! Reset consecutive bonus when characters don't match |
| | 1691 | + is_consecutive = .false. |
| | 1692 | + consecutive_bonus = 0 |
| | 1693 | + ! Small penalty for gaps |
| | 1694 | + if (match_start > 0) then |
| | 1695 | + score = score - 1 |
| | 1696 | + end if |
| 1622 | end if | 1697 | end if |
| 1623 | end do | 1698 | end do |
| 1624 | | 1699 | |
| 1625 | - ! Match succeeds if we found all pattern characters | 1700 | + ! No match if we didn't find all pattern characters |
| 1626 | - matches = (pattern_idx > len_trim(pattern)) | 1701 | + if (pattern_idx <= len_trim(pattern_lower)) then |
| 1627 | - end function fuzzy_match | 1702 | + score = 0 |
| | 1703 | + return |
| | 1704 | + end if |
| | 1705 | + |
| | 1706 | + ! Bonus for shorter strings (prefer concise matches) |
| | 1707 | + score = score - len_trim(text_lower) |
| | 1708 | + |
| | 1709 | + end function fuzzy_match_score |
| | 1710 | + |
| | 1711 | + subroutine to_lowercase(str) |
| | 1712 | + ! Convert string to lowercase in-place |
| | 1713 | + character(len=*), intent(inout) :: str |
| | 1714 | + integer :: i |
| | 1715 | + character(len=1) :: c |
| | 1716 | + |
| | 1717 | + do i = 1, len_trim(str) |
| | 1718 | + c = str(i:i) |
| | 1719 | + if (c >= 'A' .and. c <= 'Z') then |
| | 1720 | + str(i:i) = achar(ichar(c) + 32) |
| | 1721 | + end if |
| | 1722 | + end do |
| | 1723 | + end subroutine to_lowercase |
| 1628 | | 1724 | |
| 1629 | end program fuss | 1725 | end program fuss |