From dcdd06c8c3f9a76e7d6afdc823799462c462dceb Mon Sep 17 00:00:00 2001 From: markmental Date: Tue, 10 Feb 2026 18:38:32 -0500 Subject: [PATCH] Readable Fullscreen Modals for Stats, Standings/Bracket, and History --- DEVLOG.md | 11 +++++++ src/app.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++++ src/ui/detail.rs | 46 ++++------------------------ src/ui/mod.rs | 10 ++++-- src/ui/modal.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 179 insertions(+), 45 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index c370e3a..7f5a802 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -67,3 +67,14 @@ - Added live league standings snapshots after each fixture. - Added ASCII knockout tree bracket snapshots and updates after each semifinal/final. - Tuned detail view layout to allocate more space to stats/competition/history panels. + +## 2026-02-10 - Fullscreen info modals for readability + +### Scope completed +- Added dedicated fullscreen overlay modals for: + - Stats (`t`) + - Standings/Bracket (`g`) + - History (`h`) +- Added modal scrolling controls (`j/k` or up/down) and close controls (`Esc` or `q`). +- Simplified detail view to focus on scoreboard, logs, and instance info. +- Added detail-panel hint bar to direct users to the new dedicated modals. diff --git a/src/app.rs b/src/app.rs index 1c86241..d1071f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -54,10 +54,29 @@ pub struct App { pub selected: usize, pub show_detail: bool, pub create_draft: Option, + pub overlay_modal: Option, + pub overlay_scroll: usize, pub status_line: String, next_id: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlayModal { + Stats, + Competition, + History, +} + +impl OverlayModal { + pub fn title(self) -> &'static str { + match self { + OverlayModal::Stats => "Stats", + OverlayModal::Competition => "Standings / Bracket", + OverlayModal::History => "History", + } + } +} + #[derive(Debug, Clone)] pub struct TeamSlotDraft { pub is_cpu: bool, @@ -106,6 +125,8 @@ impl App { selected: 0, show_detail: false, create_draft: None, + overlay_modal: None, + overlay_scroll: 0, status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()), next_id: 0, } @@ -121,6 +142,28 @@ impl App { self.status_line = "Create canceled".to_string(); } + pub fn open_overlay_modal(&mut self, modal: OverlayModal) { + self.show_detail = true; + self.overlay_modal = Some(modal); + self.overlay_scroll = 0; + self.status_line = format!("Opened {} modal", modal.title()); + } + + pub fn close_overlay_modal(&mut self) { + self.overlay_modal = None; + self.overlay_scroll = 0; + self.status_line = "Closed modal".to_string(); + } + + pub fn overlay_scroll_up(&mut self) { + self.overlay_scroll = self.overlay_scroll.saturating_sub(1); + } + + pub fn overlay_scroll_down(&mut self) { + let max_scroll = self.active_overlay_line_count().saturating_sub(1); + self.overlay_scroll = (self.overlay_scroll + 1).min(max_scroll); + } + pub fn next_instance_seed_preview(&self) -> u64 { derive_seed(self.base_seed, self.next_id as u64 + 1) } @@ -289,6 +332,27 @@ impl App { self.instances.get(self.selected) } + fn active_overlay_line_count(&self) -> usize { + let Some(modal) = self.overlay_modal else { + return 0; + }; + let Some(inst) = self.selected_instance() else { + return 0; + }; + + match modal { + OverlayModal::Stats => inst.stats_lines.len().max(1), + OverlayModal::Competition => { + if inst.competition_lines.is_empty() { + 1 + } else { + inst.competition_lines.len() + } + } + OverlayModal::History => inst.history_lines.len().max(1), + } + } + pub fn select_prev(&mut self) { if self.instances.is_empty() { return; @@ -348,6 +412,16 @@ pub fn run_tui(mut app: App) -> io::Result<()> { continue; } + if app.overlay_modal.is_some() { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.close_overlay_modal(), + KeyCode::Up | KeyCode::Char('k') => app.overlay_scroll_up(), + KeyCode::Down | KeyCode::Char('j') => app.overlay_scroll_down(), + _ => {} + } + continue; + } + match key.code { KeyCode::Char('q') => running = false, KeyCode::Up | KeyCode::Char('k') => app.select_prev(), @@ -360,6 +434,9 @@ pub fn run_tui(mut app: App) -> io::Result<()> { KeyCode::Char('d') => app.delete_selected(), KeyCode::Enter | KeyCode::Char('v') => app.show_detail = !app.show_detail, KeyCode::Char('e') => app.export_selected(), + KeyCode::Char('t') => app.open_overlay_modal(OverlayModal::Stats), + KeyCode::Char('g') => app.open_overlay_modal(OverlayModal::Competition), + KeyCode::Char('h') => app.open_overlay_modal(OverlayModal::History), KeyCode::Char('1') => app.cycle_speed(Speed::X1), KeyCode::Char('2') => app.cycle_speed(Speed::X2), KeyCode::Char('4') => app.cycle_speed(Speed::X4), diff --git a/src/ui/detail.rs b/src/ui/detail.rs index 21bd9d1..e696c1c 100644 --- a/src/ui/detail.rs +++ b/src/ui/detail.rs @@ -1,6 +1,6 @@ use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use crate::app::App; use crate::instance::SimStatus; @@ -17,8 +17,8 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(6), - Constraint::Length(12), + Constraint::Min(8), + Constraint::Length(2), ]) .split(area); @@ -69,41 +69,7 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) { ); f.render_widget(side, middle[1]); - let bottom = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(34), - Constraint::Percentage(33), - Constraint::Percentage(33), - ]) - .split(chunks[2]); - - let stats = List::new( - inst.stats_lines - .iter() - .map(|s| ListItem::new(s.clone())) - .collect::>(), - ) - .block(Block::default().title("Stats").borders(Borders::ALL)); - let comp = List::new( - inst.competition_lines - .iter() - .map(|s| ListItem::new(s.clone())) - .collect::>(), - ) - .block(Block::default().title("Competition").borders(Borders::ALL)); - let hist = List::new( - inst.history_lines - .iter() - .map(|s| ListItem::new(s.clone())) - .collect::>(), - ) - .block(Block::default().title("History").borders(Borders::ALL)); - - f.render_widget(stats, bottom[0]); - f.render_widget(comp, bottom[1]); - f.render_widget(hist, bottom[2]); - - let tabs = Tabs::new(vec!["Dashboard", "Detail"]).select(1); - let _ = tabs; + let help = Paragraph::new("Open readable panels: t=Stats, g=Standings/Bracket, h=History") + .block(Block::default().borders(Borders::ALL).title("Panels")); + f.render_widget(help, chunks[2]); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 78145d5..598591e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,7 +19,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) { ]) .split(f.area()); - let header = Paragraph::new("MentalNet SoccerCloud | n/l/o create | s start | c clone | d delete | e export | v detail | q quit") + let header = Paragraph::new("MentalNet SoccerCloud | n/l/o create | s start | c clone | d delete | e export | v detail | t stats | g standings | h history | q quit") .block(Block::default().title("Dashboard").borders(Borders::ALL)) .style(Style::default().fg(Color::Cyan)); f.render_widget(header, areas[0]); @@ -31,7 +31,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) { } let footer = Paragraph::new(format!( - "{} | speed={} (1/2/4/0) | modal: m=manual p=cpu [ ] team Enter=create Esc=cancel", + "{} | speed={} (1/2/4/0) | create modal: m=manual p=cpu [ ] team Enter=create Esc=cancel | view modal: j/k scroll Esc/q close", app.status_line, app.speed.label() )) @@ -40,6 +40,10 @@ pub fn draw(f: &mut Frame<'_>, app: &App) { f.render_widget(footer, areas[2]); if let Some(draft) = &app.create_draft { - modal::render(f, f.area(), app, draft); + modal::render_create(f, f.area(), app, draft); + } + + if let Some(kind) = app.overlay_modal { + modal::render_overlay(f, f.area(), app, kind); } } diff --git a/src/ui/modal.rs b/src/ui/modal.rs index e750605..5a6bee3 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -2,10 +2,10 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; -use crate::app::{App, CreateDraft}; +use crate::app::{App, CreateDraft, OverlayModal}; use crate::data::TEAMS; -pub fn render(f: &mut Frame<'_>, area: Rect, app: &App, draft: &CreateDraft) { +pub fn render_create(f: &mut Frame<'_>, area: Rect, app: &App, draft: &CreateDraft) { let popup = centered_rect(70, 70, area); f.render_widget(Clear, popup); @@ -59,6 +59,82 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App, draft: &CreateDraft) { f.render_widget(help, inner[2]); } +pub fn render_overlay(f: &mut Frame<'_>, area: Rect, app: &App, modal: OverlayModal) { + let popup = centered_rect(90, 86, area); + f.render_widget(Clear, popup); + + let frame = Block::default() + .title(modal.title()) + .borders(Borders::ALL) + .style(Style::default().fg(Color::White).bg(Color::Black)); + f.render_widget(frame, popup); + + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), + Constraint::Min(4), + Constraint::Length(1), + ]) + .split(popup); + + let Some(inst) = app.selected_instance() else { + f.render_widget(Paragraph::new("No selected instance"), inner[1]); + return; + }; + + let all_lines: Vec = match modal { + OverlayModal::Stats => { + if inst.stats_lines.is_empty() { + vec!["No stats available yet. Start a simulation first.".to_string()] + } else { + inst.stats_lines.clone() + } + } + OverlayModal::Competition => { + if inst.competition_lines.is_empty() { + vec!["No standings/bracket available for this simulation yet.".to_string()] + } else { + inst.competition_lines.clone() + } + } + OverlayModal::History => { + if inst.history_lines.is_empty() { + vec!["No history entries yet.".to_string()] + } else { + inst.history_lines.clone() + } + } + }; + + let viewport = (inner[1].height as usize).saturating_sub(2).max(1); + let max_start = all_lines.len().saturating_sub(viewport); + let start = app.overlay_scroll.min(max_start); + let end = (start + viewport).min(all_lines.len()); + let visible = &all_lines[start..end]; + let items: Vec = visible + .iter() + .map(|line| ListItem::new(line.clone())) + .collect(); + + let top = Paragraph::new(format!( + "sim-{} [{}] lines {}-{} / {}", + inst.id, + inst.sim_type.as_str(), + start + 1, + end, + all_lines.len() + )); + f.render_widget(top, inner[0]); + + let list = List::new(items).block(Block::default().borders(Borders::ALL)); + f.render_widget(list, inner[1]); + + let help = Paragraph::new("j/k or up/down to scroll, Esc or q to close"); + f.render_widget(help, inner[2]); +} + fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let vertical = Layout::default() .direction(Direction::Vertical)