Implement Phase 4: async TUI with Dashboard and Gameday screens

Replace blocking event loop with tokio::select! over EventStream, mpsc
channel, and tick interval. Add message bus architecture with AppMessage
enum for background task results. Implement Dashboard with roster summary
cards and API sync, and Gameday with cascading team/pitcher selectors,
matchup table, and 9-slot lineup management with save/load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-28 07:09:25 -06:00
parent ebe4196bfc
commit 6d2b11a797
10 changed files with 1737 additions and 61 deletions

29
rust/Cargo.lock generated
View File

@ -667,6 +667,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@ -711,6 +726,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
@ -729,8 +755,10 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@ -2181,6 +2209,7 @@ dependencies = [
"crossterm 0.28.1",
"csv",
"figment",
"futures",
"ratatui",
"regex",
"reqwest",

View File

@ -42,5 +42,8 @@ csv = "1"
# Hashing
sha2 = "0.10"
# Async streams
futures = "0.3"
# Regex
regex = "1"

View File

@ -1,76 +1,319 @@
use crossterm::event::KeyEvent;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Layout},
text::Text,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use sqlx::sqlite::SqlitePool;
use std::time::Instant;
use tokio::sync::mpsc;
use crate::calc::matchup::MatchupResult;
use crate::config::Settings;
use crate::db::models::{BatterCard, Lineup, Player, Roster, Team};
use crate::screens::dashboard::DashboardState;
use crate::screens::gameday::GamedayState;
// =============================================================================
// Messages
// =============================================================================
#[derive(Debug)]
pub enum AppMessage {
// Dashboard
RosterLoaded(Roster),
SyncStarted,
SyncComplete(Result<crate::api::sync::SyncResult, anyhow::Error>),
// Gameday — init
CacheReady,
CacheError(String),
TeamsLoaded(Vec<Team>),
MyBattersLoaded(Vec<(Player, Option<BatterCard>)>),
LineupsLoaded(Vec<Lineup>),
// Gameday — on team select
PitchersLoaded(Vec<Player>),
// Gameday — on pitcher select
MatchupsCalculated(Vec<MatchupResult>),
// Gameday — lineup
LineupSaved(String),
LineupSaveError(String),
// General
Notify(String, NotifyLevel),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Dashboard,
pub enum NotifyLevel {
Info,
Success,
Error,
}
// =============================================================================
// Notification
// =============================================================================
pub struct Notification {
pub message: String,
pub level: NotifyLevel,
pub created_at: Instant,
pub duration_ms: u64,
}
impl Notification {
pub fn new(message: String, level: NotifyLevel) -> Self {
Self {
message,
level,
created_at: Instant::now(),
duration_ms: 3000,
}
}
pub fn is_expired(&self) -> bool {
self.created_at.elapsed().as_millis() > self.duration_ms as u128
}
}
// =============================================================================
// Screen enum
// =============================================================================
/// Stub screen identifier for screens not yet implemented.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StubScreen {
Roster,
Matchup,
Lineup,
Gameday,
Settings,
}
impl StubScreen {
pub fn label(&self) -> &'static str {
match self {
StubScreen::Roster => "Roster",
StubScreen::Matchup => "Matchup",
StubScreen::Lineup => "Lineup",
StubScreen::Settings => "Settings",
}
}
}
pub enum ActiveScreen {
Dashboard(DashboardState),
Gameday(GamedayState),
Stub(StubScreen),
}
impl ActiveScreen {
pub fn label(&self) -> &'static str {
match self {
ActiveScreen::Dashboard(_) => "Dashboard",
ActiveScreen::Gameday(_) => "Gameday",
ActiveScreen::Stub(s) => s.label(),
}
}
}
// =============================================================================
// App
// =============================================================================
pub struct App {
pub current_screen: Screen,
pub screen: ActiveScreen,
pub pool: SqlitePool,
pub settings: Settings,
pub tx: mpsc::UnboundedSender<AppMessage>,
pub notification: Option<Notification>,
pub tick_count: u64,
}
impl App {
pub fn new(settings: Settings, pool: SqlitePool) -> Self {
pub fn new(
settings: Settings,
pool: SqlitePool,
tx: mpsc::UnboundedSender<AppMessage>,
) -> Self {
let screen = ActiveScreen::Dashboard(DashboardState::new(
settings.team.abbrev.clone(),
settings.team.season as i64,
));
Self {
current_screen: Screen::Dashboard,
screen,
pool,
settings,
tx,
notification: None,
tick_count: 0,
}
}
/// Fire initial data load for the starting screen.
pub fn on_mount(&mut self) {
if let ActiveScreen::Dashboard(s) = &mut self.screen {
s.mount(&self.pool, &self.tx);
}
}
pub fn on_tick(&mut self) {
self.tick_count = self.tick_count.wrapping_add(1);
if let Some(n) = &self.notification {
if n.is_expired() {
self.notification = None;
}
}
}
/// Returns false when a text input or selector popup is active.
pub fn should_quit_on_q(&self) -> bool {
match &self.screen {
ActiveScreen::Gameday(s) => !s.is_input_captured(),
_ => true,
}
}
fn is_input_captured(&self) -> bool {
match &self.screen {
ActiveScreen::Gameday(s) => s.is_input_captured(),
_ => false,
}
}
pub fn handle_key(&mut self, key: KeyEvent) {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('1') => self.current_screen = Screen::Dashboard,
KeyCode::Char('2') => self.current_screen = Screen::Roster,
KeyCode::Char('3') => self.current_screen = Screen::Matchup,
KeyCode::Char('4') => self.current_screen = Screen::Lineup,
KeyCode::Char('5') => self.current_screen = Screen::Gameday,
KeyCode::Char('6') => self.current_screen = Screen::Settings,
_ => {}
// Global navigation (only when not capturing input)
if !self.is_input_captured() {
match key.code {
KeyCode::Char('d') => {
if !matches!(&self.screen, ActiveScreen::Dashboard(_)) {
self.switch_to_dashboard();
return;
}
}
KeyCode::Char('g') => {
if !matches!(&self.screen, ActiveScreen::Gameday(_)) {
self.switch_to_gameday();
return;
}
}
_ => {}
}
}
// Delegate to current screen
match &mut self.screen {
ActiveScreen::Dashboard(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx),
ActiveScreen::Gameday(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx),
ActiveScreen::Stub(_) => {}
}
}
pub fn handle_message(&mut self, msg: AppMessage) {
match msg {
AppMessage::Notify(text, level) => {
self.notification = Some(Notification::new(text, level));
}
_ => match &mut self.screen {
ActiveScreen::Dashboard(s) => s.handle_message(msg),
ActiveScreen::Gameday(s) => s.handle_message(msg),
ActiveScreen::Stub(_) => {}
},
}
}
fn switch_to_dashboard(&mut self) {
let mut state = DashboardState::new(
self.settings.team.abbrev.clone(),
self.settings.team.season as i64,
);
state.mount(&self.pool, &self.tx);
self.screen = ActiveScreen::Dashboard(state);
}
fn switch_to_gameday(&mut self) {
let mut state = GamedayState::new(
self.settings.team.abbrev.clone(),
self.settings.team.season as i64,
);
state.mount(&self.pool, &self.tx);
self.screen = ActiveScreen::Gameday(state);
}
pub fn render(&self, frame: &mut Frame) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(1), // nav bar
Constraint::Min(0), // screen content
Constraint::Length(1), // status bar
])
.split(area);
let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
self.render_nav_bar(frame, chunks[0]);
// Navigation bar
let nav = Paragraph::new(Text::raw(
" [1] Dashboard [2] Roster [3] Matchup [4] Lineup [5] Gameday [6] Settings [q] Quit",
))
.block(Block::default().borders(Borders::ALL).title("SBA Scout"));
frame.render_widget(nav, chunks[0]);
match &self.screen {
ActiveScreen::Dashboard(s) => s.render(frame, chunks[1], self.tick_count),
ActiveScreen::Gameday(s) => s.render(frame, chunks[1], self.tick_count),
ActiveScreen::Stub(scr) => render_stub(frame, chunks[1], scr),
}
// Screen content
let content = match self.current_screen {
Screen::Dashboard => "Dashboard - Press a number key to navigate",
Screen::Roster => "Roster Screen (not yet implemented)",
Screen::Matchup => "Matchup Screen (not yet implemented)",
Screen::Lineup => "Lineup Screen (not yet implemented)",
Screen::Gameday => "Gameday Screen (not yet implemented)",
Screen::Settings => "Settings Screen (not yet implemented)",
self.render_status_bar(frame, chunks[2]);
}
fn render_nav_bar(&self, frame: &mut Frame, area: Rect) {
let active = self.screen.label();
let items = [
("d", "Dashboard"),
("g", "Gameday"),
];
let spans: Vec<Span> = items
.iter()
.flat_map(|(key, label)| {
let is_active = *label == active;
let style = if is_active {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
vec![
Span::styled(format!(" [{}] ", key), Style::default().fg(Color::DarkGray)),
Span::styled(*label, style),
]
})
.collect();
let nav = Paragraph::new(Line::from(spans));
frame.render_widget(nav, area);
}
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let (text, style) = match &self.notification {
Some(n) => {
let color = match n.level {
NotifyLevel::Info => Color::White,
NotifyLevel::Success => Color::Green,
NotifyLevel::Error => Color::Red,
};
(n.message.as_str(), Style::default().fg(color))
}
None => ("", Style::default()),
};
let body = Paragraph::new(Text::raw(content))
.block(Block::default().borders(Borders::ALL).title(format!("{:?}", self.current_screen)));
frame.render_widget(body, chunks[1]);
let bar = Paragraph::new(text).style(style);
frame.render_widget(bar, area);
}
}
fn render_stub(frame: &mut Frame, area: Rect, screen: &StubScreen) {
let text = format!("{} (not yet implemented)", screen.label());
let widget = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(screen.label()))
.alignment(Alignment::Center);
frame.render_widget(widget, area);
}

