@@ -313,6 +313,22 @@ contains |
| 313 | 313 | ! Always use fast blocking read - timeouts are too slow |
| 314 | 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 | 332 | ! Check for alt-g to toggle git mode |
| 317 | 333 | ! alt-g is encoded as achar(1 + ichar('g') - ichar('a')) = achar(7) |
| 318 | 334 | if (key == achar(7)) then |
@@ -391,11 +407,6 @@ contains |
| 391 | 407 | |
| 392 | 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 | 410 | needs_full_redraw = .true. |
| 400 | 411 | end if |
| 401 | 412 | cycle ! Skip case statement |
@@ -692,17 +703,14 @@ contains |
| 692 | 703 | visible_items = term_height - top_padding - 6 |
| 693 | 704 | if (visible_items < 3) visible_items = 3 |
| 694 | 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 | 707 | if (mode == 'git') then |
| 697 | 708 | ! In git mode: q exits to normal mode |
| 698 | 709 | mode = 'normal' |
| 699 | 710 | needs_full_redraw = .true. |
| 700 | | - else |
| 701 | | - ! In normal mode: q quits the application |
| 702 | | - running = .false. |
| 703 | 711 | end if |
| 704 | | - case (achar(17)) ! Ctrl-Q - force quit from any mode |
| 705 | | - running = .false. |
| 712 | + ! Note: In normal mode, 'q' is used for fuzzy search |
| 713 | + ! Use ctrl-q to quit from normal mode |
| 706 | 714 | case default |
| 707 | 715 | ! Unhandled keys - do nothing |
| 708 | 716 | continue |
@@ -1528,102 +1536,190 @@ contains |
| 1528 | 1536 | end subroutine refresh_and_rebuild |
| 1529 | 1537 | |
| 1530 | 1538 | subroutine fuzzy_jump_to_match(items, n_items, pattern, selected) |
| 1531 | | - ! Jump to first item that fuzzy matches the pattern |
| 1532 | | - ! Prioritizes matching item names (basename) over full paths |
| 1533 | | - ! Searches from NEXT position (skips current item to allow cycling) |
| 1539 | + ! Jump to BEST matching item using fzf-style scoring |
| 1540 | + ! Two-pass approach: basename matches first, then path matches |
| 1541 | + ! This ensures "src" matches "src/" directory before "src/file.f90" |
| 1534 | 1542 | type(selectable_item), intent(in) :: items(:) |
| 1535 | 1543 | integer, intent(in) :: n_items |
| 1536 | 1544 | character(len=*), intent(in) :: pattern |
| 1537 | 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) |
| 1541 | | - start_pos = selected + 1 |
| 1542 | | - if (start_pos > n_items) start_pos = 1 |
| 1566 | + ! PASS 1: Search for basename matches (directories, file names) |
| 1567 | + do i = 1, n_items |
| 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 | 1570 | if (associated(items(i)%node)) then |
| 1548 | | - if (fuzzy_match(pattern, items(i)%node%name)) then |
| 1549 | | - selected = i |
| 1550 | | - return |
| 1571 | + score = fuzzy_match_score(pattern, items(i)%node%name) |
| 1572 | + if (score > best_score) then |
| 1573 | + best_score = score |
| 1574 | + best_idx = i |
| 1551 | 1575 | end if |
| 1552 | 1576 | end if |
| 1553 | 1577 | end do |
| 1554 | 1578 | |
| 1555 | | - ! Wrap around: search from beginning to current position (inclusive) |
| 1556 | | - do i = 1, selected |
| 1557 | | - if (associated(items(i)%node)) then |
| 1558 | | - if (fuzzy_match(pattern, items(i)%node%name)) then |
| 1559 | | - selected = i |
| 1560 | | - return |
| 1561 | | - end if |
| 1562 | | - end if |
| 1563 | | - end do |
| 1579 | + ! If we found a good basename match, use it |
| 1580 | + if (best_score >= 5000) then ! Prefix or exact match |
| 1581 | + selected = best_idx |
| 1582 | + ! DEBUG |
| 1583 | + open(99, file='/tmp/fuss_debug.log', position='append') |
| 1584 | + write(99, '(A,I0,A,A,A,I0,A)') ' BASENAME MATCH: item=', best_idx, ' path=', & |
| 1585 | + trim(items(best_idx)%path), ' score=', best_score, ' (basename)' |
| 1586 | + close(99) |
| 1587 | + return |
| 1588 | + end if |
| 1564 | 1589 | |
| 1565 | | - ! PASS 2: If no name match, try matching full path |
| 1566 | | - ! Search from next position forward |
| 1567 | | - do i = start_pos, n_items |
| 1568 | | - if (fuzzy_match(pattern, items(i)%path)) then |
| 1569 | | - selected = i |
| 1570 | | - return |
| 1571 | | - end if |
| 1572 | | - end do |
| 1590 | + ! PASS 2: Search full paths if no good basename match |
| 1591 | + do i = 1, n_items |
| 1592 | + if (i == selected) cycle |
| 1573 | 1593 | |
| 1574 | | - ! Wrap around: search from beginning to current position (inclusive) |
| 1575 | | - do i = 1, selected |
| 1576 | | - if (fuzzy_match(pattern, items(i)%path)) then |
| 1577 | | - selected = i |
| 1578 | | - return |
| 1594 | + score = fuzzy_match_score(pattern, items(i)%path) |
| 1595 | + if (score > best_score) then |
| 1596 | + best_score = score |
| 1597 | + best_idx = i |
| 1579 | 1598 | end if |
| 1580 | 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 | 1610 | end subroutine fuzzy_jump_to_match |
| 1584 | 1611 | |
| 1585 | | - function fuzzy_match(pattern, text) result(matches) |
| 1586 | | - ! Fuzzy matching like fzf: pattern chars must appear in order in text |
| 1587 | | - ! Case-insensitive matching |
| 1588 | | - ! Returns .true. if all pattern chars found in sequence |
| 1612 | + function fuzzy_match_score(pattern, text) result(score) |
| 1613 | + ! Fuzzy matching with fzf-style scoring |
| 1614 | + ! Returns a score (higher is better), 0 means no match |
| 1589 | 1615 | character(len=*), intent(in) :: pattern, text |
| 1590 | | - logical :: matches |
| 1591 | | - integer :: pattern_idx, text_idx |
| 1592 | | - character(len=1) :: pattern_char, text_char |
| 1616 | + integer :: score |
| 1617 | + integer :: pattern_idx, text_idx, match_start, consecutive_bonus |
| 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 | 1624 | if (len_trim(pattern) == 0) then |
| 1598 | | - matches = .true. |
| 1625 | + score = 1 |
| 1599 | 1626 | return |
| 1600 | 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 | 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 |
| 1605 | | - do text_idx = 1, len_trim(text) |
| 1606 | | - if (pattern_idx > len_trim(pattern)) exit |
| 1655 | + do text_idx = 1, len_trim(text_lower) |
| 1656 | + if (pattern_idx > len_trim(pattern_lower)) exit |
| 1607 | 1657 | |
| 1608 | | - ! Case-insensitive comparison |
| 1609 | | - pattern_char = pattern(pattern_idx:pattern_idx) |
| 1610 | | - text_char = text(text_idx:text_idx) |
| 1658 | + if (pattern_lower(pattern_idx:pattern_idx) == text_lower(text_idx:text_idx)) then |
| 1659 | + if (match_start == -1) match_start = text_idx |
| 1611 | 1660 | |
| 1612 | | - ! Convert to lowercase for comparison |
| 1613 | | - if (pattern_char >= 'A' .and. pattern_char <= 'Z') then |
| 1614 | | - pattern_char = achar(ichar(pattern_char) + 32) |
| 1615 | | - end if |
| 1616 | | - if (text_char >= 'A' .and. text_char <= 'Z') then |
| 1617 | | - text_char = achar(ichar(text_char) + 32) |
| 1618 | | - end if |
| 1661 | + ! Base score for each matched character |
| 1662 | + score = score + 100 |
| 1663 | + |
| 1664 | + ! Bonus for consecutive characters |
| 1665 | + if (is_consecutive) then |
| 1666 | + consecutive_bonus = consecutive_bonus + 1 |
| 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 | 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 | 1697 | end if |
| 1623 | 1698 | end do |
| 1624 | 1699 | |
| 1625 | | - ! Match succeeds if we found all pattern characters |
| 1626 | | - matches = (pattern_idx > len_trim(pattern)) |
| 1627 | | - end function fuzzy_match |
| 1700 | + ! No match if we didn't find all pattern characters |
| 1701 | + if (pattern_idx <= len_trim(pattern_lower)) then |
| 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 | 1725 | end program fuss |