agb_eb_ext 0.25.3

AGB Extension methods
Documentation
use crate::direction::Direction;
use agb::fixnum::{Vector2D, vec2};
use agb::input::ButtonController;

/// Cursor for grid/list menu with every option on screen
///
/// Items are laid out left to right, top to bottom; the last row may be partial.
///
/// Use [`ScrollingCursor`](crate::scrolling_cursor::ScrollingCursor) if menu scrolls
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct MenuCursor {
    idx: u8,
    x: u8,
    y: u8,
    column_count: u8,
    row_count: u8,
    item_count: u8,
    can_wrap: bool,
}

/// Outcome of a cursor update
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum CursorResult {
    /// No direction was pressed
    NoChange,
    /// A direction was pressed but the cursor could not move that way
    Invalid,
    /// The cursor moved
    Moved,
}

impl CursorResult {
    /// `true` if the cursor moved
    #[inline]
    pub const fn is_moved(self) -> bool {
        matches!(self, CursorResult::Moved)
    }
}

impl MenuCursor {
    /// Create a cursor at the first item
    ///
    /// Panics if `column_count` or `item_count` is 0
    pub fn new(column_count: u8, item_count: u8) -> Self {
        assert!(column_count > 0, "MenuCursor created with no columns");
        assert!(item_count > 0, "MenuCursor created with no items");
        let mut row_count = 0;
        let mut remaining = item_count;
        while remaining > 0 {
            remaining = remaining.saturating_sub(column_count);
            row_count += 1;
        }
        Self {
            idx: 0,
            x: 0,
            y: 0,
            column_count,
            row_count,
            item_count,
            can_wrap: false,
        }
    }

    /// Enable wrapping: moving off an edge wraps to the opposite edge of the same row/column
    ///
    /// On a partial last row, wrapping up from the top row lands on the last item in that column,
    /// and wrapping right on the last row lands on the first column.
    pub fn with_wrapping(mut self) -> Self {
        self.can_wrap = true;
        self
    }

    /// Enable or disable wrapping (see [`with_wrapping`](Self::with_wrapping))
    pub fn set_wrapping(&mut self, can_wrap: bool) {
        self.can_wrap = can_wrap;
    }

    /// `true` if the cursor wraps at the edges
    #[inline]
    pub fn can_wrap(&self) -> bool {
        self.can_wrap
    }

    /// Index of selected item
    #[inline]
    pub fn idx(&self) -> usize {
        self.idx as usize
    }

    /// Selected `(column, row)`
    #[inline]
    pub fn pos(&self) -> (u8, u8) {
        (self.x, self.y)
    }

    /// Selected `(column, row)` as a vector
    #[inline]
    pub fn vec_pos(&self) -> Vector2D<i32> {
        vec2(self.x as i32, self.y as i32)
    }

    /// Selected `(column, row)` as `usize`
    #[inline]
    pub fn pos_usize(&self) -> (usize, usize) {
        (self.x as usize, self.y as usize)
    }

    /// Number of columns
    #[inline]
    pub fn column_count(&self) -> u8 {
        self.column_count
    }

    /// Number of rows (including a partial last row)
    #[inline]
    pub fn row_count(&self) -> u8 {
        self.row_count
    }

    /// Number of items
    #[inline]
    pub fn item_count(&self) -> u8 {
        self.item_count
    }

    /// `true` if the first item is selected
    #[inline]
    pub fn is_first(&self) -> bool {
        self.idx == 0
    }

    /// `true` if the last item is selected
    #[inline]
    pub fn is_last(&self) -> bool {
        self.idx + 1 == self.item_count
    }

    /// Select item `idx`, clamped to the last item
    pub fn set_idx(&mut self, idx: usize) {
        self.idx = idx.min((self.item_count - 1) as usize) as u8;
        let mut x = self.idx;
        let mut y = 0;
        while x >= self.column_count {
            x -= self.column_count;
            y += 1;
        }
        self.x = x;
        self.y = y;
    }

    /// Move the cursor based on any direction just pressed
    pub fn update(&mut self, button_controller: &ButtonController) -> CursorResult {
        match Direction::from_recent_input(button_controller) {
            Some(dir) => self.apply(dir),
            None => CursorResult::NoChange,
        }
    }

