diff --git a/docs/ADMIN_DISPUTES.md b/docs/ADMIN_DISPUTES.md index 147abb8..798a83d 100644 --- a/docs/ADMIN_DISPUTES.md +++ b/docs/ADMIN_DISPUTES.md @@ -23,12 +23,12 @@ The admin interface provides dedicated tabs for dispute management: ### 1. Disputes Pending Tab -Lists all pending disputes on the Mostro network (state: `Initiated`). Admins can: +Lists pending disputes on the Mostro network (state: `Initiated`, filtered via `get_initiated_disputes`). Admins can: -- **View dispute details**: Order ID, parties involved, status +- **View dispute details**: Dispute ID, status, created time (Created column drops on narrow terminals) - **Take a dispute**: Select a dispute and press Enter to take ownership -- **Navigate**: Use arrow keys to browse the dispute list -- **Color coding**: Disputes are color-coded by status (Yellow for pending) +- **Navigate**: ↑↓ browse the list; selection is by dispute UUID (`selected_pending_dispute_id`), resolved through `selected_pending_dispute` / `move_pending_dispute_selection` in `src/ui/helpers/dispute_selection.rs` +- **Scrolling**: persistent `disputes_table_state` + `render_table_list_scrollbar` (same offset/track pattern as the Orders tab) ### 2. Disputes in Progress Tab @@ -43,7 +43,7 @@ The interface is divided into three main sections: 1. **Left Sidebar (20%)**: List of disputes in progress (or finalized when Shift+C filter is active) - Shows truncated dispute IDs (safely handles short IDs without panicking) - **Selection by dispute id** (`AppState.selected_dispute_id`), resolved through `get_filtered_disputes` / `selected_filtered_dispute` / `move_dispute_selection` in `src/ui/helpers/dispute_selection.rs` — Up/Down and all chat/finalize/attachment actions use the **visible** filtered list, never a raw index into mixed open+closed rows - - **Scrollable list**: stateful `List` + `ListState` keeps the highlighted row in view when disputes overflow the sidebar; vertical scrollbar when the list is taller than the panel + - **Scrollable list**: stateful `List` + `ListState` keeps the highlighted row in view when disputes overflow the sidebar; vertical scrollbar via shared `render_table_list_scrollbar` (viewport offset, data-row track) - Highlighted selection with Up/Down arrow keys (skips disputes hidden by the current filter) - Updates main area when selection changes - Shows "No disputes in progress" / "No finalized disputes" when empty diff --git a/docs/TUI_INTERFACE.md b/docs/TUI_INTERFACE.md index 8500139..bb1fa1d 100644 --- a/docs/TUI_INTERFACE.md +++ b/docs/TUI_INTERFACE.md @@ -23,7 +23,12 @@ pub struct AppState { pub active_tab: Tab, /// Orders tab selection by order UUID (currency-filtered; see helpers/order_selection.rs). pub selected_order_id: Option, - pub selected_dispute_idx: usize, // Disputes Pending (Initiated) list index + /// Persistent scroll state for the Orders tab table. + pub orders_table_state: TableState, + /// Disputes Pending selection by dispute UUID (initiated projection; see dispute_selection.rs). + pub selected_pending_dispute_id: Option, + /// Persistent scroll state for the Disputes Pending table. + pub disputes_table_state: TableState, /// Disputes In Progress / Finalized selection is by **dispute id**, not a raw /// index into `admin_disputes_in_progress` (see `helpers/dispute_selection.rs`). pub selected_dispute_id: Option, @@ -77,7 +82,7 @@ Mostrix supports two distinct roles, each with its own set of tabs and workflows Focused on trading and order management. -- **Orders**: View the global order book (stateful table scrolls with ↑↓ when the book is taller than the terminal; optional vertical scrollbar). +- **Orders**: View the global order book (persistent `TableState` scrolls with ↑↓; shared vertical scrollbar confined to data rows). - **My Trades**: Manage active trades. - **Messages**: Direct messages for trade coordination. - **Settings**: Local configuration, including key rotation via **Generate New Keys** and mnemonic backup prompts. **User mode only**: **Set Lightning Address (buyer)** / **Clear Lightning Address** — optional `user@domain.com` stored in `settings.toml`; confirm-save fetches LNURL metadata (`payRequest`) before persisting (see `src/util/ln_address.rs`, `spawn_verify_and_save_ln_address_task`). The visible menu and **Enter** routing share **`ADMIN_SETTINGS`** / **`USER_SETTINGS`** in `src/ui/tabs/settings_tab.rs` (`SettingsMenuAction` + label per row; **`settings_action_for_index`**). @@ -87,7 +92,7 @@ Focused on trading and order management. Focused on dispute resolution and protocol management. -- **Disputes Pending**: List of disputes waiting to be taken. Only displays disputes with `Initiated` status (filtering implemented in `disputes_tab.rs`). Admins can select and take ownership of these disputes. +- **Disputes Pending**: List of disputes waiting to be taken (`Initiated` only via `get_initiated_disputes`). Selection by dispute UUID (`selected_pending_dispute_id` + `dispute_selection.rs`); persistent `disputes_table_state` + shared scrollbar (same pattern as Orders). Admins take ownership with Enter. - **Disputes in Progress**: Complete workspace for managing taken disputes (state: `InProgress`), featuring: - Integrated chat system with buyer and seller - Comprehensive dispute information header @@ -270,9 +275,10 @@ The `handle_key_event` function dispatches keys based on the current `UiMode`. Renders a table of pending orders from the Mostro network. Status and order kinds are color-coded for readability. -- **Scrolling**: uses a stateful [`Table`](https://docs.rs/ratatui) + `TableState` so ↑↓ keeps the selected row in view when the order book is taller than the terminal (same idea as the Messages sidebar / Disputes In Progress list). A vertical scrollbar appears when row count exceeds the visible body height. +- **Scrolling**: persistent [`TableState`](https://docs.rs/ratatui) on `AppState.orders_table_state` so ↑↓ keeps the selected row in view without resetting the viewport each frame (aligned with Disputes Pending). A vertical scrollbar from `render_table_list_scrollbar` appears when row count exceeds the visible body; thumb tracks viewport **offset** and stays on the data-row track (does not overwrite borders/header). - **Selection by order id** (`selected_order_id` + `helpers/order_selection.rs`): ↑↓ / highlight / Enter all resolve through the same currency-filtered book projection. If the stored id is hidden by `currencies_filter`, selection falls back to the first visible row so take/cancel never targets a filtered-out order. Survives book reorders better than a raw list index. - **Narrow terminals** (`width < 100`): compact column set (Kind / Fiat Amt / Premium / Payment) — Premium stays visible. +- **Short terminals** (`height < 4`): header row is dropped so at least one data row remains visible. **Source**: `src/ui/tabs/orders_tab.rs`, `src/ui/helpers/order_selection.rs` diff --git a/src/main.rs b/src/main.rs index 6ea73c9..52dc18b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -691,11 +691,8 @@ async fn main() -> Result<(), anyhow::Error> { } } - // Ensure the selected dispute index is valid when disputes list changes. - // Only count "initiated" disputes since that's what we display + // Ensure Pending dispute selection stays valid when the list changes. { - use mostro_core::prelude::*; - use std::str::FromStr; let disputes_lock = match disputes.lock() { Ok(g) => g, Err(e) => { @@ -711,19 +708,7 @@ async fn main() -> Result<(), anyhow::Error> { continue; } }; - let initiated_count = disputes_lock - .iter() - .filter(|d| { - DisputeStatus::from_str(d.status.as_str()) - .map(|s| s == DisputeStatus::Initiated) - .unwrap_or(false) - }) - .count(); - if initiated_count > 0 && app.selected_dispute_idx >= initiated_count { - app.selected_dispute_idx = initiated_count.saturating_sub(1); - } else if initiated_count == 0 { - app.selected_dispute_idx = 0; - } + crate::ui::helpers::clamp_pending_dispute_selection(&mut app, &disputes_lock); } // Process async completions before draw so popups appear without extra keypresses. diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index af6802f..0385695 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -169,7 +169,11 @@ pub struct AppState { pub selected_order_id: Option, /// Persistent scroll state for the Orders tab table pub orders_table_state: TableState, - pub selected_dispute_idx: usize, // Selected dispute in Disputes Pending tab + /// Disputes Pending selection by **dispute UUID**, resolved against the + /// initiated-status projection (`helpers/dispute_selection.rs`). + pub selected_pending_dispute_id: Option, + /// Persistent scroll state for the Disputes Pending table + pub disputes_table_state: TableState, /// Disputes In Progress / Finalized selection is by **dispute id**, not a raw /// index into `admin_disputes_in_progress` (see `helpers/dispute_selection.rs`). pub selected_dispute_id: Option, // Selected dispute (by dispute id) in Disputes in Progress tab @@ -280,7 +284,8 @@ impl AppState { active_tab: initial_tab, selected_order_id: None, orders_table_state: TableState::default(), - selected_dispute_idx: 0, + selected_pending_dispute_id: None, + disputes_table_state: TableState::default(), selected_dispute_id: None, active_chat_party: ChatParty::Buyer, admin_chat_input: String::new(), @@ -367,9 +372,11 @@ impl AppState { self.user_role = new_role; self.active_tab = Tab::first(new_role); self.mode = UiMode::default_for_role(new_role); - self.selected_dispute_idx = 0; + self.selected_pending_dispute_id = None; + self.disputes_table_state = TableState::default(); self.selected_settings_option = 0; self.selected_order_id = None; + self.orders_table_state = TableState::default(); self.selected_dispute_id = None; self.active_chat_party = ChatParty::Buyer; self.admin_chat_input.clear(); diff --git a/src/ui/draw.rs b/src/ui/draw.rs index b98c5ea..513d3ac 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -12,6 +12,46 @@ use crate::ui::orders::strip_new_order_messages_and_clamp_selected; use crate::ui::*; use crate::util::fatal::request_fatal_restart; +/// Preferred content height so bordered tab panels can still show one data row +/// (top border + row + bottom border) after the panel drops its own header. +const MIN_SHELL_CONTENT_HEIGHT: u16 = 3; +const FULL_TAB_BAR_HEIGHT: u16 = 3; +const FULL_STATUS_BAR_HEIGHT: u16 = 3; + +/// Tab-bar and status-bar heights for the main shell. +/// +/// On short terminals, shrink the status bar first, then the tab bar, so the +/// active tab keeps at least [`MIN_SHELL_CONTENT_HEIGHT`] rows whenever the +/// terminal is tall enough to allow it. +fn shell_chrome_heights(total_height: u16, show_status: bool) -> (u16, u16) { + let status_full = if show_status { + FULL_STATUS_BAR_HEIGHT + } else { + 0 + }; + if total_height >= FULL_TAB_BAR_HEIGHT + status_full + MIN_SHELL_CONTENT_HEIGHT { + return (FULL_TAB_BAR_HEIGHT, status_full); + } + + let mut tabs = FULL_TAB_BAR_HEIGHT.min(total_height); + let mut status = status_full.min(total_height.saturating_sub(tabs)); + let mut content = total_height.saturating_sub(tabs).saturating_sub(status); + + if content < MIN_SHELL_CONTENT_HEIGHT { + let need = MIN_SHELL_CONTENT_HEIGHT - content; + let take_status = status.min(need); + status -= take_status; + content += take_status; + } + if content < MIN_SHELL_CONTENT_HEIGHT { + let need = MIN_SHELL_CONTENT_HEIGHT - content; + let take_tabs = tabs.min(need); + tabs -= take_tabs; + } + + (tabs, status) +} + /// Main UI draw function, extracted from `ui::mod`. pub fn ui_draw( f: &mut ratatui::Frame, @@ -20,19 +60,20 @@ pub fn ui_draw( disputes: &Arc>>, status_line: Option<&[String]>, ) { - // Create layout: one row for tabs, content area, and status bar (3 lines for status) + let (tab_h, status_h) = shell_chrome_heights(f.area().height, status_line.is_some()); let chunks = Layout::new( Direction::Vertical, [ - Constraint::Length(3), + Constraint::Length(tab_h), Constraint::Min(0), - Constraint::Length(3), // Status bar with 3 lines + Constraint::Length(status_h), ], ) .split(f.area()); - // Render tabs - tabs::render_tabs(f, chunks[0], app.active_tab, app.user_role); + if tab_h > 0 { + tabs::render_tabs(f, chunks[0], app.active_tab, app.user_role); + } // Fatal restart prompt: render only the popup overlay (no additional locks). if app.fatal_exit_on_close { @@ -103,12 +144,7 @@ pub fn ui_draw( } } (Tab::Admin(AdminTab::DisputesPending), UserRole::Admin) => { - tabs::disputes_tab::render_disputes_tab( - f, - content_area, - disputes, - app.selected_dispute_idx, - ) + tabs::disputes_tab::render_disputes_tab(f, content_area, disputes, app) } (Tab::Admin(AdminTab::DisputesInProgress), UserRole::Admin) => { tabs::disputes_in_progress_tab::render_disputes_in_progress(f, content_area, app) @@ -137,18 +173,20 @@ pub fn ui_draw( } } - // Bottom status bar - if let Some(lines) = status_line { - let pending_count = match app.pending_notifications.lock() { - Ok(g) => *g, - Err(e) => { - request_fatal_restart(format!( - "Mostrix encountered an internal error (poisoned pending notifications lock: {e}). Please restart the app." - )); - 0 - } - }; - status::render_status_bar(f, chunks[2], lines, pending_count); + // Bottom status bar (omitted when shell chrome shrinks it to height 0) + if status_h > 0 { + if let Some(lines) = status_line { + let pending_count = match app.pending_notifications.lock() { + Ok(g) => *g, + Err(e) => { + request_fatal_restart(format!( + "Mostrix encountered an internal error (poisoned pending notifications lock: {e}). Please restart the app." + )); + 0 + } + }; + status::render_status_bar(f, chunks[2], lines, pending_count); + } } // Confirmation popup overlay (user mode only) @@ -605,3 +643,89 @@ fn render_add_solver_popup(f: &mut ratatui::Frame, add_solver_state: &AddSolverS crate::ui::helpers::render_help_text(f, chunks[7], "Press ", "Esc", " to cancel"); } + +#[cfg(test)] +mod tests { + use super::{shell_chrome_heights, ui_draw, FULL_STATUS_BAR_HEIGHT, FULL_TAB_BAR_HEIGHT}; + use crate::ui::{AppState, UserRole}; + use mostro_core::prelude::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::sync::{Arc, Mutex}; + use uuid::Uuid; + + fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool { + let mut flat = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + flat.push_str(buf[(x, y)].symbol()); + } + flat.push('\n'); + } + flat.contains(needle) + } + + fn initiated_dispute(nibble: u8) -> Dispute { + let mut dispute = Dispute::new(Uuid::new_v4(), "active".to_string()); + dispute.id = Uuid::from_bytes([nibble * 0x11; 16]); + dispute + } + + #[test] + fn shell_chrome_keeps_full_bars_when_tall_enough() { + assert_eq!( + shell_chrome_heights(9, true), + (FULL_TAB_BAR_HEIGHT, FULL_STATUS_BAR_HEIGHT) + ); + assert_eq!(shell_chrome_heights(8, false), (FULL_TAB_BAR_HEIGHT, 0)); + } + + #[test] + fn shell_chrome_shrinks_status_before_tabs_on_short_terminals() { + // 8 rows: free 1 from status → tabs stay bordered (3), content gets 3. + assert_eq!(shell_chrome_heights(8, true), (3, 2)); + assert_eq!(shell_chrome_heights(7, true), (3, 1)); + assert_eq!(shell_chrome_heights(6, true), (3, 0)); + // Below that, tabs shrink too so content still reaches 3 when possible. + assert_eq!(shell_chrome_heights(5, true), (2, 0)); + assert_eq!(shell_chrome_heights(4, true), (1, 0)); + assert_eq!(shell_chrome_heights(3, true), (0, 0)); + } + + /// Regression (Hermeme on #125): fixed 3+3 shell chrome left only 2 content + /// rows on an 8-row terminal, so Disputes Pending showed borders and no data. + #[test] + fn ui_draw_keeps_pending_dispute_visible_on_8_row_terminal() { + let dispute = initiated_dispute(1); + assert!( + dispute.id.to_string().starts_with("11111111"), + "fixture must use the 11111111- id prefix" + ); + let dispute_id = dispute.id; + let disputes = Arc::new(Mutex::new(vec![dispute])); + let orders = Arc::new(Mutex::new(Vec::new())); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(dispute_id); + let status = [ + "status line 1".to_string(), + "status line 2".to_string(), + "status line 3".to_string(), + ]; + + let backend = TestBackend::new(100, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| ui_draw(f, &mut app, &orders, &disputes, Some(&status))) + .expect("draw"); + + let buf = terminal.backend().buffer(); + assert!( + buffer_contains(buf, "11111111"), + "pending dispute id must stay visible through ui_draw at 8 rows" + ); + assert!( + buffer_contains(buf, "initiated"), + "pending dispute status must stay visible through ui_draw at 8 rows" + ); + } +} diff --git a/src/ui/helpers/dispute_selection.rs b/src/ui/helpers/dispute_selection.rs index cdbe9ce..b437c76 100644 --- a/src/ui/helpers/dispute_selection.rs +++ b/src/ui/helpers/dispute_selection.rs @@ -1,12 +1,97 @@ -//! Admin dispute selection helpers shared by rendering and key handling. +//! Dispute selection helpers shared by rendering and key handling. +//! +//! Covers both admin surfaces that pick a dispute from a filtered list: +//! - **Disputes Pending** — `mostro_core::Dispute` rows with `Initiated` status, +//! selected by UUID (`selected_pending_dispute_id`) +//! - **Disputes In Progress / Finalized** — local `AdminDispute` rows, selected by +//! dispute-id string (`selected_dispute_id`) use std::str::FromStr; -use mostro_core::prelude::DisputeStatus; +use mostro_core::prelude::{Dispute, DisputeStatus}; +use uuid::Uuid; use crate::models::AdminDispute; use crate::ui::{AppState, DisputeFilter}; +/// Pending (initiated) disputes as `(original_index, dispute)` pairs. +pub fn get_initiated_disputes(disputes: &[Dispute]) -> Vec<(usize, Dispute)> { + disputes + .iter() + .enumerate() + .filter(|(_, d)| { + DisputeStatus::from_str(d.status.as_str()) + .map(|s| s == DisputeStatus::Initiated) + .unwrap_or(false) + }) + .map(|(i, d)| (i, d.clone())) + .collect() +} + +/// Display row of the Pending-tab selection inside `initiated`. +/// +/// Falls back to the first row when nothing is selected or the id is no longer +/// in the initiated list. Returns `None` only when `initiated` is empty. +pub fn selected_pending_display_idx( + selected_pending_dispute_id: Option, + initiated: &[(usize, Dispute)], +) -> Option { + if initiated.is_empty() { + return None; + } + Some( + selected_pending_dispute_id + .and_then(|id| initiated.iter().position(|(_, d)| d.id == id)) + .unwrap_or(0), + ) +} + +/// The dispute the Pending table currently shows as selected. +/// +/// Resolves `selected_pending_dispute_id` against the initiated-status projection +/// so Enter / take always acts on the highlighted row — never on a non-initiated +/// dispute still present in the raw vec. +pub fn selected_pending_dispute(app: &AppState, disputes: &[Dispute]) -> Option { + let mut initiated = get_initiated_disputes(disputes); + let idx = selected_pending_display_idx(app.selected_pending_dispute_id, &initiated)?; + Some(initiated.swap_remove(idx).1) +} + +/// Move Pending-tab selection `delta` rows within initiated disputes, clamping +/// at both ends, and store the landing dispute's id. +pub fn move_pending_dispute_selection(app: &mut AppState, disputes: &[Dispute], delta: isize) { + let initiated = get_initiated_disputes(disputes); + let Some(idx) = selected_pending_display_idx(app.selected_pending_dispute_id, &initiated) + else { + app.selected_pending_dispute_id = None; + return; + }; + let new_idx = idx + .saturating_add_signed(delta) + .min(initiated.len().saturating_sub(1)); + app.selected_pending_dispute_id = Some(initiated[new_idx].1.id); +} + +/// Clamp / clear Pending selection when the initiated list shrinks or empties. +/// +/// Keeps a still-valid id unchanged; clears when nothing is initiated; otherwise +/// repairs a missing/stale id to the first initiated dispute (used from the main +/// loop when the dispute list refreshes). +pub fn clamp_pending_dispute_selection(app: &mut AppState, disputes: &[Dispute]) { + let initiated = get_initiated_disputes(disputes); + if initiated.is_empty() { + app.selected_pending_dispute_id = None; + return; + } + if let Some(id) = app.selected_pending_dispute_id { + if initiated.iter().any(|(_, d)| d.id == id) { + return; + } + } + // Missing or stale id → first visible initiated dispute. + app.selected_pending_dispute_id = Some(initiated[0].1.id); +} + /// Filter disputes based on the current filter state. /// Returns owned data so the caller can mutate app (e.g. scroll state) in the same block. pub fn get_filtered_disputes(app: &AppState) -> Vec<(usize, AdminDispute)> { @@ -230,4 +315,63 @@ mod tests { move_dispute_selection(&mut app, 1); assert_eq!(app.selected_dispute_id, None, "navigation stays a no-op"); } + + fn pending_dispute(nibble: u8) -> Dispute { + let mut d = Dispute::new(Uuid::from_bytes([nibble * 0x11; 16]), "active".to_string()); + d.id = Uuid::from_bytes([nibble * 0x11; 16]); + d + } + + #[test] + fn pending_selection_by_id_survives_list_reorder() { + let keep = Uuid::from_bytes([0x22; 16]); + let other = Uuid::from_bytes([0x11; 16]); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(keep); + + let reordered = vec![pending_dispute(1), pending_dispute(2)]; + assert_eq!(reordered[1].id, keep); + assert_eq!(reordered[0].id, other); + + let selected = selected_pending_dispute(&app, &reordered).expect("selection"); + assert_eq!(selected.id, keep); + } + + #[test] + fn pending_hidden_selection_falls_back_to_first_initiated() { + let initiated = Uuid::from_bytes([0x11; 16]); + let taken = Uuid::from_bytes([0x22; 16]); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(taken); + + let mut disputes = vec![pending_dispute(1), pending_dispute(2)]; + disputes[1].status = "in-progress".to_string(); + + let selected = selected_pending_dispute(&app, &disputes).expect("fallback"); + assert_eq!(selected.id, initiated); + + move_pending_dispute_selection(&mut app, &disputes, 1); + assert_eq!( + app.selected_pending_dispute_id, + Some(initiated), + "only one initiated row — clamp stays put" + ); + } + + #[test] + fn clamp_pending_clears_when_empty_and_repairs_stale_id() { + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(Uuid::from_bytes([0x99; 16])); + + clamp_pending_dispute_selection(&mut app, &[]); + assert_eq!(app.selected_pending_dispute_id, None); + + let disputes = vec![pending_dispute(1)]; + clamp_pending_dispute_selection(&mut app, &disputes); + assert_eq!( + app.selected_pending_dispute_id, + Some(disputes[0].id), + "stale id repaired to first initiated" + ); + } } diff --git a/src/ui/helpers/layout.rs b/src/ui/helpers/layout.rs index 028aec7..a203e95 100644 --- a/src/ui/helpers/layout.rs +++ b/src/ui/helpers/layout.rs @@ -1,10 +1,51 @@ use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Borders, Paragraph}; +use ratatui::widgets::{Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}; use crate::ui::PRIMARY_COLOR; +/// Vertical scrollbar for a bordered table/list whose selection scrolls with +/// [`ratatui::widgets::TableState`] / [`ratatui::widgets::ListState`]. +/// +/// Draws only when `content_len` exceeds the visible body. The track is confined +/// to data rows (skipping the top border and optional header) so the thumb does +/// not overwrite corner glyphs. +/// +/// `viewport_offset` is the stateful table/list **offset** (first visible row). +/// Ratatui only parks the thumb at the track end when +/// `position == content_length - 1`, so this helper maps the scrollable offset +/// range `[0, content_len - visible_rows]` onto that scale — selecting the last +/// row (max offset) places the thumb fully at the bottom. +pub fn render_table_list_scrollbar( + f: &mut ratatui::Frame, + area: Rect, + content_len: usize, + visible_rows: usize, + header_rows: u16, + viewport_offset: usize, +) { + if content_len <= visible_rows || visible_rows == 0 { + return; + } + let track = Rect { + x: area.x, + y: area.y + 1 + header_rows, + width: area.width, + height: visible_rows as u16, + }; + // Max table offset keeps the last row at the bottom of the viewport. + // Remap so that offset maps onto ratatui's [0, content_length - 1] positions. + let max_offset = content_len.saturating_sub(visible_rows); + let mut scrollbar_state = + ScrollbarState::new(max_offset.saturating_add(1)).position(viewport_offset.min(max_offset)); + f.render_stateful_widget( + Scrollbar::default().orientation(ScrollbarOrientation::VerticalRight), + track, + &mut scrollbar_state, + ); +} + /// Creates a centered popup area within the given area. pub fn create_centered_popup(area: Rect, width: u16, height: u16) -> Rect { let (popup_width, popup_height) = (width.min(area.width), height.min(area.height)); diff --git a/src/ui/helpers/mod.rs b/src/ui/helpers/mod.rs index 2391290..2f52da0 100644 --- a/src/ui/helpers/mod.rs +++ b/src/ui/helpers/mod.rs @@ -33,14 +33,17 @@ pub use chat_visibility::{ get_selected_chat_message, get_visible_attachment_messages, message_visible_for_party, }; pub use dispute_selection::{ - get_filtered_disputes, move_dispute_selection, selected_display_idx, selected_filtered_dispute, + clamp_pending_dispute_selection, get_filtered_disputes, get_initiated_disputes, + move_dispute_selection, move_pending_dispute_selection, selected_display_idx, + selected_filtered_dispute, selected_pending_display_idx, selected_pending_dispute, }; pub use formatting::{ format_local_timestamp, format_order_id, format_premium, format_user_rating, is_dispute_finalized, relative_time_compact, short_order_id, }; pub use layout::{ - create_centered_popup, render_help_text, render_yes_no_buttons, render_yes_no_cancel_buttons, + create_centered_popup, render_help_text, render_table_list_scrollbar, render_yes_no_buttons, + render_yes_no_cancel_buttons, }; pub use order_chat_projection::{ active_order_chat_list_len, active_order_chat_list_snapshot, build_active_order_chat_list, diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index e23d6b7..80df080 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -3,7 +3,7 @@ use crate::shared::permissions::SolverPermission; use crate::ui::admin_state::AddSolverState; use crate::ui::helpers::{ build_active_order_chat_list, save_order_chat_message, selected_filtered_book_order, - selected_filtered_dispute, + selected_filtered_dispute, selected_pending_dispute, }; use crate::ui::key_handler::chat_helpers::{ build_order_action_view_state, handle_enter_finalize_popup, message_counter, @@ -1020,20 +1020,7 @@ fn handle_enter_normal_mode(app: &mut AppState, ctx: &super::EnterKeyContext<'_> return; } }; - // Filter to only get "initiated" disputes - let initiated_disputes: Vec<(usize, &Dispute)> = disputes_lock - .iter() - .enumerate() - .filter(|(_, dispute)| { - DisputeStatus::from_str(dispute.status.as_str()) - .map(|s| s == DisputeStatus::Initiated) - .unwrap_or(false) - }) - .collect(); - - if let Some((_original_idx, dispute)) = initiated_disputes.get(app.selected_dispute_idx) { - // Only allow taking disputes with "Initiated" status - // (We already filtered, so this should always be true) + if let Some(dispute) = selected_pending_dispute(app, &disputes_lock) { app.mode = UiMode::AdminMode(AdminMode::ConfirmTakeDispute(dispute.id, true)); // Default to YES } diff --git a/src/ui/key_handler/navigation.rs b/src/ui/key_handler/navigation.rs index 163c4de..5c725f5 100644 --- a/src/ui/key_handler/navigation.rs +++ b/src/ui/key_handler/navigation.rs @@ -1,5 +1,6 @@ use crate::ui::helpers::{ active_order_chat_list_len, move_book_order_selection, move_dispute_selection, + move_pending_dispute_selection, }; use crate::ui::orders::strip_new_order_messages_and_clamp_selected; use crate::ui::{ @@ -190,9 +191,6 @@ fn handle_up_key( }; move_book_order_selection(app, &orders_lock, -1); } else if let Tab::Admin(AdminTab::DisputesPending) = app.active_tab { - // Only count disputes with "initiated" status - use mostro_core::prelude::*; - use std::str::FromStr; let disputes_lock = match disputes.lock() { Ok(g) => g, Err(e) => { @@ -202,28 +200,7 @@ fn handle_up_key( return; } }; - let initiated_count = disputes_lock - .iter() - .filter(|d| { - DisputeStatus::from_str(d.status.as_str()) - .map(|s| s == DisputeStatus::Initiated) - .unwrap_or(false) - }) - .count(); - if initiated_count == 0 { - app.selected_dispute_idx = 0; - } else { - // Ensure index doesn't go below 0 - if app.selected_dispute_idx > 0 { - app.selected_dispute_idx -= 1; - } else { - app.selected_dispute_idx = 0; - } - // Clamp to valid range - app.selected_dispute_idx = app - .selected_dispute_idx - .min(initiated_count.saturating_sub(1)); - } + move_pending_dispute_selection(app, &disputes_lock, -1); } else if let Tab::Admin(AdminTab::DisputesInProgress) = app.active_tab { move_dispute_selection(app, -1); } else if let Tab::User(UserTab::Messages) = app.active_tab { @@ -336,9 +313,6 @@ fn handle_down_key( }; move_book_order_selection(app, &orders_lock, 1); } else if let Tab::Admin(AdminTab::DisputesPending) = app.active_tab { - // Only count disputes with "initiated" status - use mostro_core::prelude::*; - use std::str::FromStr; let disputes_lock = match disputes.lock() { Ok(g) => g, Err(e) => { @@ -348,28 +322,7 @@ fn handle_down_key( return; } }; - let initiated_count = disputes_lock - .iter() - .filter(|d| { - DisputeStatus::from_str(d.status.as_str()) - .map(|s| s == DisputeStatus::Initiated) - .unwrap_or(false) - }) - .count(); - if initiated_count == 0 { - app.selected_dispute_idx = 0; - } else { - // Ensure index doesn't exceed bounds - if app.selected_dispute_idx < initiated_count.saturating_sub(1) { - app.selected_dispute_idx += 1; - } else { - app.selected_dispute_idx = initiated_count.saturating_sub(1); - } - // Clamp to valid range - app.selected_dispute_idx = app - .selected_dispute_idx - .min(initiated_count.saturating_sub(1)); - } + move_pending_dispute_selection(app, &disputes_lock, 1); } else if let Tab::Admin(AdminTab::DisputesInProgress) = app.active_tab { move_dispute_selection(app, 1); } else if let Tab::User(UserTab::Messages) = app.active_tab { diff --git a/src/ui/tabs/disputes_in_progress_tab.rs b/src/ui/tabs/disputes_in_progress_tab.rs index 2994cb2..5bc0d25 100644 --- a/src/ui/tabs/disputes_in_progress_tab.rs +++ b/src/ui/tabs/disputes_in_progress_tab.rs @@ -4,8 +4,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect, Size}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{ - Block, BorderType, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph, Scrollbar, - ScrollbarOrientation, ScrollbarState, + Block, BorderType, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph, }; use tui_scrollview::{ScrollView, ScrollbarVisibility}; @@ -13,6 +12,7 @@ use crate::ui::constants::*; use crate::ui::helpers::{ build_chat_scrollview_content, count_visible_attachments, format_local_timestamp, format_user_rating, get_filtered_disputes, get_selected_chat_message, + render_table_list_scrollbar, }; use crate::ui::ChatParty; use crate::ui::{AdminMode, AppState, DisputeFilter, UiMode, BACKGROUND_COLOR, PRIMARY_COLOR}; @@ -89,7 +89,8 @@ pub fn render_disputes_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut f.render_widget(empty_paragraph, sidebar_area); } else { // Stateful List keeps the selected row in view when the sidebar overflows - // (same pattern as Messages tab). Highlight is applied by ListState. + // (same pattern as Orders / Disputes Pending tables). Scrollbar uses the + // shared data-row track helper after ListState computes its offset. let items: Vec = filtered_disputes .iter() .map(|(_original_idx, d)| { @@ -111,15 +112,14 @@ pub fn render_disputes_in_progress(f: &mut ratatui::Frame, area: Rect, app: &mut f.render_stateful_widget(list, sidebar_area, &mut list_state); let visible_rows = sidebar_area.height.saturating_sub(2) as usize; - if filtered_disputes.len() > visible_rows && visible_rows > 0 { - let mut scrollbar_state = - ScrollbarState::new(filtered_disputes.len()).position(valid_selected_idx); - f.render_stateful_widget( - Scrollbar::default().orientation(ScrollbarOrientation::VerticalRight), - sidebar_area, - &mut scrollbar_state, - ); - } + render_table_list_scrollbar( + f, + sidebar_area, + filtered_disputes.len(), + visible_rows, + 0, + list_state.offset(), + ); } // 2. Main Area @@ -987,6 +987,39 @@ mod tests { ); } + /// Last sidebar selection must park the shared scrollbar thumb against `▼` + /// on the sidebar's right edge (same remapping as Orders / Pending). + #[test] + fn sidebar_scrollbar_thumb_reaches_track_bottom_on_last_row() { + let mut app = AppState::new(UserRole::Admin); + app.admin_disputes_in_progress = (0..20) + .map(|i| dispute(&format!("dip-{i:02}"), "in-progress")) + .collect(); + app.selected_dispute_id = Some("dip-19".to_string()); + + let backend = TestBackend::new(100, 16); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_in_progress(f, f.area(), &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + // Sidebar is ~20% width → right edge of first column ≈ x=19 + let sidebar_right = (buf.area.width as f64 * 0.20).floor() as u16; + let sidebar_right = sidebar_right.saturating_sub(1); + let end_cap_y = buf.area.height - 2; + assert_eq!( + buf[(sidebar_right, end_cap_y)].symbol(), + "▼", + "sidebar scrollbar end cap must sit on the last track row" + ); + assert_eq!( + buf[(sidebar_right, end_cap_y - 1)].symbol(), + "█", + "thumb must reach the cell above ▼ when the last sidebar dispute is selected" + ); + } + #[test] fn sidebar_shows_first_disputes_when_selection_is_at_top() { let mut app = AppState::new(UserRole::Admin); diff --git a/src/ui/tabs/disputes_tab.rs b/src/ui/tabs/disputes_tab.rs index 6a60e4a..c5f9eac 100644 --- a/src/ui/tabs/disputes_tab.rs +++ b/src/ui/tabs/disputes_tab.rs @@ -1,4 +1,3 @@ -use std::str::FromStr; use std::sync::{Arc, Mutex}; use mostro_core::prelude::*; @@ -7,16 +6,23 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Span; use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table}; -use crate::ui::helpers::format_local_timestamp; -use crate::ui::{BACKGROUND_COLOR, PRIMARY_COLOR}; +use crate::ui::helpers::{ + format_local_timestamp, get_initiated_disputes, render_table_list_scrollbar, + selected_pending_display_idx, +}; +use crate::ui::{AppState, BACKGROUND_COLOR, PRIMARY_COLOR}; -/// Render the disputes tab showing a table of active disputes -/// This tab is only visible in admin mode +/// Render the Disputes Pending table (admin mode only). +/// +/// Uses a persistent [`TableState`] (`app.disputes_table_state`) so ↑↓ keeps the +/// selected row in view without resetting the viewport each frame. Selection is +/// resolved by dispute UUID against the initiated-status projection +/// (`helpers/dispute_selection.rs`). Scrollbar via [`render_table_list_scrollbar`]. pub fn render_disputes_tab( f: &mut ratatui::Frame, area: ratatui::layout::Rect, disputes: &Arc>>, - selected_dispute_idx: usize, + app: &mut AppState, ) { let disputes_lock = match disputes.lock() { Ok(g) => g, @@ -41,24 +47,11 @@ pub fn render_disputes_tab( } }; - // Filter to only show disputes with "initiated" status - let initiated_disputes: Vec<&Dispute> = disputes_lock - .iter() - .filter(|dispute| { - DisputeStatus::from_str(dispute.status.as_str()) - .map(|s| s == DisputeStatus::Initiated) - .unwrap_or(false) - }) - .collect(); - - // Ensure selected index is within bounds of filtered list - let valid_selected_idx = if initiated_disputes.is_empty() { - 0 - } else { - selected_dispute_idx.min(initiated_disputes.len().saturating_sub(1)) - }; + let initiated = get_initiated_disputes(&disputes_lock); + let valid_selected_idx = + selected_pending_display_idx(app.selected_pending_dispute_id, &initiated).unwrap_or(0); - if initiated_disputes.is_empty() { + if initiated.is_empty() { let paragraph = Paragraph::new(Span::styled( "📭 No disputes found", Style::default().fg(Color::Yellow), @@ -72,48 +65,57 @@ pub fn render_disputes_tab( .style(Style::default().bg(BACKGROUND_COLOR)), ); f.render_widget(paragraph, area); - } else { - let header_cells = vec![ - Cell::from("🆔 Dispute ID").style(Style::default().add_modifier(Modifier::BOLD)), - Cell::from("📊 Status").style(Style::default().add_modifier(Modifier::BOLD)), - Cell::from("📅 Created").style(Style::default().add_modifier(Modifier::BOLD)), - ]; - let header = Row::new(header_cells); - - let rows: Vec = initiated_disputes - .iter() - .enumerate() - .map(|(display_idx, dispute)| { - let id_cell = Cell::from(dispute.id.to_string()); - - let status_str = dispute.status.clone(); - let status_cell = Cell::from(status_str); - - let date_cell = Cell::from( - format_local_timestamp(dispute.created_at, "%Y-%m-%d %H:%M") - .unwrap_or_else(|| "Invalid date".to_string()), - ); + return; + } - let row = Row::new(vec![id_cell, status_cell, date_cell]); + // Compact layouts for small areas: + // - full 40/20/25 when inner width ≥ 87 + // - id + status columns when 43 ≤ inner < 87 + // - single combined cell (short id + status) when inner < 43 + // Drop the header when height < 4 so a data row remains. + let inner_width = area.width.saturating_sub(2); + let show_created = inner_width >= 87; + let ultra_compact = inner_width < 43; + let show_header = area.height >= 4; - if display_idx == valid_selected_idx { - // Highlight the selected row - row.style(Style::default().bg(PRIMARY_COLOR).fg(Color::Black)) - } else { - row + let rows: Vec = initiated + .iter() + .map(|(_orig, dispute)| { + if ultra_compact { + // One cell: shortened UUID prefix + status (fits ~40-col bodies). + let id = dispute.id.to_string(); + let short_id: String = id.chars().take(8).collect(); + Row::new(vec![Cell::from(format!("{short_id} {}", dispute.status))]) + } else { + let mut cells = vec![ + Cell::from(dispute.id.to_string()), + Cell::from(dispute.status.clone()), + ]; + if show_created { + cells.push(Cell::from( + format_local_timestamp(dispute.created_at, "%Y-%m-%d %H:%M") + .unwrap_or_else(|| "Invalid date".to_string()), + )); } - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(40), - Constraint::Length(20), - Constraint::Length(25), - ], - ) - .header(header) + Row::new(cells) + } + }) + .collect(); + + let constraints: Vec = if show_created { + vec![ + Constraint::Length(40), + Constraint::Length(20), + Constraint::Length(25), + ] + } else if ultra_compact { + vec![Constraint::Min(1)] + } else { + // Narrow: dispute id takes the remaining width, status stays visible + vec![Constraint::Min(20), Constraint::Length(20)] + }; + + let mut table = Table::new(rows, constraints) .block( Block::default() .title("Disputes Pending") @@ -129,6 +131,327 @@ pub fn render_disputes_tab( .add_modifier(Modifier::BOLD), ); - f.render_widget(table, area); + if show_header { + let header_cells = if ultra_compact { + vec![Cell::from("🆔 Dispute").style(Style::default().add_modifier(Modifier::BOLD))] + } else { + let mut cells = vec![ + Cell::from("🆔 Dispute ID").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("📊 Status").style(Style::default().add_modifier(Modifier::BOLD)), + ]; + if show_created { + cells.push( + Cell::from("📅 Created").style(Style::default().add_modifier(Modifier::BOLD)), + ); + } + cells + }; + table = table.header(Row::new(header_cells)); + } + + // Persistent TableState keeps ↑ scroll smooth (same as Orders tab). + app.disputes_table_state.select(Some(valid_selected_idx)); + f.render_stateful_widget(table, area, &mut app.disputes_table_state); + + let header_rows = u16::from(show_header); + let visible_rows = area.height.saturating_sub(2 + header_rows) as usize; + render_table_list_scrollbar( + f, + area, + initiated.len(), + visible_rows, + header_rows, + app.disputes_table_state.offset(), + ); +} + +#[cfg(test)] +mod tests { + use super::render_disputes_tab; + use crate::ui::{AppState, UserRole}; + use mostro_core::prelude::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::sync::{Arc, Mutex}; + use uuid::Uuid; + + fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool { + let mut flat = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + flat.push_str(buf[(x, y)].symbol()); + } + flat.push('\n'); + } + flat.contains(needle) + } + + fn initiated_dispute(nibble: u8) -> Dispute { + let mut dispute = Dispute::new(Uuid::new_v4(), "active".to_string()); + // Deterministic, visually distinct id prefix per row (e.g. 00000000-, 11111111-, ...) + dispute.id = Uuid::from_bytes([nibble * 0x11; 16]); + dispute + } + + /// When more pending disputes exist than table rows, selecting a late row + /// must scroll the stateful table so that dispute stays visible. + #[test] + fn table_scrolls_to_keep_selected_dispute_visible() { + let disputes: Vec = (0..10).map(initiated_dispute).collect(); + let first_id = disputes[0].id.to_string(); + let last_id = disputes[9].id.to_string(); + let last_uuid = disputes[9].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(last_uuid); + + // 8 high: 2 borders + 1 header leave 5 visible rows for 10 disputes. + let backend = TestBackend::new(100, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + assert!( + buffer_contains(buf, &last_id[..8]), + "selected late dispute must be visible after table scroll" + ); + assert!( + !buffer_contains(buf, &first_id[..8]), + "first dispute should scroll off-screen when selecting the last" + ); + } + + /// Narrow terminals drop the Created column so dispute id and status + /// stay readable instead of being clipped by the fixed 40/20/25 layout. + #[test] + fn narrow_area_drops_created_column_but_keeps_id_and_status() { + let disputes: Vec = (0..3).map(initiated_dispute).collect(); + let first_id = disputes[0].id.to_string(); + let first_uuid = disputes[0].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(first_uuid); + + let backend = TestBackend::new(60, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + assert!( + buffer_contains(buf, &first_id[..8]), + "dispute id must stay visible in narrow layout" + ); + assert!( + buffer_contains(buf, "initiated"), + "status must stay visible in narrow layout" + ); + assert!( + !buffer_contains(buf, "Created"), + "Created column should be dropped when the area is narrow" + ); + } + + /// Below 43 cols (inner width < 43) the two-column Min(20)+Length(20) layout + /// cannot fit; a single cell with shortened id + status keeps both visible. + #[test] + fn ultra_narrow_area_uses_single_column_short_id_and_status() { + let disputes: Vec = (0..3).map(initiated_dispute).collect(); + let first_id = disputes[0].id.to_string(); + let short_id = &first_id[..8]; + let first_uuid = disputes[0].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(first_uuid); + + // area width 42 → inner 40 < 43 → ultra-compact single column + let backend = TestBackend::new(42, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + assert!( + buffer_contains(buf, short_id), + "shortened dispute id must stay visible under 43 cols" + ); + assert!( + buffer_contains(buf, "initiated"), + "status must stay visible under 43 cols" + ); + assert!( + !buffer_contains(buf, "Created"), + "Created column must not appear in ultra-compact layout" + ); + assert!( + !buffer_contains(buf, "Dispute ID"), + "full Dispute ID header should yield to compact Dispute header" + ); + } + + /// With no room below the header (height < 4) the header is dropped so at + /// least one data row remains visible. + #[test] + fn short_area_drops_header_but_shows_selected_row() { + let disputes: Vec = (0..3).map(initiated_dispute).collect(); + let second_id = disputes[1].id.to_string(); + let second_uuid = disputes[1].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(second_uuid); + + let backend = TestBackend::new(100, 3); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + assert!( + !buffer_contains(buf, "Dispute ID"), + "header should be dropped when the area is too short" + ); + assert!( + buffer_contains(buf, &second_id[..8]), + "selected dispute row must be visible without the header" + ); + } + + /// When the selection forces the table to scroll, the scrollbar must not + /// overwrite the block borders or the header row. + #[test] + fn scrollbar_preserves_borders_and_header_when_scrolled() { + let disputes: Vec = (0..10).map(initiated_dispute).collect(); + let last_uuid = disputes[9].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(last_uuid); + + // Selected row 9 with 5 visible rows → table offset (5) != selected index (9) + let backend = TestBackend::new(100, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + let right = buf.area.width - 1; + assert_eq!(buf[(right, 0)].symbol(), "╮", "top-right corner intact"); + assert_eq!( + buf[(right, buf.area.height - 1)].symbol(), + "╯", + "bottom-right corner intact" + ); + assert_eq!( + buf[(right, 1)].symbol(), + "│", + "header row border must not be overwritten by the scrollbar" + ); + assert!( + buffer_contains(buf, "Dispute ID"), + "header must still render while scrolled" + ); + } + + /// Selecting the last pending dispute must park the scrollbar thumb against + /// the end cap (`▼`) — same offset remapping as Orders. + #[test] + fn scrollbar_thumb_reaches_track_bottom_on_last_row() { + let disputes: Vec = (0..10).map(initiated_dispute).collect(); + let last_uuid = disputes[9].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(last_uuid); + + // height 8 → borders+header leave 5 data rows; track y=2..6 with ▲…▼ + let backend = TestBackend::new(100, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + let right = buf.area.width - 1; + let end_cap_y = buf.area.height - 2; + let above_end = end_cap_y - 1; + assert_eq!( + buf[(right, end_cap_y)].symbol(), + "▼", + "scrollbar end cap must sit on the last track row" + ); + assert_eq!( + buf[(right, above_end)].symbol(), + "█", + "thumb must reach the cell above ▼ when the last dispute is selected" + ); + } + + #[test] + fn table_shows_first_disputes_when_selection_is_at_top() { + let disputes: Vec = (0..10).map(initiated_dispute).collect(); + let first_id = disputes[0].id.to_string(); + let last_id = disputes[9].id.to_string(); + let first_uuid = disputes[0].id; + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(first_uuid); + + let backend = TestBackend::new(100, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw"); + + let buf = terminal.backend().buffer(); + assert!( + buffer_contains(buf, &first_id[..8]), + "first dispute must stay visible when selected" + ); + assert!( + !buffer_contains(buf, &last_id[..8]), + "last dispute should not appear while scrolled to the top" + ); + } + + /// Persisted TableState keeps the viewport offset when moving selection up + /// one row after scrolling to the bottom (Orders-tab alignment). + #[test] + fn persisted_table_state_keeps_viewport_when_moving_up() { + let disputes: Vec = (0..10).map(initiated_dispute).collect(); + let last_uuid = disputes[9].id; + let eighth_uuid = disputes[8].id; + let first_id = disputes[0].id.to_string(); + let disputes = Arc::new(Mutex::new(disputes)); + let mut app = AppState::new(UserRole::Admin); + app.selected_pending_dispute_id = Some(last_uuid); + + let backend = TestBackend::new(100, 8); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw bottom"); + + let offset_at_bottom = app.disputes_table_state.offset(); + assert!(offset_at_bottom > 0, "must have scrolled for last row"); + + app.selected_pending_dispute_id = Some(eighth_uuid); + terminal + .draw(|f| render_disputes_tab(f, f.area(), &disputes, &mut app)) + .expect("draw up one"); + + assert_eq!( + app.disputes_table_state.offset(), + offset_at_bottom, + "moving up one row should not reset viewport to top" + ); + let buf = terminal.backend().buffer(); + assert!( + !buffer_contains(buf, &first_id[..8]), + "first row must stay scrolled off after ↑ from bottom" + ); } } diff --git a/src/ui/tabs/orders_tab.rs b/src/ui/tabs/orders_tab.rs index cf2cc14..1e0dc19 100644 --- a/src/ui/tabs/orders_tab.rs +++ b/src/ui/tabs/orders_tab.rs @@ -4,21 +4,22 @@ use mostro_core::prelude::*; use ratatui::layout::{Constraint, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Span; -use ratatui::widgets::{ - Block, BorderType, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, - ScrollbarState, Table, -}; +use ratatui::widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table}; use crate::ui::helpers::{ - format_local_timestamp, format_premium, get_filtered_book_orders, selected_book_display_idx, + format_local_timestamp, format_premium, get_filtered_book_orders, render_table_list_scrollbar, + selected_book_display_idx, }; use crate::ui::{apply_kind_color, AppState, BACKGROUND_COLOR, PRIMARY_COLOR}; /// Renders the available orders table, with fewer columns when terminal width is limited. /// -/// Uses a stateful [`Table`] so ↑↓ selection stays in view when the book is taller -/// than the terminal. Selection is resolved by order id against the currency-filtered +/// Uses a persistent [`TableState`] (`app.orders_table_state`) so ↑↓ selection stays +/// in view when the book is taller than the terminal (viewport offset survives +/// frames). Selection is resolved by order id against the currency-filtered /// projection (`helpers/order_selection.rs`) so highlight and Enter stay aligned. +/// Vertical scrollbar uses [`render_table_list_scrollbar`] (offset + data-row track). +/// On short terminals (`height < 4`) the header is dropped so a data row remains. pub fn render_orders_tab( f: &mut ratatui::Frame, area: Rect, @@ -87,6 +88,9 @@ pub fn render_orders_tab( selected_book_display_idx(app.selected_order_id, &filtered).unwrap_or(0); let compact = area.width < 100; + // Drop the header when height < 4 so at least one data row stays visible + // (same short-terminal rule as Disputes Pending). + let show_header = area.height >= 4; let header_labels = if compact { vec!["📈 Kind", "💵 Fiat Amt", "± Premium", "💳 Payment"] } else { @@ -102,11 +106,6 @@ pub fn render_orders_tab( "📅 Created", ] }; - let header_cells = header_labels - .into_iter() - .map(|label| Cell::from(label).style(Style::default().add_modifier(Modifier::BOLD))) - .collect::>(); - let header = Row::new(header_cells); let rows: Vec = filtered .iter() @@ -206,8 +205,7 @@ pub fn render_orders_tab( }; let row_count = rows.len(); - let table = Table::new(rows, widths) - .header(header) + let mut table = Table::new(rows, widths) .row_highlight_style(Style::default().bg(PRIMARY_COLOR).fg(Color::Black)) .block( Block::default() @@ -218,19 +216,27 @@ pub fn render_orders_tab( .style(Style::default().bg(BACKGROUND_COLOR)), ); + if show_header { + let header_cells = header_labels + .into_iter() + .map(|label| Cell::from(label).style(Style::default().add_modifier(Modifier::BOLD))) + .collect::>(); + table = table.header(Row::new(header_cells)); + } + app.orders_table_state.select(Some(display_selected_idx)); f.render_stateful_widget(table, area, &mut app.orders_table_state); - // Header + borders consume 3 rows; remaining height is the scrollable body. - let visible_rows = area.height.saturating_sub(3) as usize; - if row_count > visible_rows && visible_rows > 0 { - let mut scrollbar_state = ScrollbarState::new(row_count).position(display_selected_idx); - f.render_stateful_widget( - Scrollbar::default().orientation(ScrollbarOrientation::VerticalRight), - area, - &mut scrollbar_state, - ); - } + let header_rows = u16::from(show_header); + let visible_rows = area.height.saturating_sub(2 + header_rows) as usize; + render_table_list_scrollbar( + f, + area, + row_count, + visible_rows, + header_rows, + app.orders_table_state.offset(), + ); } fn premium_cell(premium: i64) -> Cell<'static> { @@ -415,4 +421,105 @@ mod tests { assert_eq!(selected.id, Some(eur_id)); assert_eq!(selected.payment_method, "PAY-EUR"); } + + #[test] + fn scrollbar_preserves_borders_and_header_when_scrolled() { + let mut book = Vec::new(); + let mut last_id = Uuid::nil(); + for i in 0..40 { + let o = sample_order(&format!("PAY-{i:02}"), 0); + last_id = o.id.unwrap(); + book.push(o); + } + let orders = Arc::new(Mutex::new(book)); + let mut app = AppState::new(UserRole::User); + app.selected_order_id = Some(last_id); + + let backend = TestBackend::new(130, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) + .unwrap(); + + let buf = terminal.backend().buffer(); + let right = buf.area.width - 1; + assert_eq!(buf[(right, 0)].symbol(), "╮", "top-right corner intact"); + assert_eq!( + buf[(right, buf.area.height - 1)].symbol(), + "╯", + "bottom-right corner intact" + ); + assert_eq!( + buf[(right, 1)].symbol(), + "│", + "header row border must not be overwritten by the scrollbar" + ); + assert!( + buffer_contains(buf, "Premium"), + "header must still render while scrolled" + ); + } + + /// Selecting the last order must park the scrollbar thumb against the end + /// cap (`▼`), with no empty track (`║`) between thumb and bottom. + #[test] + fn scrollbar_thumb_reaches_track_bottom_on_last_row() { + let mut book = Vec::new(); + let mut last_id = Uuid::nil(); + for i in 0..40 { + let o = sample_order(&format!("PAY-{i:02}"), 0); + last_id = o.id.unwrap(); + book.push(o); + } + let orders = Arc::new(Mutex::new(book)); + let mut app = AppState::new(UserRole::User); + app.selected_order_id = Some(last_id); + + // height 10 → borders+header leave 7 data rows; track y=2..8 with ▲…▼ + let backend = TestBackend::new(130, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) + .unwrap(); + + let buf = terminal.backend().buffer(); + let right = buf.area.width - 1; + let end_cap_y = buf.area.height - 2; // ▼ just above bottom border + let above_end = end_cap_y - 1; + assert_eq!( + buf[(right, end_cap_y)].symbol(), + "▼", + "scrollbar end cap must sit on the last track row" + ); + assert_eq!( + buf[(right, above_end)].symbol(), + "█", + "thumb must reach the cell above ▼ when the last order is selected" + ); + } + + #[test] + fn short_area_drops_header_but_shows_selected_row() { + let o = sample_order("PAY-SHORT", 0); + let id = o.id.unwrap(); + let orders = Arc::new(Mutex::new(vec![o])); + let mut app = AppState::new(UserRole::User); + app.selected_order_id = Some(id); + + let backend = TestBackend::new(130, 3); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render_orders_tab(f, f.area(), &orders, &mut app)) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!( + !buffer_contains(buf, "Premium"), + "header should be dropped when the area is too short" + ); + assert!( + buffer_contains(buf, "PAY-SHORT"), + "selected order row must be visible without the header" + ); + } }