From 59b17d461d12f15cdc3d829fc9b8e189434949a2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 21 May 2026 11:53:09 +0200 Subject: [PATCH] feat: improve large page navigation --- src/input/state/actions/key_press/panels.rs | 18 +- src/input/state/core/board_picker/mod.rs | 13 + src/input/state/core/board_picker/state.rs | 1 + .../state/core/board_picker/state/actions.rs | 8 +- .../state/core/board_picker/state/edit.rs | 29 +- .../core/board_picker/state/lifecycle.rs | 94 +++- .../state/core/board_picker/state/nav.rs | 438 +++++++++++++++++ src/input/state/core/menus/commands.rs | 27 +- src/input/state/tests/board_picker.rs | 451 +++++++++++++++++- src/input/state/tests/menus/context_menu.rs | 91 ++++ src/ui/board_picker/page_panel.rs | 1 + .../page_panel/thumbnail/cards.rs | 15 + .../page_panel/thumbnail/types.rs | 1 + 13 files changed, 1153 insertions(+), 34 deletions(-) create mode 100644 src/input/state/core/board_picker/state/nav.rs diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 7383e03b..66ad188d 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -108,7 +108,11 @@ impl InputState { _ => true, } } else if self.board_picker_focus() == BoardPickerFocus::PagePanel { - self.handle_board_picker_page_panel_key(key) + if let Some(consumed) = self.handle_board_picker_page_nav_key(key) { + consumed + } else { + self.handle_board_picker_page_panel_key(key) + } } else { self.handle_board_picker_board_list_key(key) } @@ -348,6 +352,18 @@ impl InputState { self.board_picker_add_page(); true } + Key::Char('g') | Key::Char('G') if self.modifiers.ctrl => { + self.board_picker_begin_page_jump(); + true + } + Key::Char('f') | Key::Char('F') if self.modifiers.ctrl => { + self.board_picker_begin_page_search(); + true + } + Key::Char('/') if !self.modifiers.ctrl && !self.modifiers.alt => { + self.board_picker_begin_page_search(); + true + } Key::Backspace => { self.board_picker_backspace_search(); true diff --git a/src/input/state/core/board_picker/mod.rs b/src/input/state/core/board_picker/mod.rs index 4c87d6fb..a6479112 100644 --- a/src/input/state/core/board_picker/mod.rs +++ b/src/input/state/core/board_picker/mod.rs @@ -48,6 +48,8 @@ const BOARD_PICKER_RECENT_LINE_HEIGHT_COMPACT: f64 = 14.0; const BOARD_PICKER_RECENT_MAX_NAMES: usize = 3; const BOARD_PICKER_RECENT_LABEL_MAX_CHARS: usize = BOARD_PICKER_SEARCH_MAX_LEN + 6; const MAX_PAGE_NAME_LEN: usize = 40; +const BOARD_PICKER_PAGE_SEARCH_MAX_LEN: usize = 40; +const BOARD_PICKER_PAGE_JUMP_MAX_LEN: usize = 6; const PAGE_PANEL_GAP: f64 = 16.0; const PAGE_PANEL_PADDING_X: f64 = 12.0; const PAGE_THUMB_HEIGHT: f64 = 88.0; @@ -77,6 +79,10 @@ pub enum BoardPickerState { page_focus_page_index: Option, page_scroll_row: usize, page_scroll_target_page_index: Option, + page_nav_mode: BoardPickerPageNavMode, + page_search_query: String, + page_search_cursor: Option, + page_jump_buffer: String, }, } @@ -92,6 +98,13 @@ pub enum BoardPickerMode { Quick, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoardPickerPageNavMode { + Normal, + Jump, + Search, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoardPickerEditMode { Name, diff --git a/src/input/state/core/board_picker/state.rs b/src/input/state/core/board_picker/state.rs index 03cfd856..31916c80 100644 --- a/src/input/state/core/board_picker/state.rs +++ b/src/input/state/core/board_picker/state.rs @@ -2,4 +2,5 @@ mod actions; mod drag; mod edit; mod lifecycle; +mod nav; mod ordering; diff --git a/src/input/state/core/board_picker/state/actions.rs b/src/input/state/core/board_picker/state/actions.rs index 8cea6afc..0fec3f91 100644 --- a/src/input/state/core/board_picker/state/actions.rs +++ b/src/input/state/core/board_picker/state/actions.rs @@ -60,6 +60,7 @@ impl InputState { { self.board_picker_queue_page_scroll_to(page_index); self.board_picker_set_page_focus_page_index(page_index); + self.board_picker_reconcile_page_nav_after_page_change(); } } @@ -67,7 +68,11 @@ impl InputState { let Some(board_index) = self.board_picker_page_panel_board_index() else { return PageDeleteOutcome::Pending; }; - self.delete_page_in_board(board_index, page_index) + let outcome = self.delete_page_in_board(board_index, page_index); + if !matches!(outcome, PageDeleteOutcome::Pending) { + self.board_picker_reconcile_page_nav_after_page_change(); + } + outcome } pub(crate) fn board_picker_duplicate_page(&mut self, page_index: usize) { @@ -83,6 +88,7 @@ impl InputState { { self.board_picker_queue_page_scroll_to(new_page_index); self.board_picker_set_page_focus_page_index(new_page_index); + self.board_picker_reconcile_page_nav_after_page_change(); } } diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index c4372c19..34f8de06 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -5,8 +5,8 @@ use super::super::super::base::{InputState, UiToastKind}; use super::super::{ BOARD_PICKER_RECENT_LABEL_MAX_CHARS, BOARD_PICKER_RECENT_MAX_NAMES, BOARD_PICKER_SEARCH_MAX_LEN, BoardPickerEdit, BoardPickerEditMode, BoardPickerFocus, - BoardPickerMode, BoardPickerPageEdit, BoardPickerState, MAX_BOARD_NAME_LEN, MAX_PAGE_NAME_LEN, - color_to_hex, parse_hex_color, truncate_search_label, + BoardPickerMode, BoardPickerPageEdit, BoardPickerPageNavMode, BoardPickerState, + MAX_BOARD_NAME_LEN, MAX_PAGE_NAME_LEN, color_to_hex, parse_hex_color, truncate_search_label, }; impl InputState { @@ -73,6 +73,7 @@ impl InputState { let name = edit.buffer.trim().to_string(); let name = if name.is_empty() { None } else { Some(name) }; let _ = self.rename_page_in_board(edit.board_index, edit.page_index, name); + self.board_picker_reconcile_page_nav_after_page_change(); self.needs_redraw = true; true } @@ -105,6 +106,20 @@ impl InputState { } pub(crate) fn board_picker_footer_text(&self) -> String { + match self.board_picker_page_nav_mode() { + BoardPickerPageNavMode::Jump => { + let buffer = self.board_picker_page_jump_buffer().unwrap_or_default(); + return format!("Go to page: {buffer} Enter: go Esc: cancel"); + } + BoardPickerPageNavMode::Search => { + let query = self.board_picker_page_search_query().unwrap_or_default(); + if !query.trim().is_empty() && self.board_picker_page_search_match_count() == 0 { + return format!("Search pages: {query} No matches Esc: clear"); + } + return format!("Search pages: {query} Enter: open F3: next Esc: clear"); + } + BoardPickerPageNavMode::Normal => {} + } let search = self.board_picker_search.trim(); if !search.is_empty() { return format!( @@ -115,7 +130,7 @@ impl InputState { if self.board_picker_is_quick() { "Enter: switch Type: jump Esc: close".to_string() } else if self.board_picker_focus() == BoardPickerFocus::PagePanel { - "Enter: open Ctrl+N: add F2: rename Del: delete Tab: back".to_string() + "Enter: open Ctrl+N: add Ctrl+G: page /: search F2: rename".to_string() } else { let page_panel_enabled = self .board_picker_layout @@ -206,6 +221,10 @@ impl InputState { page_focus_page_index, page_scroll_row, page_scroll_target_page_index, + page_nav_mode, + page_search_query, + page_search_cursor, + page_jump_buffer, } = &mut self.board_picker_state else { return; @@ -220,6 +239,10 @@ impl InputState { *page_focus_page_index = None; *page_scroll_row = 0; *page_scroll_target_page_index = selected_active_page; + *page_nav_mode = BoardPickerPageNavMode::Normal; + page_search_query.clear(); + *page_search_cursor = None; + page_jump_buffer.clear(); if let Some(row) = selected_row_full { *selected = row; } diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index b3c6afe4..7b225bb4 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -1,5 +1,5 @@ use super::super::super::base::InputState; -use super::super::{BoardPickerFocus, BoardPickerMode, BoardPickerState}; +use super::super::{BoardPickerFocus, BoardPickerMode, BoardPickerPageNavMode, BoardPickerState}; impl InputState { pub(crate) fn is_board_picker_open(&self) -> bool { @@ -39,6 +39,10 @@ impl InputState { page_focus_page_index: None, page_scroll_row: 0, page_scroll_target_page_index: Some(active_page), + page_nav_mode: BoardPickerPageNavMode::Normal, + page_search_query: String::new(), + page_search_cursor: None, + page_jump_buffer: String::new(), }; let selected_row = self.board_picker_row_for_board(active_index); if let (Some(selected), BoardPickerState::Open { selected: row, .. }) = @@ -72,6 +76,10 @@ impl InputState { page_focus_page_index: None, page_scroll_row: 0, page_scroll_target_page_index: Some(active_page), + page_nav_mode: BoardPickerPageNavMode::Normal, + page_search_query: String::new(), + page_search_cursor: None, + page_jump_buffer: String::new(), }; let selected_row = self.board_picker_row_for_board(active_index); if let (Some(selected), BoardPickerState::Open { selected: row, .. }) = @@ -163,6 +171,10 @@ impl InputState { focus, page_focus_page_index, page_scroll_target_page_index, + page_nav_mode, + page_search_query, + page_search_cursor, + page_jump_buffer, .. } = &mut self.board_picker_state else { @@ -178,6 +190,10 @@ impl InputState { } BoardPickerFocus::BoardList => { *page_focus_page_index = None; + *page_nav_mode = BoardPickerPageNavMode::Normal; + page_search_query.clear(); + *page_search_cursor = None; + page_jump_buffer.clear(); } } self.needs_redraw = true; @@ -237,12 +253,20 @@ impl InputState { page_focus_page_index, page_scroll_row, page_scroll_target_page_index, + page_nav_mode, + page_search_query, + page_search_cursor, + page_jump_buffer, .. } = &mut self.board_picker_state { *selected = next; *focus = BoardPickerFocus::BoardList; *page_focus_page_index = None; + *page_nav_mode = BoardPickerPageNavMode::Normal; + page_search_query.clear(); + *page_search_cursor = None; + page_jump_buffer.clear(); if previous_board != Some(next_board) { *page_scroll_row = 0; *page_scroll_target_page_index = Some(next_active_page); @@ -321,38 +345,62 @@ impl InputState { let cols = layout.page_cols.max(1); let visible_slots = layout.page_visible_slots.max(1); let page_count = layout.page_count; + let Some((scroll_row, _, _)) = self.board_picker_page_panel_state_parts() else { + return false; + }; + let current = scroll_row.min(max); + let next = if delta_rows.is_negative() { + current.saturating_sub(delta_rows.unsigned_abs()) + } else { + current.saturating_add(delta_rows as usize).min(max) + }; + if scroll_row == next { + return false; + } + let first_visible = next.saturating_mul(cols).min(page_count); + let last_visible = first_visible + .saturating_add(visible_slots) + .min(page_count) + .saturating_sub(1); + let visible_search_match = (self.board_picker_page_nav_mode() + == BoardPickerPageNavMode::Search) + .then(|| { + self.board_picker_page_search_visible_match( + first_visible, + last_visible, + delta_rows.is_negative(), + ) + }) + .flatten(); if let BoardPickerState::Open { page_scroll_row, page_focus_page_index, page_scroll_target_page_index, + page_nav_mode, + page_search_cursor, .. } = &mut self.board_picker_state { - let current = (*page_scroll_row).min(max); - let next = if delta_rows.is_negative() { - current.saturating_sub(delta_rows.unsigned_abs()) - } else { - current.saturating_add(delta_rows as usize).min(max) - }; *page_scroll_target_page_index = None; - if *page_scroll_row != next { - *page_scroll_row = next; - if let Some(focus_page) = page_focus_page_index { - let first_visible = next.saturating_mul(cols).min(page_count); - let last_visible = first_visible - .saturating_add(visible_slots) - .min(page_count) - .saturating_sub(1); - if *focus_page < first_visible { - *page_focus_page_index = Some(first_visible.min(last_visible)); - } else if *focus_page > last_visible { - *page_focus_page_index = Some(last_visible); - } + *page_scroll_row = next; + if *page_nav_mode == BoardPickerPageNavMode::Search { + if let Some((cursor, page_index)) = visible_search_match { + *page_search_cursor = Some(cursor); + *page_focus_page_index = Some(page_index); + } else { + *page_search_cursor = None; + *page_focus_page_index = None; + } + } else if let Some(focus_page) = page_focus_page_index { + if *focus_page < first_visible { + *page_focus_page_index = Some(first_visible.min(last_visible)); + } else if *focus_page > last_visible { + *page_focus_page_index = Some(last_visible); } - self.needs_redraw = true; - self.dirty_tracker.mark_full(); - return true; } + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + return true; } false } diff --git a/src/input/state/core/board_picker/state/nav.rs b/src/input/state/core/board_picker/state/nav.rs new file mode 100644 index 00000000..867cd06f --- /dev/null +++ b/src/input/state/core/board_picker/state/nav.rs @@ -0,0 +1,438 @@ +use crate::input::events::Key; + +use super::super::super::base::{InputState, UiToastKind}; +use super::super::{ + BOARD_PICKER_PAGE_JUMP_MAX_LEN, BOARD_PICKER_PAGE_SEARCH_MAX_LEN, BoardPickerPageNavMode, + BoardPickerState, +}; + +impl InputState { + pub(crate) fn board_picker_page_nav_mode(&self) -> BoardPickerPageNavMode { + match &self.board_picker_state { + BoardPickerState::Open { page_nav_mode, .. } => *page_nav_mode, + BoardPickerState::Hidden => BoardPickerPageNavMode::Normal, + } + } + + pub(crate) fn board_picker_page_jump_buffer(&self) -> Option<&str> { + match &self.board_picker_state { + BoardPickerState::Open { + page_nav_mode, + page_jump_buffer, + .. + } if *page_nav_mode == BoardPickerPageNavMode::Jump => Some(page_jump_buffer.as_str()), + _ => None, + } + } + + pub(crate) fn board_picker_page_search_query(&self) -> Option<&str> { + match &self.board_picker_state { + BoardPickerState::Open { + page_nav_mode, + page_search_query, + .. + } if *page_nav_mode == BoardPickerPageNavMode::Search => { + Some(page_search_query.as_str()) + } + _ => None, + } + } + + pub(crate) fn board_picker_page_search_cursor(&self) -> Option { + match &self.board_picker_state { + BoardPickerState::Open { + page_nav_mode, + page_search_cursor, + .. + } if *page_nav_mode == BoardPickerPageNavMode::Search => *page_search_cursor, + _ => None, + } + } + + pub(crate) fn board_picker_begin_page_jump(&mut self) { + self.board_picker_clear_search(); + if let BoardPickerState::Open { + page_nav_mode, + page_search_query, + page_search_cursor, + page_jump_buffer, + .. + } = &mut self.board_picker_state + { + *page_nav_mode = BoardPickerPageNavMode::Jump; + page_search_query.clear(); + *page_search_cursor = None; + page_jump_buffer.clear(); + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + } + } + + pub(crate) fn board_picker_begin_page_search(&mut self) { + self.board_picker_clear_search(); + if let BoardPickerState::Open { + page_nav_mode, + page_search_query, + page_search_cursor, + page_jump_buffer, + .. + } = &mut self.board_picker_state + { + *page_nav_mode = BoardPickerPageNavMode::Search; + page_search_query.clear(); + *page_search_cursor = None; + page_jump_buffer.clear(); + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + } + } + + pub(crate) fn board_picker_clear_page_nav(&mut self) -> bool { + let BoardPickerState::Open { + page_nav_mode, + page_search_query, + page_search_cursor, + page_jump_buffer, + .. + } = &mut self.board_picker_state + else { + return false; + }; + let changed = *page_nav_mode != BoardPickerPageNavMode::Normal + || !page_search_query.is_empty() + || page_search_cursor.is_some() + || !page_jump_buffer.is_empty(); + if changed { + *page_nav_mode = BoardPickerPageNavMode::Normal; + page_search_query.clear(); + *page_search_cursor = None; + page_jump_buffer.clear(); + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + } + changed + } + + pub(crate) fn handle_board_picker_page_nav_key(&mut self, key: Key) -> Option { + match self.board_picker_page_nav_mode() { + BoardPickerPageNavMode::Normal => None, + BoardPickerPageNavMode::Jump => Some(self.handle_board_picker_page_jump_key(key)), + BoardPickerPageNavMode::Search => Some(self.handle_board_picker_page_search_key(key)), + } + } + + fn handle_board_picker_page_jump_key(&mut self, key: Key) -> bool { + match key { + Key::Escape => { + self.board_picker_clear_page_nav(); + true + } + Key::Return => { + self.board_picker_commit_page_jump(); + true + } + Key::Backspace | Key::Delete => { + if let BoardPickerState::Open { + page_jump_buffer, .. + } = &mut self.board_picker_state + { + page_jump_buffer.pop(); + self.needs_redraw = true; + } + true + } + Key::Char(ch) if ch.is_ascii_digit() => { + if let BoardPickerState::Open { + page_jump_buffer, .. + } = &mut self.board_picker_state + && page_jump_buffer.len() < BOARD_PICKER_PAGE_JUMP_MAX_LEN + { + page_jump_buffer.push(ch); + self.needs_redraw = true; + } + true + } + _ => true, + } + } + + fn board_picker_commit_page_jump(&mut self) { + let buffer = match self.board_picker_page_jump_buffer() { + Some(buffer) => buffer.trim().to_string(), + None => return, + }; + if buffer.is_empty() { + return; + } + let Ok(page_number) = buffer.parse::() else { + return; + }; + let page_count = self.board_picker_selected_board_page_count(); + if page_number == 0 || page_number > page_count { + self.set_ui_toast(UiToastKind::Warning, "Page number out of range."); + self.needs_redraw = true; + return; + } + let page_index = page_number - 1; + self.board_picker_clear_page_nav(); + self.board_picker_set_page_focus_page_index(page_index); + } + + fn handle_board_picker_page_search_key(&mut self, key: Key) -> bool { + match key { + Key::Escape => { + self.board_picker_clear_page_nav(); + true + } + Key::Return => { + if let Some(page_index) = self.board_picker_page_search_active_match() { + self.board_picker_activate_page(page_index); + } + true + } + Key::Backspace | Key::Delete => { + if let BoardPickerState::Open { + page_search_query, .. + } = &mut self.board_picker_state + { + page_search_query.pop(); + } + self.board_picker_reconcile_page_search_after_query_change(); + true + } + Key::F3 => { + self.board_picker_cycle_page_search_match(self.modifiers.shift); + true + } + Key::Space if !self.modifiers.ctrl && !self.modifiers.alt => { + self.board_picker_append_page_search_char(' '); + true + } + Key::Char(ch) if !self.modifiers.ctrl && !self.modifiers.alt && !ch.is_control() => { + self.board_picker_append_page_search_char(ch); + true + } + _ => true, + } + } + + fn board_picker_append_page_search_char(&mut self, ch: char) { + if let BoardPickerState::Open { + page_search_query, .. + } = &mut self.board_picker_state + { + if page_search_query.len() >= BOARD_PICKER_PAGE_SEARCH_MAX_LEN { + return; + } + page_search_query.push(ch); + } + self.board_picker_reconcile_page_search_after_query_change(); + } + + fn board_picker_reconcile_page_search_after_query_change(&mut self) { + let matches = self.board_picker_page_search_match_indexes(); + if let BoardPickerState::Open { + page_search_cursor, .. + } = &mut self.board_picker_state + { + *page_search_cursor = if matches.is_empty() { None } else { Some(0) }; + } + if let Some(page_index) = matches.first().copied() { + self.board_picker_set_page_focus_page_index(page_index); + } else { + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + } + } + + pub(crate) fn board_picker_reconcile_page_nav_after_page_change(&mut self) { + if self.board_picker_page_nav_mode() != BoardPickerPageNavMode::Search { + return; + } + let matches = self.board_picker_page_search_match_indexes(); + let page_count = self.board_picker_selected_board_page_count(); + let current_focus = self.board_picker_page_focus_page_index(); + let mut focus_page = None; + + if let BoardPickerState::Open { + page_search_cursor, .. + } = &mut self.board_picker_state + { + if matches.is_empty() { + *page_search_cursor = None; + if page_count > 0 + && let Some(current_focus) = current_focus + && current_focus >= page_count + { + focus_page = Some(page_count.saturating_sub(1)); + } + } else { + let next_cursor = page_search_cursor + .unwrap_or(0) + .min(matches.len().saturating_sub(1)); + *page_search_cursor = Some(next_cursor); + focus_page = matches.get(next_cursor).copied(); + } + } + + if let Some(page_index) = focus_page { + self.board_picker_set_page_focus_page_index(page_index); + } else { + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + } + } + + fn board_picker_cycle_page_search_match(&mut self, reverse: bool) { + let matches = self.board_picker_page_search_match_indexes(); + if matches.is_empty() { + if let BoardPickerState::Open { + page_search_cursor, .. + } = &mut self.board_picker_state + { + *page_search_cursor = None; + } + self.needs_redraw = true; + return; + } + let current = self + .board_picker_page_search_cursor() + .map(|cursor| cursor.min(matches.len().saturating_sub(1))); + let next = match (current, reverse) { + (Some(current), true) => current + .checked_sub(1) + .unwrap_or_else(|| matches.len().saturating_sub(1)), + (Some(current), false) => (current + 1) % matches.len(), + (None, true) => matches.len().saturating_sub(1), + (None, false) => 0, + }; + if let BoardPickerState::Open { + page_search_cursor, .. + } = &mut self.board_picker_state + { + *page_search_cursor = Some(next); + } + if let Some(page_index) = matches.get(next).copied() { + self.board_picker_set_page_focus_page_index(page_index); + } + } + + pub(crate) fn board_picker_page_search_active_match(&self) -> Option { + let cursor = self.board_picker_page_search_cursor()?; + let matches = self.board_picker_page_search_match_indexes(); + matches + .get(cursor.min(matches.len().saturating_sub(1))) + .copied() + } + + pub(crate) fn board_picker_page_search_visible_match( + &self, + first_visible: usize, + last_visible: usize, + prefer_last: bool, + ) -> Option<(usize, usize)> { + let matches = self.board_picker_page_search_match_indexes(); + if matches.is_empty() { + return None; + } + let current = self + .board_picker_page_search_cursor() + .unwrap_or(0) + .min(matches.len().saturating_sub(1)); + if let Some(page_index) = matches.get(current).copied() + && page_index >= first_visible + && page_index <= last_visible + { + return Some((current, page_index)); + } + let mut visible_matches = matches + .iter() + .copied() + .enumerate() + .filter(|(_, page_index)| *page_index >= first_visible && *page_index <= last_visible); + if prefer_last { + visible_matches.next_back() + } else { + visible_matches.next() + } + } + + pub(crate) fn board_picker_page_search_match_count(&self) -> usize { + self.board_picker_page_search_match_indexes().len() + } + + pub(crate) fn board_picker_page_search_match_indexes(&self) -> Vec { + let Some(query) = self.board_picker_page_search_query() else { + return Vec::new(); + }; + let Some(board_index) = self.board_picker_page_panel_board_index() else { + return Vec::new(); + }; + let Some(board) = self.boards.board_states().get(board_index) else { + return Vec::new(); + }; + let page_count = board.pages.page_count(); + let normalized = query.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return Vec::new(); + } + if let Some(page_number) = exact_page_number_query(&normalized) { + return page_number + .checked_sub(1) + .filter(|index| *index < page_count) + .into_iter() + .collect(); + } + board + .pages + .pages() + .iter() + .enumerate() + .filter_map(|(index, page)| { + page.page_name() + .is_some_and(|name| name.to_ascii_lowercase().contains(&normalized)) + .then_some(index) + }) + .collect() + } + + pub(crate) fn board_picker_page_matches_current_search(&self, page_index: usize) -> bool { + let Some(query) = self.board_picker_page_search_query() else { + return false; + }; + let normalized = query.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return false; + } + if let Some(page_number) = exact_page_number_query(&normalized) { + return page_number.checked_sub(1) == Some(page_index); + } + let Some(board_index) = self.board_picker_page_panel_board_index() else { + return false; + }; + self.boards + .board_states() + .get(board_index) + .and_then(|board| board.pages.pages().get(page_index)) + .and_then(|page| page.page_name()) + .is_some_and(|name| name.to_ascii_lowercase().contains(&normalized)) + } + + fn board_picker_selected_board_page_count(&self) -> usize { + self.board_picker_page_panel_board_index() + .and_then(|board_index| self.boards.board_states().get(board_index)) + .map_or(0, |board| board.pages.page_count()) + } +} + +fn exact_page_number_query(normalized: &str) -> Option { + let digits = if normalized.chars().all(|ch| ch.is_ascii_digit()) { + normalized + } else { + normalized.strip_prefix("page ")?.trim() + }; + if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + digits.parse::().ok() +} diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index c5fe2fc8..6a1ea4eb 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -27,6 +27,10 @@ impl InputState { } } + fn board_picker_page_context_change_affects_panel(&self, board_index: usize) -> bool { + self.board_picker_page_panel_board_index() == Some(board_index) + } + pub fn execute_menu_command(&mut self, command: MenuCommand) { match command { MenuCommand::Copy => { @@ -236,13 +240,25 @@ impl InputState { } MenuCommand::PageDuplicateFromContext => { if let Some(target) = self.context_menu_page_target { - let _ = self.duplicate_page_in_board(target.board_index, target.page_index); + let affects_panel = + self.board_picker_page_context_change_affects_panel(target.board_index); + if self.duplicate_page_in_board(target.board_index, target.page_index) + && affects_panel + { + self.board_picker_reconcile_page_nav_after_page_change(); + } } self.close_context_menu(); } MenuCommand::PageDeleteFromContext => { if let Some(target) = self.context_menu_page_target { - let _ = self.delete_page_in_board(target.board_index, target.page_index); + let affects_panel = + self.board_picker_page_context_change_affects_panel(target.board_index); + let outcome = self.delete_page_in_board(target.board_index, target.page_index); + if !matches!(outcome, crate::draw::PageDeleteOutcome::Pending) && affects_panel + { + self.board_picker_reconcile_page_nav_after_page_change(); + } } self.close_context_menu(); } @@ -250,19 +266,24 @@ impl InputState { if let Some(target) = self.context_menu_page_target { let source_board = target.board_index; let page_index = target.page_index; + let affects_panel = + self.board_picker_page_context_change_affects_panel(source_board); if let Some(target_index) = self .boards .board_states() .iter() .position(|board| board.spec.id == id) { - let _ = self.move_page_between_boards_with_activation( + let moved = self.move_page_between_boards_with_activation( source_board, page_index, target_index, false, true, ); + if moved && affects_panel { + self.board_picker_reconcile_page_nav_after_page_change(); + } } } self.close_context_menu(); diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 1eec4087..9d6aff54 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -1,10 +1,10 @@ use super::create_test_input_state; -use crate::draw::Frame; +use crate::draw::{Frame, PageDeleteOutcome}; use crate::input::BoardBackground; use crate::input::events::Key; use crate::input::state::core::board_picker::{ BoardPickerDrag, BoardPickerEditMode, BoardPickerFocus, BoardPickerMode, BoardPickerPageDrag, - BoardPickerPageEdit, BoardPickerState, + BoardPickerPageEdit, BoardPickerPageNavMode, BoardPickerState, }; use crate::input::state::{ PAGE_DELETE_ICON_MARGIN, PAGE_DELETE_ICON_SIZE, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, @@ -178,6 +178,21 @@ fn set_board_page_count( pages.extend((0..page_count.max(1)).map(|_| Frame::new())); } +fn set_board_page_names( + state: &mut crate::input::state::InputState, + board_index: usize, + names: &[&str], +) { + set_board_page_count(state, board_index, names.len()); + for (index, name) in names.iter().enumerate() { + assert!( + state.boards.board_states_mut()[board_index] + .pages + .set_page_name(index, Some((*name).to_string())) + ); + } +} + fn page_thumb_origin( layout: crate::input::state::BoardPickerLayout, page_index: usize, @@ -630,6 +645,99 @@ fn board_picker_wheel_scroll_up_clamps_focus_to_last_visible_page() { assert_page_visible(after, expected_focus); } +#[test] +fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input, board_index, 80); + update_picker_layout(&mut input, 1280, 720); + + let cols = input + .board_picker_layout() + .expect("layout") + .page_cols + .max(1); + let second_match = cols; + let mut names = vec![""; 80]; + names[0] = "Match first"; + names[second_match] = "Match visible after scroll"; + set_board_page_names(&mut input, board_index, &names); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "match".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!(input.board_picker_page_search_cursor(), Some(0)); + assert_eq!(input.board_picker_page_search_active_match(), Some(0)); + assert_eq!(input.board_picker_page_focus_page_index(), Some(0)); + + assert!(input.board_picker_scroll_page_panel_rows(1)); + update_picker_layout(&mut input, 1280, 720); + + assert_eq!(input.board_picker_page_search_cursor(), Some(1)); + assert_eq!( + input.board_picker_page_search_active_match(), + Some(second_match) + ); + assert_eq!( + input.board_picker_page_focus_page_index(), + Some(second_match) + ); + assert_page_visible(*input.board_picker_layout().expect("layout"), second_match); + + assert!(input.handle_board_picker_key(Key::Return)); + assert!(!input.is_board_picker_open()); + assert_eq!( + input.boards.board_states()[board_index] + .pages + .active_index(), + second_match + ); +} + +#[test] +fn board_picker_page_search_wheel_scroll_without_visible_match_clears_cursor() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + let mut names = vec![""; 80]; + names[0] = "Match first"; + set_board_page_names(&mut input, board_index, &names); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "match".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!(input.board_picker_page_search_cursor(), Some(0)); + assert_eq!(input.board_picker_page_search_active_match(), Some(0)); + assert_eq!(input.board_picker_page_focus_page_index(), Some(0)); + + assert!(input.board_picker_scroll_page_panel_rows(1)); + update_picker_layout(&mut input, 1280, 720); + + assert_eq!(input.board_picker_page_search_cursor(), None); + assert_eq!(input.board_picker_page_search_active_match(), None); + assert_eq!(input.board_picker_page_focus_page_index(), None); + assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.is_board_picker_open()); + + assert!(input.handle_board_picker_key(Key::F3)); + update_picker_layout(&mut input, 1280, 720); + assert_eq!(input.board_picker_page_search_cursor(), Some(0)); + assert_eq!(input.board_picker_page_search_active_match(), Some(0)); + assert_eq!(input.board_picker_page_focus_page_index(), Some(0)); + assert_page_visible(*input.board_picker_layout().expect("layout"), 0); +} + #[test] fn board_picker_column_change_keeps_focused_absolute_page_visible() { let mut input = create_test_input_state(); @@ -652,6 +760,328 @@ fn board_picker_column_change_keeps_focused_absolute_page_visible() { assert_page_visible(narrow, 9); } +#[test] +fn board_picker_page_jump_focuses_absolute_page_and_scrolls_visible() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input, board_index, 18); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.modifiers.ctrl = true; + assert!(input.handle_board_picker_key(Key::Char('g'))); + input.modifiers.ctrl = false; + for ch in "12".chars() { + assert!(input.handle_board_picker_key(Key::Char(ch))); + } + assert_eq!(input.board_picker_page_jump_buffer(), Some("12")); + assert!(input.handle_board_picker_key(Key::Return)); + update_picker_layout(&mut input, 1280, 720); + + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Normal + ); + assert_eq!(input.board_picker_page_focus_page_index(), Some(11)); + assert_page_visible(*input.board_picker_layout().expect("layout"), 11); +} + +#[test] +fn board_picker_page_jump_edges_keep_picker_open() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input, board_index, 4); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + input.modifiers.ctrl = true; + input.handle_board_picker_key(Key::Char('g')); + input.modifiers.ctrl = false; + + assert!(input.handle_board_picker_key(Key::Char('x'))); + assert_eq!(input.board_picker_page_jump_buffer(), Some("")); + assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.is_board_picker_open()); + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Jump + ); + + for ch in "99".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.is_board_picker_open()); + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Jump + ); + assert_eq!( + input.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Page number out of range.") + ); + + assert!(input.handle_board_picker_key(Key::Escape)); + assert!(input.is_board_picker_open()); + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Normal + ); +} + +#[test] +fn board_picker_page_search_slash_starts_without_inserting_slash() { + let mut input = create_test_input_state(); + input.open_board_picker(); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + assert!(input.handle_board_picker_key(Key::Char('/'))); + + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Search + ); + assert_eq!(input.board_picker_page_search_query(), Some("")); +} + +#[test] +fn board_picker_selecting_current_board_row_clears_page_nav_mode() { + let mut input = create_test_input_state(); + input.open_board_picker(); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "target".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Search + ); + + let selected = input.board_picker_selected_index().expect("selected row"); + input.board_picker_set_selected(selected); + + assert_eq!(input.board_picker_focus(), BoardPickerFocus::BoardList); + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Normal + ); + assert_eq!(input.board_picker_page_search_query(), None); +} + +#[test] +fn board_picker_page_search_finds_named_page_beyond_visible_window() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + let mut names = vec![""; 14]; + names[12] = "Capstone"; + set_board_page_names(&mut input, board_index, &names); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + assert!(input.handle_board_picker_key(Key::Char('/'))); + for ch in "cap".chars() { + assert!(input.handle_board_picker_key(Key::Char(ch))); + } + update_picker_layout(&mut input, 1280, 720); + + assert_eq!(input.board_picker_page_search_active_match(), Some(12)); + assert_eq!(input.board_picker_page_focus_page_index(), Some(12)); + assert_page_visible(*input.board_picker_layout().expect("layout"), 12); +} + +#[test] +fn board_picker_page_search_numeric_is_exact_page_number() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + let mut names = vec![""; 12]; + names[0] = "Topic 12"; + set_board_page_names(&mut input, board_index, &names); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + assert!(input.handle_board_picker_key(Key::Char('/'))); + for ch in "12".chars() { + assert!(input.handle_board_picker_key(Key::Char(ch))); + } + + assert_eq!(input.board_picker_page_search_active_match(), Some(11)); + assert!(input.board_picker_page_matches_current_search(11)); + assert!(!input.board_picker_page_matches_current_search(0)); +} + +#[test] +fn board_picker_page_search_no_match_enter_is_noop() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input, board_index, 4); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + assert!(input.handle_board_picker_key(Key::Char('/'))); + for ch in "missing".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!(input.board_picker_page_search_match_count(), 0); + assert!(input.handle_board_picker_key(Key::Return)); + + assert!(input.is_board_picker_open()); + assert_eq!( + input.boards.board_states()[board_index] + .pages + .active_index(), + 0 + ); + assert!(input.handle_board_picker_key(Key::Escape)); + assert!(input.is_board_picker_open()); + assert_eq!( + input.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Normal + ); +} + +#[test] +fn board_picker_page_search_f3_cycles_matches() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + let mut names = vec![""; 12]; + names[2] = "Match early"; + names[11] = "Match late"; + set_board_page_names(&mut input, board_index, &names); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "match".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!(input.board_picker_page_search_active_match(), Some(2)); + + assert!(input.handle_board_picker_key(Key::F3)); + assert_eq!(input.board_picker_page_search_active_match(), Some(11)); + input.modifiers.shift = true; + assert!(input.handle_board_picker_key(Key::F3)); + input.modifiers.shift = false; + + assert_eq!(input.board_picker_page_search_active_match(), Some(2)); +} + +#[test] +fn board_picker_page_search_enter_opens_absolute_page_beyond_nine() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + let mut names = vec![""; 12]; + names[11] = "Target"; + set_board_page_names(&mut input, board_index, &names); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "target".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!(input.board_picker_page_search_active_match(), Some(11)); + assert!(input.handle_board_picker_key(Key::Return)); + + assert!(!input.is_board_picker_open()); + assert_eq!( + input.boards.board_states()[board_index] + .pages + .active_index(), + 11 + ); +} + +#[test] +fn board_picker_page_search_rename_updates_derived_match() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input, board_index, 12); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "target".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + assert_eq!(input.board_picker_page_search_match_count(), 0); + + input.board_picker_page_edit = Some(BoardPickerPageEdit { + board_index, + page_index: 9, + buffer: "Target".to_string(), + }); + assert!(input.board_picker_commit_page_edit()); + + assert_eq!(input.board_picker_page_search_active_match(), Some(9)); + assert_eq!(input.board_picker_page_focus_page_index(), Some(9)); +} + +#[test] +fn board_picker_page_search_pending_delete_preserves_confirmed_delete_clamps() { + let mut input = create_test_input_state(); + input.open_board_picker(); + let board_index = input + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_names( + &mut input, + board_index, + &["Match one", "Match two", "Other"], + ); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input.handle_board_picker_key(Key::Char('/')); + for ch in "match".chars() { + input.handle_board_picker_key(Key::Char(ch)); + } + input.handle_board_picker_key(Key::F3); + assert_eq!(input.board_picker_page_search_cursor(), Some(1)); + assert_eq!(input.board_picker_page_search_active_match(), Some(1)); + + assert_eq!( + input.board_picker_delete_page(1), + PageDeleteOutcome::Pending + ); + assert_eq!(input.board_picker_page_search_cursor(), Some(1)); + assert_eq!(input.board_picker_page_search_active_match(), Some(1)); + + assert_eq!( + input.board_picker_delete_page(1), + PageDeleteOutcome::Removed + ); + assert_eq!(input.board_picker_page_search_cursor(), Some(0)); + assert_eq!(input.board_picker_page_search_active_match(), Some(0)); + assert_eq!(input.board_picker_page_focus_page_index(), Some(0)); +} + #[test] fn board_picker_row_action_hitboxes_match_rendered_positions() { let mut input = create_test_input_state(); @@ -772,7 +1202,22 @@ fn board_picker_footer_text_changes_for_quick_and_page_panel_modes() { input.board_picker_set_focus(BoardPickerFocus::PagePanel); assert_eq!( input.board_picker_footer_text(), - "Enter: open Ctrl+N: add F2: rename Del: delete Tab: back" + "Enter: open Ctrl+N: add Ctrl+G: page /: search F2: rename" + ); + + input.modifiers.ctrl = true; + input.handle_board_picker_key(Key::Char('g')); + input.modifiers.ctrl = false; + assert_eq!( + input.board_picker_footer_text(), + "Go to page: Enter: go Esc: cancel" + ); + + input.handle_board_picker_key(Key::Escape); + input.handle_board_picker_key(Key::Char('/')); + assert_eq!( + input.board_picker_footer_text(), + "Search pages: Enter: open F3: next Esc: clear" ); } diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index 6972b847..1a92a0c7 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -1,5 +1,6 @@ use super::*; use crate::draw::{BoardPages, Frame}; +use crate::input::state::core::board_picker::{BoardPickerFocus, BoardPickerPageNavMode}; use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; fn board_index(state: &InputState, id: &str) -> usize { @@ -598,6 +599,96 @@ fn page_duplicate_from_context_duplicates_target_page_and_closes_menu() { assert!(!state.is_context_menu_open()); } +#[test] +fn page_delete_from_context_reconciles_board_picker_page_search_cursor() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_named_pages( + &mut state, + blackboard, + &[Some("Match one"), Some("Match two"), Some("Other")], + 0, + ); + state.open_board_picker(); + state.board_picker_set_focus(BoardPickerFocus::PagePanel); + + state.handle_board_picker_key(Key::Char('/')); + for ch in "match".chars() { + state.handle_board_picker_key(Key::Char(ch)); + } + state.handle_board_picker_key(Key::F3); + assert_eq!( + state.board_picker_page_nav_mode(), + BoardPickerPageNavMode::Search + ); + assert_eq!(state.board_picker_page_search_cursor(), Some(1)); + assert_eq!(state.board_picker_page_search_active_match(), Some(1)); + + state.open_page_context_menu((5, 5), blackboard, 0); + state.execute_menu_command(MenuCommand::PageDeleteFromContext); + assert_eq!(state.board_picker_page_search_cursor(), Some(1)); + assert_eq!( + state.boards.board_states()[blackboard].pages.page_count(), + 3 + ); + + state.open_page_context_menu((5, 5), blackboard, 0); + state.execute_menu_command(MenuCommand::PageDeleteFromContext); + + assert_eq!( + state.boards.board_states()[blackboard].pages.page_count(), + 2 + ); + assert_eq!(state.board_picker_page_search_match_count(), 1); + assert_eq!(state.board_picker_page_search_cursor(), Some(0)); + assert_eq!(state.board_picker_page_search_active_match(), Some(0)); + + assert!(state.handle_board_picker_key(Key::Return)); + assert!(!state.is_board_picker_open()); + assert_eq!( + state.boards.board_states()[blackboard].pages.active_index(), + 0 + ); +} + +#[test] +fn page_search_active_match_clamps_stale_cursor_after_external_page_delete() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + state.switch_board(BOARD_ID_BLACKBOARD); + set_named_pages( + &mut state, + blackboard, + &[Some("Match one"), Some("Match two"), Some("Other")], + 0, + ); + state.open_board_picker(); + state.board_picker_set_focus(BoardPickerFocus::PagePanel); + + state.handle_board_picker_key(Key::Char('/')); + for ch in "match".chars() { + state.handle_board_picker_key(Key::Char(ch)); + } + state.handle_board_picker_key(Key::F3); + assert_eq!(state.board_picker_page_search_cursor(), Some(1)); + assert_eq!(state.board_picker_page_search_active_match(), Some(1)); + + state.delete_page_in_board(blackboard, 0); + state.delete_page_in_board(blackboard, 0); + + assert_eq!(state.board_picker_page_search_match_count(), 1); + assert_eq!(state.board_picker_page_search_cursor(), Some(1)); + assert_eq!(state.board_picker_page_search_active_match(), Some(0)); + + assert!(state.handle_board_picker_key(Key::Return)); + assert!(!state.is_board_picker_open()); + assert_eq!( + state.boards.board_states()[blackboard].pages.active_index(), + 0 + ); +} + #[test] fn page_move_to_board_command_moves_page_switches_board_and_closes_menu() { let mut state = create_test_input_state(); diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index 9e1f8289..27590eca 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -127,6 +127,7 @@ pub(super) fn render_page_panel( page_name: page.page_name(), is_active, is_drop_target, + is_search_match: input_state.board_picker_page_matches_current_search(index), is_hovered: hover_index == Some(index), is_keyboard_focused: page_focus_page_index == Some(index), delete_hovered: hover_delete == Some(index), diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 56162205..29968fe6 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -27,6 +27,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageT page_name, is_active, is_drop_target, + is_search_match, is_hovered, is_keyboard_focused, delete_hovered, @@ -71,6 +72,20 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageT let _ = ctx.stroke(); } + if is_search_match { + ctx.set_source_rgba(1.0, 0.84, 0.28, 0.82); + ctx.set_line_width(1.25); + draw_rounded_rect( + ctx, + x - 2.0, + y - 2.0, + width + 4.0, + height + 4.0, + radius + 2.0, + ); + let _ = ctx.stroke(); + } + if is_keyboard_focused { constants::set_color(ctx, BORDER_FOCUS); ctx.set_line_width(1.5); diff --git a/src/ui/board_picker/page_panel/thumbnail/types.rs b/src/ui/board_picker/page_panel/thumbnail/types.rs index 0c95e56d..1dcd08b4 100644 --- a/src/ui/board_picker/page_panel/thumbnail/types.rs +++ b/src/ui/board_picker/page_panel/thumbnail/types.rs @@ -16,6 +16,7 @@ pub(in crate::ui::board_picker::page_panel) struct PageThumbnailArgs<'a> { pub(in crate::ui::board_picker::page_panel) page_name: Option<&'a str>, pub(in crate::ui::board_picker::page_panel) is_active: bool, pub(in crate::ui::board_picker::page_panel) is_drop_target: bool, + pub(in crate::ui::board_picker::page_panel) is_search_match: bool, pub(in crate::ui::board_picker::page_panel) is_hovered: bool, pub(in crate::ui::board_picker::page_panel) is_keyboard_focused: bool, pub(in crate::ui::board_picker::page_panel) delete_hovered: bool,