    /// Move the cursor in `dir`, as if that direction had just been pressed
    pub fn apply(&mut self, dir: Direction) -> CursorResult {
        match dir {
            Direction::Up => {
                if self.y > 0 {
                    self.y -= 1;
                    self.idx -= self.column_count;
                    CursorResult::Moved
                } else if self.can_wrap && self.row_count > 1 {
                    // Last row that has an item in this column
                    let mut idx = self.idx as u16;
                    let mut y = self.y;
                    while idx + (self.column_count as u16) < self.item_count as u16 {
                        idx += self.column_count as u16;
                        y += 1;
                    }
                    self.idx = idx as u8;
                    self.y = y;
                    CursorResult::Moved
                } else {
                    CursorResult::Invalid
                }
            }
            Direction::Down => {
                if (self.idx as u16 + self.column_count as u16) < self.item_count as u16 {
                    self.y += 1;
                    self.idx += self.column_count;
                    CursorResult::Moved
                } else if self.can_wrap && self.y > 0 {
                    self.idx = self.x;
                    self.y = 0;
                    CursorResult::Moved
                } else {
                    CursorResult::Invalid
                }
            }
            Direction::Left => {
                if self.x > 0 {
                    self.x -= 1;
                    self.idx -= 1;
                    CursorResult::Moved
                } else if self.can_wrap && self.column_count > 1 && self.item_count > 1 {
                    // Last item in this row (may be partial)
                    let row_end = self.idx as u16 + self.column_count as u16 - 1;
                    let last = (self.item_count as u16 - 1).min(row_end);
                    self.x = (last - self.idx as u16) as u8;
                    self.idx = last as u8;
                    if self.x == 0 {
                        CursorResult::Invalid
                    } else {
                        CursorResult::Moved
                    }
                } else {
                    CursorResult::Invalid
                }
            }
            Direction::Right => {
                if (self.x + 1 < self.column_count) && (self.idx + 1 < self.item_count) {
                    self.x += 1;
                    self.idx += 1;
                    CursorResult::Moved
                } else if self.can_wrap && self.x > 0 {
                    self.idx -= self.x;
                    self.x = 0;
                    CursorResult::Moved
                } else {
                    CursorResult::Invalid
                }
            }
        }
    }
}

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

    #[test_case]
    fn moves_within_grid(_gba: &mut agb::Gba) {
        let mut cursor = MenuCursor::new(3, 9);
        assert!(cursor.is_first());
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Invalid);
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Invalid);
        assert!(cursor.apply(Direction::Right).is_moved());
        assert_eq!(cursor.pos(), (1, 0));
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Moved);
        assert_eq!(cursor.idx(), 4);
        assert_eq!(cursor.vec_pos(), vec2(1, 1));
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Moved);
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Moved);
        assert_eq!(cursor.pos(), (0, 0));
    }

    #[test_case]
    fn blocked_on_partial_last_row(_gba: &mut agb::Gba) {
        // 3 columns, 8 items: last row only has 2 entries
        let mut cursor = MenuCursor::new(3, 8);
        cursor.set_idx(5);
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Invalid);
        cursor.set_idx(4);
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Moved);
        assert_eq!(cursor.idx(), 7);
        assert!(cursor.is_last());
        assert_eq!(cursor.apply(Direction::Right), CursorResult::Invalid);
    }

    #[test_case]
    fn no_overflow_on_large_grid(_gba: &mut agb::Gba) {
        // idx + column_count exceeds u8::MAX; must report Invalid, not panic or wrap
        let mut cursor = MenuCursor::new(128, 255);
        cursor.set_idx(200);
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Invalid);
        assert_eq!(cursor.idx(), 200);
        cursor.set_idx(254);
        assert!(cursor.is_last());
        assert_eq!(cursor.apply(Direction::Right), CursorResult::Invalid);
    }

    #[test_case]
    fn wraps_within_full_grid(_gba: &mut agb::Gba) {
        let mut cursor = MenuCursor::new(3, 9).with_wrapping();
        assert!(cursor.can_wrap());
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Moved);
        assert_eq!(cursor.pos(), (0, 2));
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Moved);
        assert_eq!(cursor.pos(), (0, 0));
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Moved);
        assert_eq!(cursor.pos(), (2, 0));
        assert_eq!(cursor.idx(), 2);
        assert_eq!(cursor.apply(Direction::Right), CursorResult::Moved);
        assert_eq!(cursor.pos(), (0, 0));
        assert_eq!(cursor.idx(), 0);
    }

    #[test_case]
    fn wraps_on_partial_last_row(_gba: &mut agb::Gba) {
        // 3 columns, 8 items: last row only has 2 entries
        let mut cursor = MenuCursor::new(3, 8).with_wrapping();
        // Up from column 2 lands on row 1 (row 2 has no column 2)
        cursor.set_idx(2);
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Moved);
        assert_eq!(cursor.idx(), 5);
        assert_eq!(cursor.pos(), (2, 1));
        // Down from idx 5 wraps to top
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Moved);
        assert_eq!(cursor.idx(), 2);
        // Left from start of partial row goes to its last item
        cursor.set_idx(6);
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Moved);
        assert_eq!(cursor.idx(), 7);
        assert_eq!(cursor.pos(), (1, 2));
        // Right from last item wraps to start of row
        assert_eq!(cursor.apply(Direction::Right), CursorResult::Moved);
        assert_eq!(cursor.idx(), 6);
        assert_eq!(cursor.pos(), (0, 2));
    }

    #[test_case]
    fn wrap_invalid_when_nowhere_to_go(_gba: &mut agb::Gba) {
        let mut cursor = MenuCursor::new(1, 1).with_wrapping();
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Invalid);
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Invalid);
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Invalid);
        assert_eq!(cursor.apply(Direction::Right), CursorResult::Invalid);
        // Single column list: horizontal is invalid, vertical wraps
        let mut cursor = MenuCursor::new(1, 4).with_wrapping();
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Invalid);
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Moved);
        assert_eq!(cursor.idx(), 3);
        // Single row: vertical is invalid, horizontal wraps
        let mut cursor = MenuCursor::new(4, 4).with_wrapping();
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Invalid);
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Moved);
        assert_eq!(cursor.idx(), 3);
        // Wrap on a large grid does not overflow
        let mut cursor = MenuCursor::new(128, 255).with_wrapping();
        cursor.set_idx(200);
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Moved);
        assert_eq!(cursor.idx(), 72);
        cursor.set_idx(0);
        assert_eq!(cursor.apply(Direction::Left), CursorResult::Moved);
        assert_eq!(cursor.idx(), 127);
    }

    #[test_case]
    fn no_wrap_by_default(_gba: &mut agb::Gba) {
        let mut cursor = MenuCursor::new(3, 9);
        assert!(!cursor.can_wrap());
        cursor.set_wrapping(true);
        assert_eq!(cursor.apply(Direction::Up), CursorResult::Moved);
        cursor.set_wrapping(false);
        assert_eq!(cursor.apply(Direction::Down), CursorResult::Invalid);
    }

    #[test_case]
    fn set_idx_clamps(_gba: &mut agb::Gba) {
        let mut cursor = MenuCursor::new(3, 9);
        cursor.set_idx(500);
        assert_eq!(cursor.idx(), 8);
        assert_eq!(cursor.pos(), (2, 2));
        cursor.set_idx(4);
        assert_eq!(cursor.pos_usize(), (1, 1));
    }

    #[test_case]
    fn counts(_gba: &mut agb::Gba) {
        assert_eq!(MenuCursor::new(3, 9).row_count(), 3);
        assert_eq!(MenuCursor::new(3, 8).row_count(), 3);
        assert_eq!(MenuCursor::new(3, 1).row_count(), 1);
        assert_eq!(MenuCursor::new(1, 255).row_count(), 255);
        assert_eq!(MenuCursor::new(255, 255).row_count(), 1);
        let cursor = MenuCursor::new(4, 10);
        assert_eq!(cursor.column_count(), 4);
        assert_eq!(cursor.item_count(), 10);
    }
}