fast-fs 0.2.1

High-speed async file system traversal library with batteries-included file browser component
Documentation
// <FILE>crates/fast-fs/src/nav/fnc_browser_nav.rs</FILE> - <DESC>Browser navigation helper functions</DESC>
// <VERS>VERSION: 0.2.0</VERS>
// <WCTX>Scroll offset fix</WCTX>
// <CLOG>Fixed update_scroll_offset to handle both top and bottom boundaries</CLOG>

//! Navigation helper functions for Browser
//!
//! These functions handle cursor movement, page navigation, and typeahead search.
//! They operate on primitive values rather than the full Browser struct.

use crate::FileList;

/// Move cursor by delta, clamping to valid range
///
/// Returns the new cursor position.
pub fn move_cursor(cursor: usize, delta: isize, len: usize) -> usize {
    if len == 0 {
        return 0;
    }
    if delta < 0 {
        cursor.saturating_sub((-delta) as usize)
    } else {
        (cursor + delta as usize).min(len - 1)
    }
}

/// Calculate new cursor for page up
///
/// Keeps one line of overlap for context.
pub fn page_up_cursor(cursor: usize, viewport_height: usize) -> usize {
    let jump = viewport_height.saturating_sub(1).max(1);
    cursor.saturating_sub(jump)
}

/// Calculate new cursor for page down
///
/// Keeps one line of overlap for context.
pub fn page_down_cursor(cursor: usize, viewport_height: usize, len: usize) -> usize {
    if len == 0 {
        return 0;
    }
    let jump = viewport_height.saturating_sub(1).max(1);
    (cursor + jump).min(len.saturating_sub(1))
}

/// Update scroll offset to keep cursor visible within viewport
///
/// Handles both top and bottom boundaries with padding.
/// Returns the new scroll offset.
pub fn update_scroll_offset(
    cursor: usize,
    scroll_offset: usize,
    padding: usize,
    viewport_height: usize,
    len: usize,
) -> usize {
    if len == 0 || viewport_height == 0 {
        return 0;
    }

    // Maximum valid scroll offset (can't scroll past end of list)
    let max_scroll = len.saturating_sub(viewport_height);

    // Check top boundary: cursor too close to top of viewport
    if cursor < scroll_offset + padding {
        return cursor.saturating_sub(padding).min(max_scroll);
    }

    // Check bottom boundary: cursor too close to bottom of viewport
    let visible_end = scroll_offset + viewport_height;
    if cursor >= visible_end.saturating_sub(padding) {
        // Scroll so cursor is padding lines from bottom
        let new_scroll =
            cursor.saturating_sub(viewport_height.saturating_sub(1).saturating_sub(padding));
        return new_scroll.min(max_scroll);
    }

    // Cursor is in comfortable range, no scroll change needed
    scroll_offset.min(max_scroll)
}

/// Find next item starting with character (case-insensitive, wrapping)
///
/// Returns Some(index) if found, None otherwise.
pub fn find_char_match(files: &FileList, start_cursor: usize, c: char) -> Option<usize> {
    let len = files.len();
    if len == 0 {
        return None;
    }

    let c_lower = c.to_lowercase().next().unwrap_or(c);
    let start = (start_cursor + 1) % len;

    for i in 0..len {
        let idx = (start + i) % len;
        if let Some(first) = files[idx].name.chars().next() {
            if first.to_lowercase().next() == Some(c_lower) {
                return Some(idx);
            }
        }
    }
    None
}

/// Find next item containing substring (case-insensitive, wrapping)
///
/// Returns Some(index) if found, None otherwise.
pub fn find_substring_match(files: &FileList, start_cursor: usize, query: &str) -> Option<usize> {
    if query.is_empty() {
        return None;
    }

    let len = files.len();
    if len == 0 {
        return None;
    }

    let query_lower = query.to_lowercase();
    let start = (start_cursor + 1) % len;

    for i in 0..len {
        let idx = (start + i) % len;
        if files[idx].name.to_lowercase().contains(&query_lower) {
            return Some(idx);
        }
    }
    None
}

