From 78c13f576695da49cebe16f7eb37ec8346718dde Mon Sep 17 00:00:00 2001 From: Shaun Reed Date: Sat, 24 Jan 2026 15:33:48 -0500 Subject: [PATCH] [tui] Add TitleBar popups for drop-down menus. --- src/tui/app.rs | 4 +- src/tui/title_bar.rs | 124 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index ff8f2b5..67665b4 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -199,7 +199,6 @@ impl<'a> Widget for &mut App<'a> { ]) .split(horizontal[1]); - self.title_bar.render(vertical[0], buf); self.draw_bottom_status(vertical[3], buf); self.draw_tabs(editor_layout[0], buf); let id = App::id().to_string(); @@ -209,6 +208,9 @@ impl<'a> Widget for &mut App<'a> { .context("Failed to render Explorer") .unwrap_or_else(|e| error!(target:id.as_str(), "{}", e)); self.logger.render(vertical[2], buf); + + // The title bar is rendered last to overlay any popups created for drop-down menus. + self.title_bar.render(vertical[0], buf); } } diff --git a/src/tui/title_bar.rs b/src/tui/title_bar.rs index b4659d0..a214580 100644 --- a/src/tui/title_bar.rs +++ b/src/tui/title_bar.rs @@ -2,21 +2,22 @@ use crate::tui::component::{Action, Component, ComponentState}; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; -use ratatui::widgets::{Block, Borders, Tabs, Widget}; +use ratatui::widgets::{ + Block, Borders, Clear, List, ListItem, ListState, StatefulWidget, Tabs, Widget, +}; use strum::{EnumIter, FromRepr, IntoEnumIterator}; #[derive(Debug, Clone, Copy, PartialEq, Eq, FromRepr, EnumIter)] enum TitleBarItem { File, - Edit, View, Help, } impl TitleBarItem { - pub fn next(mut self) -> Self { + pub fn next(self) -> Self { let cur = self as usize; let next = cur.saturating_add(1); Self::from_repr(next).unwrap_or(self) @@ -31,26 +32,35 @@ impl TitleBarItem { pub fn id(&self) -> &str { match self { TitleBarItem::File => "File", - TitleBarItem::Edit => "Edit", TitleBarItem::View => "View", TitleBarItem::Help => "Help", } } + + pub fn options(&self) -> &[&str] { + match self { + TitleBarItem::File => &["Save", "Reload"], + TitleBarItem::View => &["Show/hide explorer", "Show/hide logger"], + TitleBarItem::Help => &["About"], + } + } } pub struct TitleBar { selected: TitleBarItem, opened: Option, pub(crate) component_state: ComponentState, + list_state: ListState, } impl TitleBar { + const DEFAULT_HELP: &str = "(←/h)/(→/l): Select option | Enter: Choose selection"; pub fn new() -> Self { Self { selected: TitleBarItem::File, opened: None, - component_state: ComponentState::default() - .with_help_text(concat!("TODO: Title bar help text.").as_ref()), + component_state: ComponentState::default().with_help_text(Self::DEFAULT_HELP), + list_state: ListState::default().with_selected(Some(0)), } } @@ -71,10 +81,44 @@ impl TitleBar { .select(self.selected as usize) .render(area, buf); } -} -impl Widget for &TitleBar { - fn render(self, area: Rect, buf: &mut Buffer) + fn render_drop_down( + &mut self, + title_bar_anchor: Rect, + area: Rect, + buf: &mut Buffer, + opened: TitleBarItem, + ) { + let popup_area = Self::rect_under_option(title_bar_anchor, area, 40, 10); + Clear::default().render(popup_area, buf); + let options = opened.options().iter().map(|i| ListItem::new(*i)); + StatefulWidget::render( + List::new(options) + .block(Block::bordered().title(self.selected.id())) + .highlight_style( + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "), + popup_area, + buf, + &mut self.list_state, + ); + } + + fn rect_under_option(anchor: Rect, area: Rect, width: u16, height: u16) -> Rect { + // TODO: X offset for item option? It's fine as-is, but it might look nicer. + Rect { + x: anchor.x, + y: anchor.y + anchor.height, + width: width.min(area.width), + height, + } + } + + pub fn render(&mut self, area: Rect, buf: &mut Buffer) where Self: Sized, { @@ -85,27 +129,59 @@ impl Widget for &TitleBar { height: 3, }; self.render_title_bar(title_bar_area, buf); + if let Some(opened) = self.opened { + self.render_drop_down(title_bar_area, area, buf, opened); + } } } impl Component for TitleBar { fn handle_key_events(&mut self, key: KeyEvent) -> anyhow::Result { - match key.code { - // KeyCode::Up | KeyCode::Char('k') => self.selected.key_up(), - // KeyCode::Down | KeyCode::Char('j') => self.selected.key_down(), - KeyCode::Left | KeyCode::Char('h') => { - self.selected = self.selected.prev(); - Ok(Action::Handled) + if self.opened.is_some() { + // Keybinds for popup menu. + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.list_state.select_previous(); + Ok(Action::Handled) + } + KeyCode::Down | KeyCode::Char('j') => { + self.list_state.select_next(); + Ok(Action::Handled) + } + KeyCode::Enter => { + // TODO: Handle action for the item. + Ok(Action::Handled) + } + KeyCode::Esc | KeyCode::Char('q') => { + self.opened = None; + self.component_state.help_text = Self::DEFAULT_HELP.to_string(); + self.list_state.select_first(); + Ok(Action::Handled) + } + _ => Ok(Action::Noop), } - KeyCode::Right | KeyCode::Char('l') => { - self.selected = self.selected.next(); - Ok(Action::Handled) + } else { + // Keybinds for title bar. + match key.code { + KeyCode::Left | KeyCode::Char('h') => { + self.selected = self.selected.prev(); + Ok(Action::Handled) + } + KeyCode::Right | KeyCode::Char('l') => { + self.selected = self.selected.next(); + Ok(Action::Handled) + } + KeyCode::Enter => { + self.opened = Some(self.selected); + self.component_state.help_text = concat!( + "(↑/k)/(↓/j): Select option | Enter: Choose selection |", + " ESC/Q: Close drop-down menu" + ) + .to_string(); + Ok(Action::Handled) + } + _ => Ok(Action::Noop), } - KeyCode::Enter => { - self.opened = Some(self.selected); - Ok(Action::Handled) - } - _ => Ok(Action::Noop), } } }