Readable Fullscreen Modals for Stats, Standings/Bracket, and History
This commit is contained in:
parent
39ea6c0c0a
commit
dcdd06c8c3
5 changed files with 179 additions and 45 deletions
11
DEVLOG.md
11
DEVLOG.md
|
|
@ -67,3 +67,14 @@
|
||||||
- Added live league standings snapshots after each fixture.
|
- Added live league standings snapshots after each fixture.
|
||||||
- Added ASCII knockout tree bracket snapshots and updates after each semifinal/final.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
77
src/app.rs
77
src/app.rs
|
|
@ -54,10 +54,29 @@ pub struct App {
|
||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub show_detail: bool,
|
pub show_detail: bool,
|
||||||
pub create_draft: Option<CreateDraft>,
|
pub create_draft: Option<CreateDraft>,
|
||||||
|
pub overlay_modal: Option<OverlayModal>,
|
||||||
|
pub overlay_scroll: usize,
|
||||||
pub status_line: String,
|
pub status_line: String,
|
||||||
next_id: usize,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TeamSlotDraft {
|
pub struct TeamSlotDraft {
|
||||||
pub is_cpu: bool,
|
pub is_cpu: bool,
|
||||||
|
|
@ -106,6 +125,8 @@ impl App {
|
||||||
selected: 0,
|
selected: 0,
|
||||||
show_detail: false,
|
show_detail: false,
|
||||||
create_draft: None,
|
create_draft: None,
|
||||||
|
overlay_modal: None,
|
||||||
|
overlay_scroll: 0,
|
||||||
status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()),
|
status_line: format!("Ready. Seed={base_seed}, Speed={}", speed.label()),
|
||||||
next_id: 0,
|
next_id: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +142,28 @@ impl App {
|
||||||
self.status_line = "Create canceled".to_string();
|
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 {
|
pub fn next_instance_seed_preview(&self) -> u64 {
|
||||||
derive_seed(self.base_seed, self.next_id as u64 + 1)
|
derive_seed(self.base_seed, self.next_id as u64 + 1)
|
||||||
}
|
}
|
||||||
|
|
@ -289,6 +332,27 @@ impl App {
|
||||||
self.instances.get(self.selected)
|
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) {
|
pub fn select_prev(&mut self) {
|
||||||
if self.instances.is_empty() {
|
if self.instances.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -348,6 +412,16 @@ pub fn run_tui(mut app: App) -> io::Result<()> {
|
||||||
continue;
|
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 {
|
match key.code {
|
||||||
KeyCode::Char('q') => running = false,
|
KeyCode::Char('q') => running = false,
|
||||||
KeyCode::Up | KeyCode::Char('k') => app.select_prev(),
|
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::Char('d') => app.delete_selected(),
|
||||||
KeyCode::Enter | KeyCode::Char('v') => app.show_detail = !app.show_detail,
|
KeyCode::Enter | KeyCode::Char('v') => app.show_detail = !app.show_detail,
|
||||||
KeyCode::Char('e') => app.export_selected(),
|
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('1') => app.cycle_speed(Speed::X1),
|
||||||
KeyCode::Char('2') => app.cycle_speed(Speed::X2),
|
KeyCode::Char('2') => app.cycle_speed(Speed::X2),
|
||||||
KeyCode::Char('4') => app.cycle_speed(Speed::X4),
|
KeyCode::Char('4') => app.cycle_speed(Speed::X4),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
use ratatui::prelude::*;
|
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::app::App;
|
||||||
use crate::instance::SimStatus;
|
use crate::instance::SimStatus;
|
||||||
|
|
@ -17,8 +17,8 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Min(6),
|
Constraint::Min(8),
|
||||||
Constraint::Length(12),
|
Constraint::Length(2),
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
|
|
@ -69,41 +69,7 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
);
|
);
|
||||||
f.render_widget(side, middle[1]);
|
f.render_widget(side, middle[1]);
|
||||||
|
|
||||||
let bottom = Layout::default()
|
let help = Paragraph::new("Open readable panels: t=Stats, g=Standings/Bracket, h=History")
|
||||||
.direction(Direction::Horizontal)
|
.block(Block::default().borders(Borders::ALL).title("Panels"));
|
||||||
.constraints([
|
f.render_widget(help, chunks[2]);
|
||||||
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::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.block(Block::default().title("Stats").borders(Borders::ALL));
|
|
||||||
let comp = List::new(
|
|
||||||
inst.competition_lines
|
|
||||||
.iter()
|
|
||||||
.map(|s| ListItem::new(s.clone()))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.block(Block::default().title("Competition").borders(Borders::ALL));
|
|
||||||
let hist = List::new(
|
|
||||||
inst.history_lines
|
|
||||||
.iter()
|
|
||||||
.map(|s| ListItem::new(s.clone()))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) {
|
||||||
])
|
])
|
||||||
.split(f.area());
|
.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))
|
.block(Block::default().title("Dashboard").borders(Borders::ALL))
|
||||||
.style(Style::default().fg(Color::Cyan));
|
.style(Style::default().fg(Color::Cyan));
|
||||||
f.render_widget(header, areas[0]);
|
f.render_widget(header, areas[0]);
|
||||||
|
|
@ -31,7 +31,7 @@ pub fn draw(f: &mut Frame<'_>, app: &App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let footer = Paragraph::new(format!(
|
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.status_line,
|
||||||
app.speed.label()
|
app.speed.label()
|
||||||
))
|
))
|
||||||
|
|
@ -40,6 +40,10 @@ pub fn draw(f: &mut Frame<'_>, app: &App) {
|
||||||
f.render_widget(footer, areas[2]);
|
f.render_widget(footer, areas[2]);
|
||||||
|
|
||||||
if let Some(draft) = &app.create_draft {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||||
|
|
||||||
use crate::app::{App, CreateDraft};
|
use crate::app::{App, CreateDraft, OverlayModal};
|
||||||
use crate::data::TEAMS;
|
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);
|
let popup = centered_rect(70, 70, area);
|
||||||
f.render_widget(Clear, popup);
|
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]);
|
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<String> = 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<ListItem> = 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 {
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
let vertical = Layout::default()
|
let vertical = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue