From 0fb2de4608179b659f8298aecdbf2fb1899fa336 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 21 May 2026 09:39:32 +0200 Subject: [PATCH] Add scrollable board picker pages --- src/backend/wayland/handlers/pointer/axis.rs | 76 ++- src/input/state/actions/key_press/panels.rs | 101 +++- .../state/core/board_picker/layout/compute.rs | 26 + .../board_picker/layout/compute/page_panel.rs | 108 +++- .../state/core/board_picker/layout/helpers.rs | 82 ++- .../core/board_picker/layout/hit_test.rs | 66 ++- src/input/state/core/board_picker/mod.rs | 20 +- src/input/state/core/board_picker/search.rs | 6 +- .../state/core/board_picker/state/actions.rs | 29 +- .../state/core/board_picker/state/edit.rs | 17 +- .../core/board_picker/state/lifecycle.rs | 196 ++++++-- src/input/state/tests/board_picker.rs | 476 +++++++++++++++++- src/ui/board_picker/page_panel.rs | 134 +++-- 13 files changed, 1166 insertions(+), 171 deletions(-) diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 34b389ba..642d76e1 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -2,7 +2,7 @@ use log::debug; use smithay_client_toolkit::seat::pointer::{AxisScroll, PointerEvent}; use crate::input::Tool; -use crate::input::state::COMMAND_PALETTE_MAX_VISIBLE; +use crate::input::state::{COMMAND_PALETTE_MAX_VISIBLE, InputState}; use super::*; @@ -87,6 +87,13 @@ impl WaylandState { } return; } + if try_handle_board_picker_page_panel_axis( + &mut self.input_state, + event.position, + scroll_direction, + ) { + return; + } if on_toolbar || self.pointer_over_toolbar() { return; } @@ -164,3 +171,70 @@ impl WaylandState { } } } + +fn try_handle_board_picker_page_panel_axis( + input_state: &mut InputState, + position: (f64, f64), + scroll_direction: i32, +) -> bool { + if !input_state.is_board_picker_open() || scroll_direction == 0 { + return false; + } + let x = position.0.round() as i32; + let y = position.1.round() as i32; + if !input_state.board_picker_page_panel_content_at(x, y) { + return false; + } + let delta = if scroll_direction > 0 { 1 } else { -1 }; + let _ = input_state.board_picker_scroll_page_panel_rows(delta); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::Frame; + use crate::input::state::{BoardPickerFocus, test_support::make_test_input_state}; + + fn update_picker_layout(input_state: &mut InputState) { + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).expect("image surface"); + let ctx = cairo::Context::new(&surface).expect("cairo context"); + input_state.update_board_picker_layout(&ctx, 1280, 720); + } + + fn set_board_page_count(input_state: &mut InputState, board_index: usize, page_count: usize) { + let pages = input_state.boards.board_states_mut()[board_index] + .pages + .pages_mut(); + pages.clear(); + pages.extend((0..page_count.max(1)).map(|_| Frame::new())); + } + + #[test] + fn board_picker_page_panel_axis_consumes_before_thickness_changes() { + let mut input_state = make_test_input_state(); + input_state.open_board_picker(); + let board_index = input_state + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input_state, board_index, 80); + update_picker_layout(&mut input_state); + + let layout = *input_state.board_picker_layout().expect("layout"); + let position = (layout.page_viewport_x + 1.0, layout.page_viewport_y + 1.0); + let thickness = input_state.current_thickness; + input_state.board_picker_set_focus(BoardPickerFocus::PagePanel); + + assert!(try_handle_board_picker_page_panel_axis( + &mut input_state, + position, + 1, + )); + assert_eq!(input_state.current_thickness, thickness); + update_picker_layout(&mut input_state); + + let layout = *input_state.board_picker_layout().expect("layout"); + assert_eq!(layout.page_scroll_row, 1); + } +} diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 17cfbfed..7383e03b 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -1,3 +1,4 @@ +use crate::draw::PageDeleteOutcome; use crate::input::events::Key; use crate::input::state::BoardPickerFocus; use crate::input::state::InputState; @@ -80,6 +81,10 @@ impl InputState { } } else if self.board_picker_edit_state().is_some() { match key { + Key::F2 => { + self.board_picker_rename_selected(); + true + } Key::Escape => { self.board_picker_cancel_edit(); true @@ -216,8 +221,19 @@ impl InputState { fn handle_board_picker_page_panel_key(&mut self, key: Key) -> bool { let layout = self.board_picker_layout; let page_cols = layout.map(|l| l.page_cols.max(1)).unwrap_or(1); - let visible = layout.map(|l| l.page_visible_count).unwrap_or(0); - let current = self.board_picker_page_focus_index().unwrap_or(0); + let page_count = self + .board_picker_page_panel_board_index() + .and_then(|bi| self.boards.board_states().get(bi)) + .map(|b| b.pages.page_count()) + .unwrap_or(0); + let current = self + .board_picker_page_focus_page_index() + .unwrap_or_else(|| { + self.board_picker_page_panel_board_index() + .and_then(|bi| self.boards.board_states().get(bi)) + .map_or(0, |board| board.pages.active_index()) + }) + .min(page_count.saturating_sub(1)); match key { Key::Escape => { @@ -231,48 +247,81 @@ impl InputState { true } Key::Left => { - let col = current % page_cols; - if col == 0 { + if current == 0 { self.board_picker_set_focus(BoardPickerFocus::BoardList); - } else if visible > 0 { - self.board_picker_set_page_focus_index(current.saturating_sub(1)); + } else if page_count > 0 { + self.board_picker_set_page_focus_page_index(current.saturating_sub(1)); } true } Key::Right => { - if visible > 0 { - let next = current.saturating_add(1).min(visible.saturating_sub(1)); - self.board_picker_set_page_focus_index(next); + if page_count > 0 { + let next = current.saturating_add(1).min(page_count.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(next); } true } Key::Up => { - if visible > 0 { + if page_count > 0 { let next = current.saturating_sub(page_cols); - self.board_picker_set_page_focus_index(next); + self.board_picker_set_page_focus_page_index(next); } true } Key::Down => { - if visible > 0 { + if page_count > 0 { let next = current .saturating_add(page_cols) - .min(visible.saturating_sub(1)); - self.board_picker_set_page_focus_index(next); + .min(page_count.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(next); + } + true + } + Key::Home => { + if page_count > 0 { + self.board_picker_set_page_focus_page_index(0); + } + true + } + Key::End => { + if page_count > 0 { + self.board_picker_set_page_focus_page_index(page_count.saturating_sub(1)); + } + true + } + Key::PageUp => { + if page_count > 0 { + let step = layout + .map(|l| l.page_visible_slots.max(page_cols)) + .unwrap_or(page_cols); + self.board_picker_set_page_focus_page_index(current.saturating_sub(step)); + } + true + } + Key::PageDown => { + if page_count > 0 { + let step = layout + .map(|l| l.page_visible_slots.max(page_cols)) + .unwrap_or(page_cols); + let next = current + .saturating_add(step) + .min(page_count.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(next); } true } Key::Return | Key::Space => { - if visible > 0 { + if page_count > 0 { self.board_picker_activate_page(current); } true } Key::Delete => { - if visible > 0 { - self.board_picker_delete_page(current); - // Clamp focus using post-delete page count from actual board state, - // since the layout's page_visible_count is stale after deletion. + if page_count > 0 { + let outcome = self.board_picker_delete_page(current); + if matches!(outcome, PageDeleteOutcome::Pending) { + return true; + } let remaining = self .board_picker_page_panel_board_index() .and_then(|bi| self.boards.board_states().get(bi)) @@ -281,24 +330,24 @@ impl InputState { if remaining == 0 { self.board_picker_set_focus(BoardPickerFocus::BoardList); } else { - let max_visible = layout - .map(|l| l.page_cols * l.page_max_rows) - .unwrap_or(remaining); - let new_visible = remaining.min(max_visible); - let clamped = current.min(new_visible.saturating_sub(1)); - self.board_picker_set_page_focus_index(clamped); + let clamped = current.min(remaining.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(clamped); } } true } Key::F2 => { - if visible > 0 + if page_count > 0 && let Some(board_index) = self.board_picker_page_panel_board_index() { self.board_picker_start_page_rename(board_index, current); } true } + Key::Char('n') | Key::Char('N') if self.modifiers.ctrl => { + self.board_picker_add_page(); + true + } Key::Backspace => { self.board_picker_backspace_search(); true diff --git a/src/input/state/core/board_picker/layout/compute.rs b/src/input/state/core/board_picker/layout/compute.rs index fe27be48..a52a5eb6 100644 --- a/src/input/state/core/board_picker/layout/compute.rs +++ b/src/input/state/core/board_picker/layout/compute.rs @@ -96,9 +96,22 @@ struct BoardPickerPagePanelMetrics { enabled: bool, width: f64, height: f64, + viewport_x: f64, + viewport_y: f64, + viewport_width: f64, + viewport_height: f64, + add_button_x: f64, + add_button_y: f64, + add_button_width: f64, + add_button_height: f64, thumb_width: f64, cols: usize, rows: usize, + total_rows: usize, + scroll_row: usize, + max_scroll_row: usize, + first_visible_index: usize, + visible_slots: usize, count: usize, visible_count: usize, board_index: Option, @@ -246,12 +259,25 @@ impl InputState { page_panel_y: geometry.page_panel_y, page_panel_width: page_panel.width, page_panel_height: page_panel.height, + page_viewport_x: page_panel.viewport_x + geometry.page_panel_x, + page_viewport_y: page_panel.viewport_y + geometry.page_panel_y, + page_viewport_width: page_panel.viewport_width, + page_viewport_height: page_panel.viewport_height, + page_add_button_x: page_panel.add_button_x + geometry.page_panel_x, + page_add_button_y: page_panel.add_button_y + geometry.page_panel_y, + page_add_button_width: page_panel.add_button_width, + page_add_button_height: page_panel.add_button_height, page_thumb_width: page_panel.thumb_width, page_thumb_height: PAGE_THUMB_HEIGHT, page_thumb_gap: PAGE_THUMB_GAP, page_cols: page_panel.cols, page_rows: page_panel.rows, page_max_rows: PAGE_PANEL_MAX_ROWS, + page_total_rows: page_panel.total_rows, + page_scroll_row: page_panel.scroll_row, + page_max_scroll_row: page_panel.max_scroll_row, + page_first_visible_index: page_panel.first_visible_index, + page_visible_slots: page_panel.visible_slots, page_count: page_panel.count, page_visible_count: page_panel.visible_count, page_board_index: page_panel.board_index, diff --git a/src/input/state/core/board_picker/layout/compute/page_panel.rs b/src/input/state/core/board_picker/layout/compute/page_panel.rs index 29b402ba..ac5f8eeb 100644 --- a/src/input/state/core/board_picker/layout/compute/page_panel.rs +++ b/src/input/state/core/board_picker/layout/compute/page_panel.rs @@ -1,6 +1,7 @@ use super::super::super::super::base::InputState; use super::super::super::{ - PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, PAGE_PANEL_GAP, PAGE_PANEL_MAX_COLS, PAGE_PANEL_MAX_ROWS, + PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, PAGE_PANEL_ADD_BUTTON_GAP, PAGE_PANEL_ADD_BUTTON_HEIGHT, + PAGE_PANEL_GAP, PAGE_PANEL_HEADER_HEIGHT, PAGE_PANEL_MAX_COLS, PAGE_PANEL_MAX_ROWS, PAGE_PANEL_PADDING_X, PAGE_THUMB_GAP, PAGE_THUMB_HEIGHT, PAGE_THUMB_MAX_WIDTH, PAGE_THUMB_MIN_WIDTH, }; @@ -8,7 +9,7 @@ use super::BoardPickerPagePanelMetrics; impl InputState { pub(super) fn compute_board_picker_page_panel_metrics( - &self, + &mut self, screen_width: u32, screen_height: u32, footer_height: f64, @@ -19,9 +20,22 @@ impl InputState { enabled: false, width: 0.0, height: 0.0, + viewport_x: 0.0, + viewport_y: 0.0, + viewport_width: 0.0, + viewport_height: 0.0, + add_button_x: 0.0, + add_button_y: 0.0, + add_button_width: 0.0, + add_button_height: 0.0, thumb_width: 0.0, cols: 0, rows: 0, + total_rows: 0, + scroll_row: 0, + max_scroll_row: 0, + first_visible_index: 0, + visible_slots: 0, count: 0, visible_count: 0, board_index: None, @@ -32,6 +46,7 @@ impl InputState { && let Some(board) = self.boards.board_states().get(board_index) { metrics.count = board.pages.page_count(); + metrics.board_index = Some(board_index); let aspect = if screen_height == 0 { 1.0 } else { @@ -65,18 +80,95 @@ impl InputState { / metrics.cols as f64) .clamp(PAGE_THUMB_MIN_WIDTH, PAGE_THUMB_MAX_WIDTH); - let total_rows = metrics.count.max(1).div_ceil(metrics.cols); - metrics.rows = total_rows.max(1).clamp(1, PAGE_PANEL_MAX_ROWS); - metrics.visible_count = metrics.count.min(metrics.rows.saturating_mul(metrics.cols)); + metrics.total_rows = metrics.count.max(1).div_ceil(metrics.cols); + metrics.rows = metrics.total_rows.max(1).clamp(1, PAGE_PANEL_MAX_ROWS); + metrics.max_scroll_row = metrics.total_rows.saturating_sub(metrics.rows); + self.clamp_board_picker_page_panel_state(&mut metrics); + + metrics.viewport_x = PAGE_PANEL_PADDING_X; + metrics.viewport_y = PAGE_PANEL_PADDING_X + PAGE_PANEL_HEADER_HEIGHT; + metrics.viewport_width = metrics.width - PAGE_PANEL_PADDING_X * 2.0; + metrics.viewport_height = metrics.rows as f64 * page_row_height + + (metrics.rows.saturating_sub(1) as f64) * PAGE_THUMB_GAP; + metrics.add_button_x = PAGE_PANEL_PADDING_X; + metrics.add_button_y = + metrics.viewport_y + metrics.viewport_height + PAGE_PANEL_ADD_BUTTON_GAP; + metrics.add_button_width = metrics.viewport_width; + metrics.add_button_height = PAGE_PANEL_ADD_BUTTON_HEIGHT; metrics.height = PAGE_PANEL_PADDING_X * 2.0 - + metrics.rows as f64 * page_row_height - + (metrics.rows.saturating_sub(1) as f64) * PAGE_THUMB_GAP + + PAGE_PANEL_HEADER_HEIGHT + + metrics.viewport_height + + PAGE_PANEL_ADD_BUTTON_GAP + + PAGE_PANEL_ADD_BUTTON_HEIGHT + footer_height; panel_height = panel_height.max(metrics.height); metrics.enabled = true; - metrics.board_index = Some(board_index); } (metrics, panel_height) } + + fn clamp_board_picker_page_panel_state(&mut self, metrics: &mut BoardPickerPagePanelMetrics) { + let count = metrics.count; + let cols = metrics.cols.max(1); + let rows = metrics.rows.max(1); + let max_scroll_row = metrics.max_scroll_row; + let Some((scroll_row, focus_page, target_page)) = + self.board_picker_page_panel_state_parts() + else { + metrics.scroll_row = 0; + metrics.first_visible_index = 0; + metrics.visible_slots = rows.saturating_mul(cols); + metrics.visible_count = count.min(metrics.visible_slots); + return; + }; + + let clamped_focus = focus_page.map(|index| clamp_page_index(index, count)); + let scroll_target = target_page.map(|index| clamp_page_index(index, count)); + + let mut next_scroll_row = scroll_row.min(max_scroll_row); + if let Some(page_index) = scroll_target { + next_scroll_row = + scroll_row_for_page(page_index, cols, rows, next_scroll_row, max_scroll_row); + } else if let Some(page_index) = clamped_focus { + next_scroll_row = + scroll_row_for_page(page_index, cols, rows, next_scroll_row, max_scroll_row); + } + + self.set_board_picker_page_panel_state_parts(next_scroll_row, clamped_focus, None); + + metrics.scroll_row = next_scroll_row; + metrics.first_visible_index = next_scroll_row.saturating_mul(cols).min(count); + metrics.visible_slots = rows.saturating_mul(cols); + metrics.visible_count = count + .saturating_sub(metrics.first_visible_index) + .min(metrics.visible_slots); + } +} + +fn clamp_page_index(index: usize, page_count: usize) -> usize { + if page_count == 0 { + 0 + } else { + index.min(page_count.saturating_sub(1)) + } +} + +fn scroll_row_for_page( + page_index: usize, + cols: usize, + visible_rows: usize, + current_scroll_row: usize, + max_scroll_row: usize, +) -> usize { + let page_row = page_index / cols.max(1); + let visible_rows = visible_rows.max(1); + let next = if page_row < current_scroll_row { + page_row + } else if page_row >= current_scroll_row.saturating_add(visible_rows) { + page_row.saturating_add(1).saturating_sub(visible_rows) + } else { + current_scroll_row + }; + next.min(max_scroll_row) } diff --git a/src/input/state/core/board_picker/layout/helpers.rs b/src/input/state/core/board_picker/layout/helpers.rs index 102a114c..15ab6760 100644 --- a/src/input/state/core/board_picker/layout/helpers.rs +++ b/src/input/state/core/board_picker/layout/helpers.rs @@ -1,15 +1,17 @@ use super::super::super::base::InputState; -use super::super::{ - BoardPickerLayout, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, PAGE_PANEL_MAX_COLS, - PAGE_PANEL_MAX_ROWS, PAGE_PANEL_PADDING_X, -}; +use super::super::{BoardPickerLayout, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, PAGE_PANEL_MAX_COLS}; #[derive(Debug, Clone, Copy)] pub(super) struct PagePanelInfo { pub page_count: usize, pub cols: usize, pub rows: usize, + pub total_rows: usize, + pub scroll_row: usize, + pub max_scroll_row: usize, + pub first_visible_page: usize, pub visible_pages: usize, + pub visible_slots: usize, pub slot_count: usize, } @@ -30,20 +32,23 @@ impl InputState { PAGE_PANEL_MAX_COLS } .max(1); - let max_rows = if layout.page_max_rows > 0 { - layout.page_max_rows - } else { - PAGE_PANEL_MAX_ROWS - } - .max(1); - let rows = page_count.max(1).div_ceil(cols).clamp(1, max_rows); + let rows = layout.page_rows.max(1); let slot_count = rows.saturating_mul(cols); - let visible_pages = page_count.min(slot_count); + let first_visible_page = layout.page_first_visible_index.min(page_count); + let visible_pages = layout + .page_visible_count + .min(page_count.saturating_sub(first_visible_page)) + .min(slot_count); Some(PagePanelInfo { page_count, cols, rows, + total_rows: layout.page_total_rows, + scroll_row: layout.page_scroll_row, + max_scroll_row: layout.page_max_scroll_row, + first_visible_page, visible_pages, + visible_slots: layout.page_visible_slots.min(slot_count), slot_count, }) } @@ -52,23 +57,60 @@ impl InputState { layout.page_thumb_height + PAGE_NAME_HEIGHT + PAGE_NAME_PADDING + layout.page_thumb_gap } + pub(super) fn board_picker_slot_to_page_index( + &self, + layout: BoardPickerLayout, + board_index: usize, + slot: usize, + ) -> Option { + let info = self.board_picker_page_panel_info(layout, board_index)?; + if slot >= info.visible_slots { + return None; + } + let page_index = info.first_visible_page + slot; + (page_index < info.page_count).then_some(page_index) + } + + pub(super) fn board_picker_page_index_to_slot( + &self, + layout: BoardPickerLayout, + board_index: usize, + page_index: usize, + ) -> Option { + let info = self.board_picker_page_panel_info(layout, board_index)?; + if page_index < info.first_visible_page { + return None; + } + let slot = page_index - info.first_visible_page; + (slot < info.visible_slots && page_index < info.page_count).then_some(slot) + } + pub(super) fn board_picker_page_thumb_origin( &self, layout: BoardPickerLayout, board_index: usize, - index: usize, + page_index: usize, ) -> Option<(PagePanelInfo, usize, usize, f64, f64)> { let info = self.board_picker_page_panel_info(layout, board_index)?; - if index >= info.slot_count { + let slot = self.board_picker_page_index_to_slot(layout, board_index, page_index)?; + self.board_picker_page_thumb_origin_for_slot(layout, info, slot) + } + + pub(super) fn board_picker_page_thumb_origin_for_slot( + &self, + layout: BoardPickerLayout, + info: PagePanelInfo, + slot: usize, + ) -> Option<(PagePanelInfo, usize, usize, f64, f64)> { + if slot >= info.slot_count { return None; } - let row = index / info.cols; - let col = index % info.cols; + let row = slot / info.cols; + let col = slot % info.cols; let stride = Self::board_picker_page_row_stride(layout); - let thumb_x = layout.page_panel_x - + PAGE_PANEL_PADDING_X - + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); - let thumb_y = layout.page_panel_y + row as f64 * stride; + let thumb_x = + layout.page_viewport_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); + let thumb_y = layout.page_viewport_y + row as f64 * stride; Some((info, row, col, thumb_x, thumb_y)) } } diff --git a/src/input/state/core/board_picker/layout/hit_test.rs b/src/input/state/core/board_picker/layout/hit_test.rs index f0ee853b..4fdabdb0 100644 --- a/src/input/state/core/board_picker/layout/hit_test.rs +++ b/src/input/state/core/board_picker/layout/hit_test.rs @@ -3,7 +3,6 @@ use crate::draw::Color; use super::super::super::base::InputState; use super::super::{ PAGE_DELETE_ICON_MARGIN, PAGE_DELETE_ICON_SIZE, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, - PAGE_PANEL_PADDING_X, }; use super::helpers::PagePanelInfo; @@ -71,14 +70,18 @@ impl InputState { context: &BoardPickerPagePanelHitContext, mut rect_for_thumb: impl FnMut(f64, f64) -> FloatRect, ) -> Option { - for index in 0..context.info.visible_pages { + for slot in 0..context.info.visible_slots { let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(context.layout, context.board_index, index) + self.board_picker_page_thumb_origin_for_slot(context.layout, context.info, slot) else { continue; }; if rect_for_thumb(thumb_x, thumb_y).contains(x, y) { - return Some(index); + return self.board_picker_slot_to_page_index( + context.layout, + context.board_index, + slot, + ); } } None @@ -212,18 +215,26 @@ impl InputState { return false; }; - let index = context.info.visible_pages; - let add_col = index % context.info.cols; - let add_row = index / context.info.cols; - if add_row >= context.layout.page_max_rows.max(1) { + let sticky_rect = FloatRect { + x: context.layout.page_add_button_x, + y: context.layout.page_add_button_y, + w: context.layout.page_add_button_width, + h: context.layout.page_add_button_height, + }; + if sticky_rect.contains(x as f64, y as f64) { + return true; + } + + if !self.board_picker_end_of_pages_visible(&context) { return false; } - let row_stride = Self::board_picker_page_row_stride(context.layout); - let thumb_x = context.layout.page_panel_x - + PAGE_PANEL_PADDING_X - + add_col as f64 * (context.layout.page_thumb_width + context.layout.page_thumb_gap); - let thumb_y = context.layout.page_panel_y + add_row as f64 * row_stride; + let add_slot = context.info.page_count - context.info.first_visible_page; + let Some((_info, _row, _col, thumb_x, thumb_y)) = + self.board_picker_page_thumb_origin_for_slot(context.layout, context.info, add_slot) + else { + return false; + }; let thumb_rect = FloatRect { x: thumb_x, y: thumb_y, @@ -233,6 +244,11 @@ impl InputState { thumb_rect.contains(x as f64, y as f64) } + fn board_picker_end_of_pages_visible(&self, context: &BoardPickerPagePanelHitContext) -> bool { + context.info.first_visible_page + context.info.visible_pages >= context.info.page_count + && context.info.visible_pages < context.info.slot_count + } + pub(crate) fn board_picker_page_overflow_at(&self, x: i32, y: i32) -> bool { let context = self.board_picker_page_panel_context(); let Some(context) = context else { @@ -242,15 +258,12 @@ impl InputState { return false; } - let hint_x = context.layout.page_panel_x + PAGE_PANEL_PADDING_X; - let hint_y = context.layout.page_panel_y - + context.layout.page_panel_height - + context.layout.footer_font_size - + 6.0; + let hint_x = context.layout.page_add_button_x; + let hint_y = context.layout.page_add_button_y - context.layout.footer_font_size - 4.0; let hint_rect = FloatRect { x: hint_x, - y: hint_y - context.layout.footer_font_size, - w: context.layout.page_panel_width - PAGE_PANEL_PADDING_X * 2.0, + y: hint_y, + w: context.layout.page_add_button_width, h: context.layout.footer_font_size + 8.0, }; hint_rect.contains(x as f64, y as f64) @@ -276,6 +289,19 @@ impl InputState { }) } + pub(crate) fn board_picker_page_panel_content_at(&self, x: i32, y: i32) -> bool { + let Some(context) = self.board_picker_page_panel_context() else { + return false; + }; + FloatRect { + x: context.layout.page_panel_x, + y: context.layout.page_panel_y, + w: context.layout.page_panel_width, + h: context.layout.page_panel_height, + } + .contains(x as f64, y as f64) + } + pub(crate) fn board_picker_page_duplicate_index_at(&self, x: i32, y: i32) -> Option { self.with_visible_page_context(|context| { self.board_picker_find_page_thumb_index( diff --git a/src/input/state/core/board_picker/mod.rs b/src/input/state/core/board_picker/mod.rs index 8d3edf75..4c87d6fb 100644 --- a/src/input/state/core/board_picker/mod.rs +++ b/src/input/state/core/board_picker/mod.rs @@ -56,6 +56,9 @@ const PAGE_THUMB_MIN_WIDTH: f64 = 72.0; const PAGE_THUMB_MAX_WIDTH: f64 = 150.0; const PAGE_PANEL_MAX_COLS: usize = 3; const PAGE_PANEL_MAX_ROWS: usize = 3; +const PAGE_PANEL_HEADER_HEIGHT: f64 = 32.0; +const PAGE_PANEL_ADD_BUTTON_HEIGHT: f64 = 24.0; +const PAGE_PANEL_ADD_BUTTON_GAP: f64 = 8.0; pub(crate) const PAGE_HEADER_ICON_SIZE: f64 = 12.0; pub(crate) const PAGE_DELETE_ICON_SIZE: f64 = 14.0; pub(crate) const PAGE_DELETE_ICON_MARGIN: f64 = 5.0; @@ -71,7 +74,9 @@ pub enum BoardPickerState { edit: Option, mode: BoardPickerMode, focus: BoardPickerFocus, - page_focus_index: Option, + page_focus_page_index: Option, + page_scroll_row: usize, + page_scroll_target_page_index: Option, }, } @@ -159,12 +164,25 @@ pub struct BoardPickerLayout { pub page_panel_y: f64, pub page_panel_width: f64, pub page_panel_height: f64, + pub page_viewport_x: f64, + pub page_viewport_y: f64, + pub page_viewport_width: f64, + pub page_viewport_height: f64, + pub page_add_button_x: f64, + pub page_add_button_y: f64, + pub page_add_button_width: f64, + pub page_add_button_height: f64, pub page_thumb_width: f64, pub page_thumb_height: f64, pub page_thumb_gap: f64, pub page_cols: usize, pub page_rows: usize, pub page_max_rows: usize, + pub page_total_rows: usize, + pub page_scroll_row: usize, + pub page_max_scroll_row: usize, + pub page_first_visible_index: usize, + pub page_visible_slots: usize, pub page_count: usize, pub page_visible_count: usize, pub page_board_index: Option, diff --git a/src/input/state/core/board_picker/search.rs b/src/input/state/core/board_picker/search.rs index 47049098..7155c347 100644 --- a/src/input/state/core/board_picker/search.rs +++ b/src/input/state/core/board_picker/search.rs @@ -41,12 +41,12 @@ impl InputState { // Typing always returns focus to the board list if let BoardPickerState::Open { focus, - page_focus_index, + page_focus_page_index, .. } = &mut self.board_picker_state { *focus = BoardPickerFocus::BoardList; - *page_focus_index = None; + *page_focus_page_index = None; } self.board_picker_select_search_match(); self.needs_redraw = true; @@ -292,7 +292,7 @@ mod tests { state.board_picker_append_search('b'); assert_eq!(state.board_picker_focus(), BoardPickerFocus::BoardList); - assert_eq!(state.board_picker_page_focus_index(), None); + assert_eq!(state.board_picker_page_focus_page_index(), None); } #[test] diff --git a/src/input/state/core/board_picker/state/actions.rs b/src/input/state/core/board_picker/state/actions.rs index 3acbd0fb..8cea6afc 100644 --- a/src/input/state/core/board_picker/state/actions.rs +++ b/src/input/state/core/board_picker/state/actions.rs @@ -1,5 +1,6 @@ use super::super::super::base::{InputState, UiToastKind}; use super::super::{BoardPickerEditMode, BoardPickerState}; +use crate::draw::PageDeleteOutcome; impl InputState { pub(crate) fn board_picker_row_count(&self) -> usize { @@ -50,21 +51,39 @@ impl InputState { let Some(board_index) = self.board_picker_page_panel_board_index() else { return; }; - self.add_page_in_board(board_index); + if self.add_page_in_board(board_index) + && let Some(page_index) = self + .boards + .board_states() + .get(board_index) + .map(|board| board.pages.active_index()) + { + self.board_picker_queue_page_scroll_to(page_index); + self.board_picker_set_page_focus_page_index(page_index); + } } - pub(crate) fn board_picker_delete_page(&mut self, page_index: usize) { + pub(crate) fn board_picker_delete_page(&mut self, page_index: usize) -> PageDeleteOutcome { let Some(board_index) = self.board_picker_page_panel_board_index() else { - return; + return PageDeleteOutcome::Pending; }; - self.delete_page_in_board(board_index, page_index); + self.delete_page_in_board(board_index, page_index) } pub(crate) fn board_picker_duplicate_page(&mut self, page_index: usize) { let Some(board_index) = self.board_picker_page_panel_board_index() else { return; }; - let _ = self.duplicate_page_in_board(board_index, page_index); + if self.duplicate_page_in_board(board_index, page_index) + && let Some(new_page_index) = self + .boards + .board_states() + .get(board_index) + .map(|board| board.pages.active_index()) + { + self.board_picker_queue_page_scroll_to(new_page_index); + self.board_picker_set_page_focus_page_index(new_page_index); + } } pub(crate) fn board_picker_create_new(&mut self) { diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index 0bda0460..c4372c19 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -115,15 +115,15 @@ 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 F2: rename Del: delete Tab: back Esc: close".to_string() + "Enter: open Ctrl+N: add F2: rename Del: delete Tab: back".to_string() } else { let page_panel_enabled = self .board_picker_layout .is_some_and(|layout| layout.page_panel_enabled); if page_panel_enabled { - "Enter: open F2: rename Del: delete Tab: pages Esc: close".to_string() + "Enter: open F2: rename Ctrl+C: color Del: delete Tab: pages".to_string() } else { - "Enter: open F2: rename Ctrl+N: new Del: delete Esc: close".to_string() + "Enter: open F2: rename Ctrl+C: color Ctrl+N: new Del: delete".to_string() } } } @@ -193,6 +193,9 @@ impl InputState { let selected_row_full = selected_board.and_then(|board_index| { self.board_picker_row_for_board_in_mode(board_index, BoardPickerMode::Full) }); + let selected_active_page = selected_board + .and_then(|board_index| self.boards.board_states().get(board_index)) + .map(|board| board.pages.active_index()); let BoardPickerState::Open { selected, @@ -200,7 +203,9 @@ impl InputState { edit, mode, focus, - page_focus_index, + page_focus_page_index, + page_scroll_row, + page_scroll_target_page_index, } = &mut self.board_picker_state else { return; @@ -212,7 +217,9 @@ impl InputState { *hover_index = None; *edit = None; *focus = BoardPickerFocus::BoardList; - *page_focus_index = None; + *page_focus_page_index = None; + *page_scroll_row = 0; + *page_scroll_target_page_index = selected_active_page; 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 168c2716..b3c6afe4 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -29,13 +29,16 @@ impl InputState { self.board_picker_page_drag = None; self.board_picker_page_edit = None; let active_index = self.boards.active_index(); + let active_page = self.boards.active_page_index(); self.board_picker_state = BoardPickerState::Open { selected: active_index, hover_index: None, edit: None, mode: BoardPickerMode::Full, focus: BoardPickerFocus::BoardList, - page_focus_index: None, + page_focus_page_index: None, + page_scroll_row: 0, + page_scroll_target_page_index: Some(active_page), }; let selected_row = self.board_picker_row_for_board(active_index); if let (Some(selected), BoardPickerState::Open { selected: row, .. }) = @@ -59,13 +62,16 @@ impl InputState { self.board_picker_page_drag = None; self.board_picker_page_edit = None; let active_index = self.boards.active_index(); + let active_page = self.boards.active_page_index(); self.board_picker_state = BoardPickerState::Open { selected: active_index, hover_index: None, edit: None, mode: BoardPickerMode::Quick, focus: BoardPickerFocus::BoardList, - page_focus_index: None, + page_focus_page_index: None, + page_scroll_row: 0, + page_scroll_target_page_index: Some(active_page), }; let selected_row = self.board_picker_row_for_board(active_index); if let (Some(selected), BoardPickerState::Open { selected: row, .. }) = @@ -148,28 +154,15 @@ impl InputState { if self.board_picker_focus() == new_focus { return; } - // Compute active page index before mutably borrowing state, - // clamped to visible thumbnail count so focus never lands off-screen. let active_page = if new_focus == BoardPickerFocus::PagePanel { - let visible = self - .board_picker_layout - .map(|layout| layout.page_visible_count) - .unwrap_or(0); - if visible == 0 { - 0 - } else { - let board_index = self.board_picker_page_panel_board_index_inner(); - let raw = board_index - .and_then(|index| self.boards.board_states().get(index)) - .map_or(0, |board| board.pages.active_index()); - raw.min(visible.saturating_sub(1)) - } + self.board_picker_selected_board_active_page() } else { 0 }; let BoardPickerState::Open { focus, - page_focus_index, + page_focus_page_index, + page_scroll_target_page_index, .. } = &mut self.board_picker_state else { @@ -178,42 +171,39 @@ impl InputState { *focus = new_focus; match new_focus { BoardPickerFocus::PagePanel => { - if page_focus_index.is_none() { - *page_focus_index = Some(active_page); + if page_focus_page_index.is_none() { + *page_focus_page_index = Some(active_page); + *page_scroll_target_page_index = Some(active_page); } } BoardPickerFocus::BoardList => { - *page_focus_index = None; + *page_focus_page_index = None; } } self.needs_redraw = true; self.dirty_tracker.mark_full(); } - pub(crate) fn board_picker_page_focus_index(&self) -> Option { + pub(crate) fn board_picker_page_focus_page_index(&self) -> Option { match &self.board_picker_state { BoardPickerState::Open { - page_focus_index, .. - } => *page_focus_index, + page_focus_page_index, + .. + } => *page_focus_page_index, BoardPickerState::Hidden => None, } } - pub(crate) fn board_picker_set_page_focus_index(&mut self, index: usize) { - let visible = self - .board_picker_layout - .map(|layout| layout.page_visible_count) - .unwrap_or(0); - let clamped = if visible == 0 { - 0 - } else { - index.min(visible.saturating_sub(1)) - }; + pub(crate) fn board_picker_set_page_focus_page_index(&mut self, index: usize) { + let clamped = self.board_picker_clamp_page_index(index); if let BoardPickerState::Open { - page_focus_index, .. + page_focus_page_index, + page_scroll_target_page_index, + .. } = &mut self.board_picker_state { - *page_focus_index = Some(clamped); + *page_focus_page_index = Some(clamped); + *page_scroll_target_page_index = Some(clamped); self.needs_redraw = true; self.dirty_tracker.mark_full(); } @@ -232,16 +222,31 @@ impl InputState { pub(crate) fn board_picker_set_selected(&mut self, index: usize) { let row_count = self.board_picker_row_count().max(1); let next = index.min(row_count.saturating_sub(1)); + let previous_board = self.board_picker_page_panel_board_index(); + let next_board = self + .board_picker_board_index_for_row(next) + .unwrap_or_else(|| self.boards.active_index()); + let next_active_page = self + .boards + .board_states() + .get(next_board) + .map_or(0, |board| board.pages.active_index()); if let BoardPickerState::Open { selected, focus, - page_focus_index, + page_focus_page_index, + page_scroll_row, + page_scroll_target_page_index, .. } = &mut self.board_picker_state { *selected = next; *focus = BoardPickerFocus::BoardList; - *page_focus_index = None; + *page_focus_page_index = None; + if previous_board != Some(next_board) { + *page_scroll_row = 0; + *page_scroll_target_page_index = Some(next_active_page); + } self.needs_redraw = true; self.dirty_tracker.mark_full(); } @@ -254,4 +259,119 @@ impl InputState { pub(crate) fn board_picker_is_page_dragging(&self) -> bool { self.board_picker_page_drag.is_some() } + + pub(crate) fn board_picker_page_panel_state_parts( + &self, + ) -> Option<(usize, Option, Option)> { + match &self.board_picker_state { + BoardPickerState::Open { + page_scroll_row, + page_focus_page_index, + page_scroll_target_page_index, + .. + } => Some(( + *page_scroll_row, + *page_focus_page_index, + *page_scroll_target_page_index, + )), + BoardPickerState::Hidden => None, + } + } + + pub(crate) fn set_board_picker_page_panel_state_parts( + &mut self, + scroll_row: usize, + focus_page_index: Option, + scroll_target_page_index: Option, + ) { + if let BoardPickerState::Open { + page_scroll_row, + page_focus_page_index, + page_scroll_target_page_index, + .. + } = &mut self.board_picker_state + { + *page_scroll_row = scroll_row; + *page_focus_page_index = focus_page_index; + *page_scroll_target_page_index = scroll_target_page_index; + } + } + + pub(crate) fn board_picker_queue_page_scroll_to(&mut self, page_index: usize) { + let clamped = self.board_picker_clamp_page_index(page_index); + if let BoardPickerState::Open { + page_scroll_target_page_index, + .. + } = &mut self.board_picker_state + { + *page_scroll_target_page_index = Some(clamped); + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + } + } + + pub(crate) fn board_picker_scroll_page_panel_rows(&mut self, delta_rows: isize) -> bool { + let Some(layout) = self.board_picker_layout else { + return false; + }; + if !layout.page_panel_enabled || delta_rows == 0 { + return false; + } + let max = layout.page_max_scroll_row; + let cols = layout.page_cols.max(1); + let visible_slots = layout.page_visible_slots.max(1); + let page_count = layout.page_count; + if let BoardPickerState::Open { + page_scroll_row, + page_focus_page_index, + page_scroll_target_page_index, + .. + } = &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); + } + } + self.needs_redraw = true; + self.dirty_tracker.mark_full(); + return true; + } + } + false + } + + fn board_picker_selected_board_active_page(&self) -> usize { + self.board_picker_page_panel_board_index_inner() + .and_then(|index| self.boards.board_states().get(index)) + .map_or(0, |board| board.pages.active_index()) + } + + fn board_picker_clamp_page_index(&self, index: usize) -> usize { + let page_count = self + .board_picker_page_panel_board_index_inner() + .and_then(|board_index| self.boards.board_states().get(board_index)) + .map_or(0, |board| board.pages.page_count()); + if page_count == 0 { + 0 + } else { + index.min(page_count.saturating_sub(1)) + } + } } diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index ff7a9de5..1eec4087 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -1,6 +1,7 @@ use super::create_test_input_state; use crate::draw::Frame; use crate::input::BoardBackground; +use crate::input::events::Key; use crate::input::state::core::board_picker::{ BoardPickerDrag, BoardPickerEditMode, BoardPickerFocus, BoardPickerMode, BoardPickerPageDrag, BoardPickerPageEdit, BoardPickerState, @@ -177,6 +178,42 @@ fn set_board_page_count( pages.extend((0..page_count.max(1)).map(|_| Frame::new())); } +fn page_thumb_origin( + layout: crate::input::state::BoardPickerLayout, + page_index: usize, +) -> Option<(f64, f64)> { + if page_index < layout.page_first_visible_index { + return None; + } + let slot = page_index - layout.page_first_visible_index; + if slot >= layout.page_visible_slots { + return None; + } + let col = slot % layout.page_cols.max(1); + let row = slot / layout.page_cols.max(1); + if row >= layout.page_rows.max(1) { + return None; + } + Some(( + layout.page_viewport_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap), + layout.page_viewport_y + + row as f64 + * (layout.page_thumb_height + + PAGE_NAME_HEIGHT + + PAGE_NAME_PADDING + + layout.page_thumb_gap), + )) +} + +fn assert_page_visible(layout: crate::input::state::BoardPickerLayout, page_index: usize) { + assert!( + page_thumb_origin(layout, page_index).is_some(), + "page {} should be visible in {:?}", + page_index + 1, + layout + ); +} + #[test] fn board_picker_page_hit_testing_uses_rendered_thumbnail_positions() { let mut input = create_test_input_state(); @@ -186,8 +223,7 @@ fn board_picker_page_hit_testing_uses_rendered_thumbnail_positions() { let layout = *input.board_picker_layout().expect("layout"); assert!(layout.page_panel_enabled); - let thumb_x = layout.page_panel_x + 12.0; - let thumb_y = layout.page_panel_y; + let (thumb_x, thumb_y) = page_thumb_origin(layout, 0).expect("page 1 visible"); assert_eq!( input.board_picker_page_index_at((thumb_x + 1.0) as i32, (thumb_y + 1.0) as i32), @@ -230,8 +266,8 @@ fn board_picker_empty_page_list_has_no_page_hit() { update_picker_layout(&mut input, 1280, 720); let layout = *input.board_picker_layout().expect("layout"); - let add_x = (layout.page_panel_x + 12.0 + 1.0) as i32; - let add_y = (layout.page_panel_y + 1.0) as i32; + let add_x = (layout.page_viewport_x + 1.0) as i32; + let add_y = (layout.page_viewport_y + 1.0) as i32; assert_eq!(input.board_picker_page_index_at(add_x, add_y), None); assert!(input.board_picker_page_add_card_at(add_x, add_y)); @@ -272,8 +308,8 @@ fn board_picker_add_card_clickable_when_pages_exactly_fill_rows() { let add_row = target_pages / layout.page_cols.max(1); assert!(add_row < max_rows); - let add_x = (layout.page_panel_x + 12.0 + 1.0) as i32; - let add_y = (layout.page_panel_y + add_row as f64 * row_stride + 1.0) as i32; + let add_x = (layout.page_viewport_x + 1.0) as i32; + let add_y = (layout.page_viewport_y + add_row as f64 * row_stride + 1.0) as i32; assert!(input.board_picker_page_add_card_at(add_x, add_y)); assert_eq!(input.board_picker_page_index_at(add_x, add_y), None); @@ -303,13 +339,319 @@ fn board_picker_overflow_hitbox_matches_rendered_hint_position() { layout = *input.board_picker_layout().expect("layout"); } - let hint_x = (layout.page_panel_x + 12.0 + 2.0) as i32; - let hint_y = - (layout.page_panel_y + layout.page_panel_height + layout.footer_font_size + 6.0) as i32; + let hint_x = (layout.page_add_button_x + 2.0) as i32; + let hint_y = (layout.page_add_button_y - layout.footer_font_size) as i32; assert!(input.board_picker_page_overflow_at(hint_x, hint_y)); } +#[test] +fn board_picker_page_ten_hit_testing_returns_absolute_index() { + 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); + input.boards.board_states_mut()[board_index] + .pages + .switch_to_page(9); + input.board_picker_queue_page_scroll_to(9); + + update_picker_layout(&mut input, 1280, 720); + let layout = *input.board_picker_layout().expect("layout"); + assert_page_visible(layout, 9); + let (thumb_x, thumb_y) = page_thumb_origin(layout, 9).expect("page 10 origin"); + + assert_eq!( + input.board_picker_page_index_at((thumb_x + 1.0) as i32, (thumb_y + 1.0) as i32), + Some(9) + ); + + let icon_y = + thumb_y + layout.page_thumb_height - PAGE_DELETE_ICON_SIZE * 0.5 - PAGE_DELETE_ICON_MARGIN; + let rename_x = thumb_x + PAGE_DELETE_ICON_SIZE * 0.5 + PAGE_DELETE_ICON_MARGIN; + let duplicate_x = thumb_x + layout.page_thumb_width * 0.5; + let delete_x = + thumb_x + layout.page_thumb_width - PAGE_DELETE_ICON_SIZE * 0.5 - PAGE_DELETE_ICON_MARGIN; + + assert_eq!( + input.board_picker_page_rename_index_at(rename_x as i32, icon_y as i32), + Some(9) + ); + assert_eq!( + input.board_picker_page_duplicate_index_at(duplicate_x as i32, icon_y as i32), + Some(9) + ); + assert_eq!( + input.board_picker_page_delete_index_at(delete_x as i32, icon_y as i32), + Some(9) + ); +} + +#[test] +fn board_picker_page_ten_operations_use_absolute_index() { + 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); + for index in 0..12 { + assert!( + input.boards.board_states_mut()[board_index] + .pages + .set_page_name(index, Some(format!("Page {}", index + 1))) + ); + } + update_picker_layout(&mut input, 1280, 720); + + input.board_picker_duplicate_page(9); + + let pages = &input.boards.board_states()[board_index].pages; + assert_eq!(pages.active_index(), 10); + assert_eq!(pages.page_name(10), Some("Page 10")); + assert_eq!(pages.page_name(9), Some("Page 10")); + + input.board_picker_delete_page(9); + assert_eq!( + input.boards.board_states()[board_index].pages.page_count(), + 13, + "first delete should only request confirmation" + ); + input.board_picker_delete_page(9); + + let pages = &input.boards.board_states()[board_index].pages; + assert_eq!(pages.page_count(), 12); + assert_eq!(pages.page_name(9), Some("Page 10")); +} + +#[test] +fn board_picker_active_page_ten_visible_on_open_and_focus() { + let mut input = create_test_input_state(); + let board_index = input.boards.active_index(); + set_board_page_count(&mut input, board_index, 12); + input.boards.board_states_mut()[board_index] + .pages + .switch_to_page(9); + + input.open_board_picker(); + update_picker_layout(&mut input, 1280, 720); + let layout = *input.board_picker_layout().expect("layout"); + assert_page_visible(layout, 9); + + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + update_picker_layout(&mut input, 1280, 720); + let layout = *input.board_picker_layout().expect("layout"); + assert_eq!(input.board_picker_page_focus_page_index(), Some(9)); + assert_page_visible(layout, 9); +} + +#[test] +fn board_picker_keyboard_focus_scrolls_absolute_page_into_view() { + 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.board_picker_set_page_focus_page_index(14); + update_picker_layout(&mut input, 1280, 720); + + let layout = *input.board_picker_layout().expect("layout"); + assert_eq!(input.board_picker_page_focus_page_index(), Some(14)); + assert_page_visible(layout, 14); +} + +#[test] +fn board_picker_sticky_add_works_when_visible_grid_is_full() { + 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); + + let layout = *input.board_picker_layout().expect("layout"); + assert_eq!(layout.page_visible_count, layout.page_visible_slots); + let add_x = (layout.page_add_button_x + layout.page_add_button_width * 0.5) as i32; + let add_y = (layout.page_add_button_y + layout.page_add_button_height * 0.5) as i32; + + assert!(input.board_picker_page_add_card_at(add_x, add_y)); + + input.board_picker_add_page(); + update_picker_layout(&mut input, 1280, 720); + + let pages = &input.boards.board_states()[board_index].pages; + assert_eq!(pages.page_count(), 13); + assert_eq!(pages.active_index(), 12); + assert_page_visible(*input.board_picker_layout().expect("layout"), 12); +} + +#[test] +fn board_picker_ctrl_n_adds_page_while_page_panel_focused() { + 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, 1); + 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('n'))); + + assert_eq!( + input.boards.board_states()[board_index].pages.page_count(), + 2 + ); + assert_eq!( + input.boards.board_states()[board_index] + .pages + .active_index(), + 1 + ); +} + +#[test] +fn board_picker_add_and_duplicate_scroll_to_newly_active_page() { + 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, 15); + update_picker_layout(&mut input, 1280, 720); + + input.board_picker_add_page(); + update_picker_layout(&mut input, 1280, 720); + assert_page_visible(*input.board_picker_layout().expect("layout"), 15); + + input.board_picker_duplicate_page(15); + update_picker_layout(&mut input, 1280, 720); + assert_page_visible(*input.board_picker_layout().expect("layout"), 16); +} + +#[test] +fn board_picker_wheel_scroll_changes_visible_page_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"); + set_board_page_count(&mut input, board_index, 18); + update_picker_layout(&mut input, 1280, 720); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + input.board_picker_set_page_focus_page_index(0); + update_picker_layout(&mut input, 1280, 720); + + let layout = *input.board_picker_layout().expect("layout"); + let start = layout.page_first_visible_index; + let x = (layout.page_viewport_x + 1.0) as i32; + let y = (layout.page_viewport_y + 1.0) as i32; + + assert!(input.board_picker_page_panel_content_at(x, y)); + assert!(input.board_picker_scroll_page_panel_rows(1)); + update_picker_layout(&mut input, 1280, 720); + + let layout = *input.board_picker_layout().expect("layout"); + assert!(layout.page_first_visible_index > start); + assert_page_visible( + layout, + input + .board_picker_page_focus_page_index() + .expect("page focus"), + ); +} + +#[test] +fn board_picker_repeated_wheel_scroll_uses_state_between_layouts() { + 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 layout = *input.board_picker_layout().expect("layout"); + assert!(layout.page_max_scroll_row >= 2); + + assert!(input.board_picker_scroll_page_panel_rows(1)); + assert!(input.board_picker_scroll_page_panel_rows(1)); + update_picker_layout(&mut input, 1280, 720); + + let layout = *input.board_picker_layout().expect("layout"); + assert_eq!(layout.page_scroll_row, 2); + assert_eq!(layout.page_first_visible_index, layout.page_cols * 2); +} + +#[test] +fn board_picker_wheel_scroll_up_clamps_focus_to_last_visible_page() { + 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 initial = *input.board_picker_layout().expect("layout"); + assert!(initial.page_max_scroll_row >= 2); + input.set_board_picker_page_panel_state_parts(2, None, None); + update_picker_layout(&mut input, 1280, 720); + + let before = *input.board_picker_layout().expect("layout"); + let focus = before + .page_first_visible_index + .saturating_add(before.page_visible_slots) + .min(before.page_count) + .saturating_sub(1); + input.set_board_picker_page_panel_state_parts(before.page_scroll_row, Some(focus), None); + + assert!(input.board_picker_scroll_page_panel_rows(-1)); + update_picker_layout(&mut input, 1280, 720); + + let after = *input.board_picker_layout().expect("layout"); + let expected_focus = after + .page_first_visible_index + .saturating_add(after.page_visible_slots) + .min(after.page_count) + .saturating_sub(1); + assert_eq!( + input.board_picker_page_focus_page_index(), + Some(expected_focus) + ); + assert_page_visible(after, expected_focus); +} + +#[test] +fn board_picker_column_change_keeps_focused_absolute_page_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, 12); + update_picker_layout(&mut input, 1280, 720); + + input.board_picker_set_focus(BoardPickerFocus::PagePanel); + input.board_picker_set_page_focus_page_index(9); + update_picker_layout(&mut input, 1280, 720); + let wide = *input.board_picker_layout().expect("layout"); + assert_page_visible(wide, 9); + + update_picker_layout(&mut input, 180, 720); + let narrow = *input.board_picker_layout().expect("layout"); + assert_eq!(input.board_picker_page_focus_page_index(), Some(9)); + assert_page_visible(narrow, 9); +} + #[test] fn board_picker_row_action_hitboxes_match_rendered_positions() { let mut input = create_test_input_state(); @@ -390,8 +732,8 @@ fn board_picker_page_focus_clamps_to_existing_pages() { assert_eq!(layout.page_visible_count, 1); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.board_picker_set_page_focus_index(usize::MAX); - assert_eq!(input.board_picker_page_focus_index(), Some(0)); + input.board_picker_set_page_focus_page_index(usize::MAX); + assert_eq!(input.board_picker_page_focus_page_index(), Some(0)); } #[test] @@ -416,10 +758,21 @@ fn board_picker_footer_text_changes_for_quick_and_page_panel_modes() { ); input.open_board_picker(); + assert_eq!( + input.board_picker_footer_text(), + "Enter: open F2: rename Ctrl+C: color Ctrl+N: new Del: delete" + ); + + update_picker_layout(&mut input, 1280, 720); + assert_eq!( + input.board_picker_footer_text(), + "Enter: open F2: rename Ctrl+C: color Del: delete Tab: pages" + ); + input.board_picker_set_focus(BoardPickerFocus::PagePanel); assert_eq!( input.board_picker_footer_text(), - "Enter: open F2: rename Del: delete Tab: back Esc: close" + "Enter: open Ctrl+N: add F2: rename Del: delete Tab: back" ); } @@ -469,6 +822,105 @@ fn board_picker_rename_selected_promotes_quick_mode_to_full_editing() { ); } +#[test] +fn board_picker_f2_starts_board_name_edit_not_color_edit() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + + input.open_board_picker(); + let selected_row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + input.board_picker_set_selected(selected_row); + assert!(input.handle_board_picker_key(Key::F2)); + + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Name, selected_row, "Blackboard")) + ); +} + +#[test] +fn board_picker_f2_key_route_starts_board_name_edit_not_color_edit() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + + input.open_board_picker(); + let selected_row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + input.board_picker_set_selected(selected_row); + input.on_key_press(Key::F2); + + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Name, selected_row, "Blackboard")) + ); +} + +#[test] +fn board_picker_f2_switches_color_edit_back_to_name_edit() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + + input.open_board_picker(); + let selected_row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + input.board_picker_set_selected(selected_row); + input.board_picker_edit_color_selected(); + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Color, selected_row, "#111111")) + ); + + assert!(input.handle_board_picker_key(Key::F2)); + + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Name, selected_row, "Blackboard")) + ); +} + +#[test] +fn board_picker_ctrl_c_starts_board_color_edit() { + let mut input = create_test_input_state(); + let blackboard_index = input + .boards + .board_states() + .iter() + .position(|board| board.spec.id == "blackboard") + .expect("blackboard board"); + + input.open_board_picker(); + let selected_row = input + .board_picker_row_for_board(blackboard_index) + .expect("blackboard row"); + input.board_picker_set_selected(selected_row); + input.modifiers.ctrl = true; + assert!(input.handle_board_picker_key(Key::Char('c'))); + + assert_eq!( + input.board_picker_edit_state(), + Some((BoardPickerEditMode::Color, selected_row, "#111111")) + ); +} + #[test] fn board_picker_edit_color_selected_shows_info_toast_for_transparent_board() { let mut input = create_test_input_state(); diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index 17b83222..9e1f8289 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -4,7 +4,9 @@ use crate::input::InputState; use crate::input::state::{ BoardPickerFocus, BoardPickerLayout, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, }; -use crate::ui::constants::{self, DIVIDER_LIGHT, TEXT_HINT, TEXT_TERTIARY}; +use crate::ui::constants::{ + self, BG_HOVER, DIVIDER_LIGHT, TEXT_HINT, TEXT_SECONDARY, TEXT_TERTIARY, +}; use crate::ui::primitives::draw_rounded_rect; use thumbnail::{ @@ -12,8 +14,6 @@ use thumbnail::{ render_page_thumbnail, }; -const PAGE_PANEL_PADDING_X: f64 = 12.0; - pub(super) fn render_page_panel( ctx: &cairo::Context, input_state: &InputState, @@ -61,12 +61,11 @@ pub(super) fn render_page_panel( let _ = ctx.show_text(&label); let (pointer_x, pointer_y) = input_state.pointer_position(); - let start_x = layout.page_panel_x + PAGE_PANEL_PADDING_X; - let start_y = layout.page_panel_y; + let start_x = layout.page_viewport_x; + let start_y = layout.page_viewport_y; let row_stride = layout.page_thumb_height + PAGE_NAME_HEIGHT + PAGE_NAME_PADDING + layout.page_thumb_gap; let cols = layout.page_cols.max(1); - let max_rows = layout.page_max_rows.max(1); // Handle empty state - show "Add your first page" CTA if page_count == 0 { @@ -84,8 +83,8 @@ pub(super) fn render_page_panel( } let active_page = board.pages.active_index(); - let page_focus_index = if input_state.board_picker_focus() == BoardPickerFocus::PagePanel { - input_state.board_picker_page_focus_index() + let page_focus_page_index = if input_state.board_picker_focus() == BoardPickerFocus::PagePanel { + input_state.board_picker_page_focus_page_index() } else { None }; @@ -93,15 +92,19 @@ pub(super) fn render_page_panel( let hover_delete = input_state.board_picker_page_delete_index_at(pointer_x, pointer_y); let hover_duplicate = input_state.board_picker_page_duplicate_index_at(pointer_x, pointer_y); let hover_rename = input_state.board_picker_page_rename_index_at(pointer_x, pointer_y); - let rows = page_count.div_ceil(cols).min(max_rows); - let visible = page_count.min(rows.saturating_mul(cols)); + let first_visible = layout.page_first_visible_index.min(page_count); + let visible = layout + .page_visible_count + .min(page_count.saturating_sub(first_visible)); + let visible_slots = layout.page_visible_slots.max(visible); - for (index, page) in pages.iter().enumerate().take(visible) { - let col = index % cols; - let row = index / cols; - if row >= rows { + for slot in 0..visible { + let index = first_visible + slot; + let Some(page) = pages.get(index) else { continue; - } + }; + let col = slot % cols; + let row = slot / cols; let thumb_x = start_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); let thumb_y = start_y + row as f64 * row_stride; let is_active = index == active_page; @@ -125,7 +128,7 @@ pub(super) fn render_page_panel( is_active, is_drop_target, is_hovered: hover_index == Some(index), - is_keyboard_focused: page_focus_index == Some(index), + is_keyboard_focused: page_focus_page_index == Some(index), delete_hovered: hover_delete == Some(index), duplicate_hovered: hover_duplicate == Some(index), rename_hovered: hover_rename == Some(index), @@ -133,11 +136,13 @@ pub(super) fn render_page_panel( } if let Some(hover_index) = hover_index - && hover_index < visible + && hover_index >= first_visible + && hover_index < first_visible + visible && !is_dragging { - let col = hover_index % cols; - let row = hover_index / cols; + let slot = hover_index - first_visible; + let col = slot % cols; + let row = slot / cols; let thumb_x = start_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); let thumb_y = start_y + row as f64 * row_stride; let page = &pages[hover_index]; @@ -157,10 +162,12 @@ pub(super) fn render_page_panel( if let Some((edit_board, edit_page, buffer)) = input_state.board_picker_page_edit_state() && edit_board == board_index - && edit_page < visible + && edit_page >= first_visible + && edit_page < first_visible + visible { - let col = edit_page % cols; - let row = edit_page / cols; + let slot = edit_page - first_visible; + let col = slot % cols; + let row = slot / cols; let thumb_x = start_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); let thumb_y = start_y + row as f64 * row_stride; render_page_rename_overlay( @@ -174,14 +181,22 @@ pub(super) fn render_page_panel( ); } - // Render "Add page" card at the end of thumbnails (if space available) - let add_card_index = visible; - let add_col = add_card_index % cols; - let add_row = add_card_index / cols; - if add_row < max_rows { + // Render "Add page" card at the end of thumbnails when the end of the page list is visible. + let end_visible = first_visible + visible >= page_count; + if end_visible && visible < visible_slots { + let add_card_slot = visible; + let add_col = add_card_slot % cols; + let add_row = add_card_slot / cols; let add_x = start_x + add_col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); let add_y = start_y + add_row as f64 * row_stride; - let add_hover = input_state.board_picker_page_add_card_at(pointer_x, pointer_y); + let add_hover = point_in_rect( + pointer_x, + pointer_y, + add_x, + add_y, + layout.page_thumb_width, + layout.page_thumb_height, + ); render_add_page_card( ctx, add_x, @@ -193,10 +208,12 @@ pub(super) fn render_page_panel( ); } - // Overflow indicator - styled as clickable + render_sticky_add_button(ctx, layout, pointer_x, pointer_y); + if page_count > visible { - let overflow = page_count - visible; - let hint = format!("+{overflow} more"); + let first_label = first_visible + 1; + let last_label = first_visible + visible; + let hint = format!("Pages {first_label}-{last_label} of {page_count}"); ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); ctx.set_font_size(layout.footer_font_size); let overflow_hover = input_state.board_picker_page_overflow_at(pointer_x, pointer_y); @@ -205,7 +222,7 @@ pub(super) fn render_page_panel( } else { constants::set_color(ctx, TEXT_HINT); } - let hint_y = start_y + layout.page_panel_height + layout.footer_font_size + 6.0; + let hint_y = layout.page_add_button_y - 4.0; ctx.move_to(start_x, hint_y); let _ = ctx.show_text(&hint); if overflow_hover && let Ok(extents) = ctx.text_extents(&hint) { @@ -217,6 +234,59 @@ pub(super) fn render_page_panel( } } +fn render_sticky_add_button( + ctx: &cairo::Context, + layout: BoardPickerLayout, + pointer_x: i32, + pointer_y: i32, +) { + let hover = point_in_rect( + pointer_x, + pointer_y, + layout.page_add_button_x, + layout.page_add_button_y, + layout.page_add_button_width, + layout.page_add_button_height, + ); + draw_rounded_rect( + ctx, + layout.page_add_button_x, + layout.page_add_button_y, + layout.page_add_button_width, + layout.page_add_button_height, + 5.0, + ); + if hover { + constants::set_color(ctx, BG_HOVER); + } else { + ctx.set_source_rgba(0.16, 0.20, 0.28, 0.85); + } + let _ = ctx.fill_preserve(); + ctx.set_source_rgba(1.0, 1.0, 1.0, if hover { 0.28 } else { 0.16 }); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(layout.footer_font_size); + constants::set_color(ctx, TEXT_SECONDARY); + let label = "+ Add page"; + if let Ok(extents) = ctx.text_extents(label) { + let text_x = + layout.page_add_button_x + (layout.page_add_button_width - extents.width()) * 0.5; + let text_y = layout.page_add_button_y + + (layout.page_add_button_height + extents.height()) * 0.5 + - 1.0; + ctx.move_to(text_x, text_y); + let _ = ctx.show_text(label); + } +} + +fn point_in_rect(x: i32, y: i32, rx: f64, ry: f64, rw: f64, rh: f64) -> bool { + let x = x as f64; + let y = y as f64; + x >= rx && x <= rx + rw && y >= ry && y <= ry + rh +} + fn render_page_rename_overlay( ctx: &cairo::Context, x: f64,