View File

@ -6,7 +6,7 @@ use figment::{
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Settings {
pub db_path: PathBuf,
pub api: ApiSettings,
@ -15,26 +15,26 @@ pub struct Settings {
pub rating_weights: RatingWeights,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApiSettings {
pub base_url: String,
pub api_key: String,
pub timeout: u64,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TeamSettings {
pub abbrev: String,
pub season: i32,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UiSettings {
pub theme: String,
pub refresh_interval: u64,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RatingWeights {
pub hit: f64,
pub on_base: f64,

View File

@ -4,3 +4,4 @@ pub mod calc;
pub mod config;
pub mod db;
pub mod screens;
pub mod widgets;

View File

@ -1,16 +1,17 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode};
use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers};
use futures::StreamExt;
use ratatui::DefaultTerminal;
use sqlx::sqlite::SqlitePool;
use tokio::sync::mpsc;
use tokio::time::{interval, Duration};
use sba_scout::app::App;
use sba_scout::config::{self, Settings};
use sba_scout::app::{App, AppMessage};
use sba_scout::config;
use sba_scout::db;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let settings = match config::load_settings() {
Ok(s) => s,
Err(e) => {
@ -32,17 +33,48 @@ async fn main() -> Result<()> {
result
}
async fn run(terminal: &mut DefaultTerminal, settings: Settings, pool: SqlitePool) -> Result<()> {
let mut app = App::new(settings, pool);
async fn run(terminal: &mut DefaultTerminal, settings: config::Settings, pool: SqlitePool) -> Result<()> {
let (tx, mut rx) = mpsc::unbounded_channel::<AppMessage>();
let mut app = App::new(settings, pool, tx);
app.on_mount();
let mut event_stream = EventStream::new();
let mut tick = interval(Duration::from_millis(250));
loop {
terminal.draw(|frame| app.render(frame))?;
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('q') {
break;
tokio::select! {
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(Event::Key(key))) => {
if key.code == KeyCode::Char('q')
&& key.modifiers == KeyModifiers::NONE
&& app.should_quit_on_q()
{
break;
}
app.handle_key(key);
}
Some(Ok(Event::Resize(_, _))) => {
// ratatui handles resize on next draw
}
Some(Err(_)) => {}
None => break,
_ => {}
}
}
maybe_msg = rx.recv() => {
if let Some(msg) = maybe_msg {
app.handle_message(msg);
}
}
_ = tick.tick() => {
app.on_tick();
}
app.handle_key(key);
}
}

View File

@ -1,6 +1,194 @@
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use sqlx::sqlite::SqlitePool;
use tokio::sync::mpsc;
pub fn render(frame: &mut Frame, area: Rect) {
let widget = Paragraph::new("Dashboard");
frame.render_widget(widget, area);
use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncState {
Never,
Syncing,
Success,
Error,
}
pub struct DashboardState {
pub team_abbrev: String,
pub season: i64,
pub majors_count: usize,
pub minors_count: usize,
pub il_count: usize,
pub swar_total: f64,
pub sync_state: SyncState,
pub sync_message: String,
}
impl DashboardState {
pub fn new(team_abbrev: String, season: i64) -> Self {
Self {
team_abbrev,
season,
majors_count: 0,
minors_count: 0,
il_count: 0,
swar_total: 0.0,
sync_state: SyncState::Never,
sync_message: String::new(),
}
}
/// Fire async roster load on mount.
pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
let pool = pool.clone();
let tx = tx.clone();
let abbrev = self.team_abbrev.clone();
let season = self.season;
tokio::spawn(async move {
match crate::db::queries::get_my_roster(&pool, &abbrev, season).await {
Ok(roster) => {
let _ = tx.send(AppMessage::RosterLoaded(roster));
}
Err(e) => {
let _ = tx.send(AppMessage::Notify(
format!("Failed to load roster: {e}"),
NotifyLevel::Error,
));
}
}
});
}
pub fn handle_key(
&mut self,
key: KeyEvent,
pool: &SqlitePool,
settings: &Settings,
tx: &mpsc::UnboundedSender<AppMessage>,
) {
if let KeyCode::Char('s') = key.code {
self.trigger_sync(pool, settings, tx);
}
}
fn trigger_sync(
&mut self,
pool: &SqlitePool,
settings: &Settings,
tx: &mpsc::UnboundedSender<AppMessage>,
) {
if self.sync_state == SyncState::Syncing {
return;
}
self.sync_state = SyncState::Syncing;
self.sync_message = "Syncing...".to_string();
let pool = pool.clone();
let settings = settings.clone();
let tx = tx.clone();
tokio::spawn(async move {
let _ = tx.send(AppMessage::SyncStarted);
let result = crate::api::sync::sync_all(&pool, &settings).await;
let _ = tx.send(AppMessage::SyncComplete(result));
});
}
pub fn handle_message(&mut self, msg: AppMessage) {
match msg {
AppMessage::RosterLoaded(roster) => {
self.majors_count = roster.majors.len();
self.minors_count = roster.minors.len();
self.il_count = roster.il.len();
self.swar_total = roster.majors.iter().filter_map(|p| p.swar).sum();
}
AppMessage::SyncStarted => {
self.sync_state = SyncState::Syncing;
self.sync_message = "Syncing...".to_string();
}
AppMessage::SyncComplete(result) => match result {
Ok(r) => {
self.sync_state = SyncState::Success;
self.sync_message = format!(
"Synced {} teams, {} players, {} transactions",
r.teams, r.players, r.transactions
);
}
Err(e) => {
self.sync_state = SyncState::Error;
self.sync_message = format!("Sync failed: {e}");
}
},
_ => {}
}
}
pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
let chunks = Layout::vertical([
Constraint::Length(3), // title
Constraint::Length(5), // summary cards
Constraint::Length(3), // sync status
Constraint::Min(0), // key hints
])
.split(area);
// Title
let title = Paragraph::new(format!(
"SBA Scout -- {} -- Season {}",
self.team_abbrev, self.season
))
.block(Block::default().borders(Borders::ALL))
.alignment(Alignment::Center);
frame.render_widget(title, chunks[0]);
// Summary cards
let card_chunks = Layout::horizontal([
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(chunks[1]);
let cards = [
("Majors", format!("{}/26", self.majors_count)),
("Minors", format!("{}/6", self.minors_count)),
("IL", self.il_count.to_string()),
("sWAR", format!("{:.1}", self.swar_total)),
];
for (i, (label, value)) in cards.iter().enumerate() {
let card = Paragraph::new(value.as_str())
.block(Block::default().borders(Borders::ALL).title(*label))
.alignment(Alignment::Center);
frame.render_widget(card, card_chunks[i]);
}
// Sync status
let spinner_chars = ['|', '/', '-', '\\'];
let spinner = spinner_chars[(tick_count as usize / 2) % 4];
let sync_text = match self.sync_state {
SyncState::Never => "[s] Sync data from API".to_string(),
SyncState::Syncing => format!("{spinner} {}", self.sync_message),
SyncState::Success => format!("OK {} [s] Sync again", self.sync_message),
SyncState::Error => format!("ERR {} [s] Retry", self.sync_message),
};
let sync_style = match self.sync_state {
SyncState::Error => Style::default().fg(Color::Red),
SyncState::Success => Style::default().fg(Color::Green),
_ => Style::default(),
};
let sync_widget = Paragraph::new(sync_text)
.style(sync_style)
.block(Block::default().borders(Borders::ALL).title("Sync"));
frame.render_widget(sync_widget, chunks[2]);
// Key hints
let hints = Paragraph::new(" [s] Sync [g] Gameday [q] Quit")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(hints, chunks[3]);
}
}

File diff suppressed because it is too large Load Diff

1
rust/src/widgets/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod selector;

View File

@ -0,0 +1,166 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
/// Event returned from selector key handling.
#[derive(Debug)]
pub enum SelectorEvent {
/// Key was not consumed (not focused or irrelevant).
None,
/// Key was consumed but no selection change.
Consumed,
/// An item was selected — contains the index.
Selected(usize),
}
/// A focusable popup-list selector widget.
///
/// Closed: renders as a single-line display showing the selected item label.
/// Open: renders as a bordered List overlay showing all items.
pub struct SelectorWidget<T: Clone> {
pub title: String,
pub items: Vec<(String, T)>,
pub selected_idx: Option<usize>,
pub highlighted_idx: usize,
pub is_open: bool,
pub is_focused: bool,
}
impl<T: Clone> SelectorWidget<T> {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
items: Vec::new(),
selected_idx: None,
highlighted_idx: 0,
is_open: false,
is_focused: false,
}
}
pub fn set_items(&mut self, items: Vec<(String, T)>) {
self.items = items;
self.selected_idx = None;
self.highlighted_idx = 0;
}
pub fn selected_value(&self) -> Option<&T> {
self.selected_idx
.and_then(|i| self.items.get(i).map(|(_, v)| v))
}
pub fn selected_label(&self) -> &str {
self.selected_idx
.and_then(|i| self.items.get(i))
.map(|(label, _)| label.as_str())
.unwrap_or("-- None --")
}
/// Handle a key event. Returns a SelectorEvent indicating what happened.
pub fn handle_key(&mut self, key: KeyEvent) -> SelectorEvent {
if !self.is_focused {
return SelectorEvent::None;
}
if !self.is_open {
match key.code {
KeyCode::Enter | KeyCode::Char(' ') => {
self.is_open = true;
self.highlighted_idx = self.selected_idx.unwrap_or(0);
return SelectorEvent::Consumed;
}
_ => return SelectorEvent::None,
}
}
// Popup is open
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if !self.items.is_empty() {
self.highlighted_idx = (self.highlighted_idx + 1) % self.items.len();
}
SelectorEvent::Consumed
}
KeyCode::Char('k') | KeyCode::Up => {
if !self.items.is_empty() {
self.highlighted_idx = self
.highlighted_idx
.checked_sub(1)
.unwrap_or(self.items.len().saturating_sub(1));
}
SelectorEvent::Consumed
}
KeyCode::Enter => {
if !self.items.is_empty() {
self.selected_idx = Some(self.highlighted_idx);
self.is_open = false;
return SelectorEvent::Selected(self.highlighted_idx);
}
SelectorEvent::Consumed
}
KeyCode::Esc => {
self.is_open = false;
SelectorEvent::Consumed
}
_ => SelectorEvent::Consumed, // consume all keys when open
}
}
/// Render the closed (single-line) view into `area`.
pub fn render_closed(&self, frame: &mut Frame, area: Rect) {
let style = if self.is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let label = format!("{}: {}", self.title, self.selected_label());
let widget = Paragraph::new(label)
.style(style)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(widget, area);
}
/// Render the popup overlay. Call AFTER rendering main content.
/// `anchor` is the area the popup appears below.
pub fn render_popup(&self, frame: &mut Frame, anchor: Rect) {
if !self.is_open {
return;
}
let popup_height = (self.items.len() as u16 + 2).min(20);
let popup_area = Rect {
x: anchor.x,
y: anchor.y + anchor.height,
width: anchor.width,
height: popup_height.min(frame.area().height.saturating_sub(anchor.y + anchor.height)),
};
if popup_area.height < 3 {
return;
}
frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = self
.items
.iter()
.enumerate()
.map(|(i, (label, _))| {
let style = if i == self.highlighted_idx {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
};
ListItem::new(label.as_str()).style(style)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(self.title.as_str()));
frame.render_widget(list, popup_area);
}
}