/// Find index of entry by name
#[allow(dead_code)] // Public API for library consumers
pub fn find_by_name(files: &FileList, name: &str) -> Option<usize> {
    files.iter().position(|e| e.name == name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_move_cursor_down() {
        assert_eq!(move_cursor(0, 1, 10), 1);
        assert_eq!(move_cursor(5, 1, 10), 6);
        assert_eq!(move_cursor(9, 1, 10), 9); // clamped
    }

    #[test]
    fn test_move_cursor_up() {
        assert_eq!(move_cursor(5, -1, 10), 4);
        assert_eq!(move_cursor(0, -1, 10), 0); // clamped
    }

    #[test]
    fn test_move_cursor_empty() {
        assert_eq!(move_cursor(0, 1, 0), 0);
        assert_eq!(move_cursor(0, -1, 0), 0);
    }

    #[test]
    fn test_page_up_cursor() {
        assert_eq!(page_up_cursor(20, 10), 11); // 20 - 9 = 11
        assert_eq!(page_up_cursor(5, 10), 0); // clamped
        assert_eq!(page_up_cursor(0, 10), 0);
    }

    #[test]
    fn test_page_down_cursor() {
        assert_eq!(page_down_cursor(0, 10, 50), 9); // 0 + 9 = 9
        assert_eq!(page_down_cursor(45, 10, 50), 49); // clamped to 49
        assert_eq!(page_down_cursor(0, 10, 0), 0); // empty list
    }

    #[test]
    fn test_update_scroll_offset_top_boundary() {
        // viewport=10, padding=2, list len=50
        // Cursor in comfortable range - no change
        assert_eq!(update_scroll_offset(10, 5, 2, 10, 50), 5);

        // Cursor too close to top - scroll up
        assert_eq!(update_scroll_offset(3, 5, 2, 10, 50), 1); // 3 - 2 = 1

        // Cursor at edge of top padding - no change
        assert_eq!(update_scroll_offset(7, 5, 2, 10, 50), 5); // 7 >= 5+2
    }

    #[test]
    fn test_update_scroll_offset_bottom_boundary() {
        // viewport=10, padding=2, list len=50
        // scroll=0, cursor at 49 (last item) - should scroll down
        let result = update_scroll_offset(49, 0, 2, 10, 50);
        assert!(
            result > 0,
            "Should scroll when cursor at end: got {}",
            result
        );
        // Cursor 49 should be visible: scroll + viewport > cursor
        assert!(result + 10 > 49, "Cursor should be visible");

        // scroll=0, cursor at 8 (near bottom of viewport=10, padding=2)
        // visible range is 0..10, so cursor 8 is at position 8 from bottom
        // with padding=2, cursor needs to be >= 10-2=8 to trigger scroll
        let result = update_scroll_offset(8, 0, 2, 10, 50);
        assert!(result >= 1, "Should scroll when cursor near bottom edge");

        // Cursor at 7 should be fine (7 < 10-2=8)
        assert_eq!(update_scroll_offset(7, 0, 2, 10, 50), 0);
    }

    #[test]
    fn test_update_scroll_offset_max_scroll() {
        // Can't scroll past end of list
        // len=15, viewport=10, max_scroll = 15-10 = 5
        let result = update_scroll_offset(14, 0, 2, 10, 15);
        assert!(result <= 5, "Scroll should not exceed max: got {}", result);
    }

    #[test]
    fn test_update_scroll_offset_empty() {
        assert_eq!(update_scroll_offset(0, 0, 2, 10, 0), 0);
        assert_eq!(update_scroll_offset(0, 0, 2, 0, 10), 0);
    }
}

// <FILE>crates/fast-fs/src/nav/fnc_browser_nav.rs</FILE>
// <VERS>END OF VERSION: 0.2.0</VERS>