From 711f92b7dd041f259de43c6ed255db90e616b5aa Mon Sep 17 00:00:00 2001 From: Shaun Reed Date: Sat, 24 Jan 2026 19:41:38 -0500 Subject: [PATCH] [tui] Add EditorTab widget. + This adds support for tabbed editors wrapped by EditorTab widgets. + The Explorer widget now opens new EditorTabs when a file is selected with Enter. + The same file may not be opened multiple times. + Tabs can be switched with ALT+h or ALT+l (or ALT+ arrow keys) + Tabs cannot yet be closed :) Fixes #9 --- src/tui.rs | 1 + src/tui/app.rs | 153 +++++++++++++++++++++--------------------- src/tui/component.rs | 1 + src/tui/editor.rs | 7 +- src/tui/editor_tab.rs | 151 +++++++++++++++++++++++++++++++++++++++++ src/tui/explorer.rs | 22 ++++-- src/tui/menu_bar.rs | 15 ++--- 7 files changed, 259 insertions(+), 91 deletions(-) create mode 100644 src/tui/editor_tab.rs diff --git a/src/tui.rs b/src/tui.rs index fbc4133..c78579f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,7 @@ mod app; mod component; mod editor; +mod editor_tab; mod explorer; mod logger; mod menu_bar; diff --git a/src/tui/app.rs b/src/tui/app.rs index 08335f7..51250c5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,21 +1,21 @@ use crate::tui::app::AppComponent::{AppEditor, AppExplorer, AppLogger}; use crate::tui::component::{Action, Component, Focus, FocusState}; -use crate::tui::editor::Editor; +use crate::tui::editor_tab::EditorTab; use crate::tui::explorer::Explorer; use crate::tui::logger::Logger; use crate::tui::menu_bar::MenuBar; use AppComponent::AppMenuBar; use anyhow::{Context, Result}; -use log::{debug, error, info, trace, warn}; +use log::error; +use ratatui::DefaultTerminal; use ratatui::buffer::Buffer; use ratatui::crossterm::event; use ratatui::crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::prelude::{Color, Style, Widget}; -use ratatui::widgets::{Block, Borders, Padding, Paragraph, Tabs, Wrap}; -use ratatui::{DefaultTerminal, symbols}; +use ratatui::prelude::{Color, Widget}; +use ratatui::widgets::{Paragraph, Wrap}; use std::path::PathBuf; use std::time::Duration; @@ -31,7 +31,7 @@ pub enum AppComponent { } pub struct App<'a> { - editor: Editor, + editor_tabs: EditorTab, explorer: Explorer<'a>, logger: Logger, menu_bar: MenuBar, @@ -45,7 +45,7 @@ impl<'a> App<'a> { pub fn new(root_path: PathBuf) -> Result { let app = Self { - editor: Editor::new(), + editor_tabs: EditorTab::new(&root_path), explorer: Explorer::new(&root_path)?, logger: Logger::new(), menu_bar: MenuBar::new(), @@ -57,12 +57,16 @@ impl<'a> App<'a> { /// Logic that should be executed once on application startup. pub fn start(&mut self) -> Result<()> { let root_path = self.explorer.root_path.clone(); - self.editor + let editor = self + .editor_tabs + .current_editor_mut() + .context("Failed to get current editor in App::start")?; + editor .set_contents(&root_path.join("src/tui/app.rs")) .context(format!( "Failed to initialize editor contents to path: {root_path:?}" ))?; - self.editor.component_state.set_focus(Focus::Active); + editor.component_state.set_focus(Focus::Active); Ok(()) } @@ -92,7 +96,13 @@ impl<'a> App<'a> { fn draw_bottom_status(&self, area: Rect, buf: &mut Buffer) { // Determine help text from the most recently focused component. let help = match self.last_active { - AppEditor => self.editor.component_state.help_text.clone(), + AppEditor => match self.editor_tabs.current_editor() { + Some(editor) => editor.component_state.help_text.clone(), + None => { + error!(target:Self::id(), "Failed to get Editor while drawing bottom status bar"); + "Failed to get current Editor while getting widget help text".to_string() + } + }, AppExplorer => self.explorer.component_state.help_text.clone(), AppLogger => self.logger.component_state.help_text.clone(), AppMenuBar => self.menu_bar.component_state.help_text.clone(), @@ -111,32 +121,14 @@ impl<'a> App<'a> { .render(area, buf); } - fn draw_tabs(&self, area: Rect, buf: &mut Buffer) { - // Determine the tab title from the current file (or use a fallback). - if let Some(title) = self.editor.file_path.clone() { - Tabs::new(vec![ - title - .file_name() - .map(|f| f.to_str()) - .unwrap_or(Some("Unknown")) - .unwrap(), - ]) - .divider(symbols::DOT) - .block( - Block::default() - .borders(Borders::NONE) - .padding(Padding::new(0, 0, 0, 0)), - ) - .highlight_style(Style::default().fg(Color::LightRed)) - .render(area, buf); - } else { - error!(target:Self::id(), "Failed to get Editor file_path while drawing Tabs widget."); - } - } - fn change_focus(&mut self, focus: AppComponent) { match focus { - AppEditor => self.editor.component_state.set_focus(Focus::Active), + AppEditor => match self.editor_tabs.current_editor_mut() { + None => { + error!(target:Self::id(), "Failed to get current Editor while changing focus") + } + Some(editor) => editor.component_state.set_focus(Focus::Active), + }, AppExplorer => self.explorer.component_state.set_focus(Focus::Active), AppLogger => self.logger.component_state.set_focus(Focus::Active), AppMenuBar => self.menu_bar.component_state.set_focus(Focus::Active), @@ -147,20 +139,26 @@ impl<'a> App<'a> { /// Refresh the contents of the editor to match the selected TreeItem in the file Explorer. /// If the selected item is not a file, this does nothing. fn refresh_editor_contents(&mut self) -> Result<()> { + // TODO: This may be useful for a preview mode of the selected file prior to opening a tab. // Use the currently selected TreeItem or get an absolute path to this source file. - let selected_pathbuf = match self.explorer.selected() { - Ok(path) => PathBuf::from(path), - Err(_) => PathBuf::from(std::path::absolute(file!())?.to_string_lossy().to_string()), - }; - let current_file_path = self - .editor - .file_path - .clone() - .context("Failed to get Editor current file_path")?; - if selected_pathbuf == current_file_path || !selected_pathbuf.is_file() { - return Ok(()); - } - self.editor.set_contents(&selected_pathbuf) + // let selected_pathbuf = match self.explorer.selected() { + // Ok(path) => PathBuf::from(path), + // Err(_) => PathBuf::from(std::path::absolute(file!())?.to_string_lossy().to_string()), + // }; + // match self.editor_tabs.current_editor_mut() { + // None => bail!("Failed to get current Editor while refreshing editor contents"), + // Some(editor) => { + // let current_file_path = editor + // .file_path + // .clone() + // .context("Failed to get Editor current file_path")?; + // if selected_pathbuf == current_file_path || !selected_pathbuf.is_file() { + // return Ok(()); + // } + // editor.set_contents(&selected_pathbuf) + // } + // } + Ok(()) } } @@ -196,9 +194,8 @@ impl<'a> Widget for &mut App<'a> { .split(horizontal[1]); self.draw_bottom_status(vertical[3], buf); - self.draw_tabs(editor_layout[0], buf); - let id = App::id().to_string(); - self.editor.render(editor_layout[1], buf); + self.editor_tabs + .render(editor_layout[0], editor_layout[1], buf); self.explorer.render(horizontal[0], buf); self.logger.render(vertical[2], buf); @@ -220,28 +217,47 @@ impl<'a> Component for App<'a> { _ => {} } } + // Handle events for all components. + let action = match self.last_active { + AppEditor => self.editor_tabs.handle_event(event.clone())?, + AppExplorer => self.explorer.handle_event(event.clone())?, + AppLogger => self.logger.handle_event(event.clone())?, + AppMenuBar => self.menu_bar.handle_event(event.clone())?, + }; + + let editor = self + .editor_tabs + .current_editor_mut() + .context("Failed to get current editor while handling App events")?; // Components should always handle mouse events for click interaction. if let Some(mouse) = event.as_mouse_event() { if mouse.kind == MouseEventKind::Down(MouseButton::Left) { - self.editor.handle_mouse_events(mouse)?; + editor.handle_mouse_events(mouse)?; self.explorer.handle_mouse_events(mouse)?; self.logger.handle_mouse_events(mouse)?; } } - // Handle events for all components. - let action = match self.last_active { - AppEditor => self.editor.handle_event(event)?, - AppExplorer => self.explorer.handle_event(event)?, - AppLogger => self.logger.handle_event(event)?, - AppMenuBar => self.menu_bar.handle_event(event)?, - }; match action { - Action::Quit | Action::Handled => return Ok(action), - Action::Save => self.editor.save()?, - _ => {} + Action::Quit | Action::Handled => Ok(action), + Action::Save => match editor.save() { + Ok(_) => Ok(Action::Handled), + Err(_) => { + error!(target:Self::id(), "Failed to save editor contents"); + Ok(Action::Noop) + } + }, + Action::OpenTab => { + if let Ok(path) = self.explorer.selected() { + let path_buf = PathBuf::from(path); + self.editor_tabs.open_tab(&path_buf)?; + Ok(Action::Handled) + } else { + Ok(Action::Noop) + } + } + _ => Ok(Action::Noop), } - Ok(Action::Noop) } /// Handles key events for the App Component only. @@ -283,19 +299,6 @@ impl<'a> Component for App<'a> { self.change_focus(AppMenuBar); Ok(Action::Handled) } - KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::ALT, - kind: KeyEventKind::Press, - state: _state, - } => { - error!(target:App::id(), "an error"); - warn!(target:App::id(), "a warning"); - info!(target:App::id(), "a two line info\nsecond line"); - debug!(target:App::id(), "a debug"); - trace!(target:App::id(), "a trace"); - Ok(Action::Handled) - } KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, diff --git a/src/tui/component.rs b/src/tui/component.rs index 0a98b84..3e07f9d 100644 --- a/src/tui/component.rs +++ b/src/tui/component.rs @@ -19,6 +19,7 @@ pub enum Action { /// The input was handled by a Component and should not be passed to the next component. Handled, + OpenTab, } pub trait Component { diff --git a/src/tui/editor.rs b/src/tui/editor.rs index c49abea..b500de8 100644 --- a/src/tui/editor.rs +++ b/src/tui/editor.rs @@ -23,14 +23,17 @@ impl Editor { "Editor" } + // TODO: You shouldnt be able to construct the editor without a path? pub fn new() -> Self { Editor { state: EditorState::default(), event_handler: EditorEventHandler::default(), file_path: None, syntax_set: SyntaxSet::load_defaults_nonewlines(), - component_state: ComponentState::default() - .with_help_text("CTRL+S: Save file | Any other input is handled by vim"), + component_state: ComponentState::default().with_help_text(concat!( + "CTRL+S: Save file | ALT+(←/h): Previous tab | ALT+(l/→): Next tab |", + " All other input is handled by vim" + )), } } diff --git a/src/tui/editor_tab.rs b/src/tui/editor_tab.rs new file mode 100644 index 0000000..d06d2bc --- /dev/null +++ b/src/tui/editor_tab.rs @@ -0,0 +1,151 @@ +use crate::tui::component::{Action, Component}; +use crate::tui::editor::Editor; +use anyhow::{Context, Result}; +use log::trace; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::Rect; +use ratatui::prelude::{Color, Style}; +use ratatui::widgets::{Block, Borders, Padding, Tabs, Widget}; +use std::collections::HashMap; + +// Render the tabs with keys as titles +// Tab keys can be file names. +// Render the editor using the key as a reference for lookup +pub struct EditorTab { + pub(crate) editors: HashMap, + tab_order: Vec, + current_editor: usize, +} + +impl EditorTab { + fn id() -> &'static str { + "EditorTab" + } + + pub fn new(path: &std::path::PathBuf) -> Self { + trace!(target:Self::id(), "Building EditorTab with path '{path:?}'"); + let tab_order = vec![path.to_string_lossy().to_string()]; + Self { + editors: HashMap::from([(tab_order.first().unwrap().to_owned(), Editor::new())]), + tab_order, + current_editor: 0, + } + } + + pub fn next_editor(&mut self) { + self.current_editor = (self.current_editor + 1) % self.tab_order.len(); + } + + pub fn prev_editor(&mut self) { + self.current_editor = self + .current_editor + .checked_sub(1) + .unwrap_or(self.tab_order.len() - 1); + } + + pub fn current_editor(&self) -> Option<&Editor> { + self.editors.get(&self.tab_order[self.current_editor]) + } + + pub fn current_editor_mut(&mut self) -> Option<&mut Editor> { + self.editors.get_mut(&self.tab_order[self.current_editor]) + } + + pub fn open_tab(&mut self, path: &std::path::PathBuf) -> Result<()> { + if self + .editors + .contains_key(&path.to_string_lossy().to_string()) + { + return Ok(()); + } + + let path_str = path.to_string_lossy().to_string(); + self.tab_order.push(path_str.clone()); + let mut editor = Editor::new(); + editor.set_contents(path).context("Failed to open tab")?; + self.editors.insert(path_str, editor); + self.current_editor = self.tab_order.len() - 1; + Ok(()) + } + + pub fn render(&mut self, tabs_area: Rect, editor_area: Rect, buf: &mut Buffer) { + // TODO: Only file name is displayed in tab title, so files with the same name in different + // directories will appear confusing. + let tab_titles = self.tab_order.iter().map(|t| { + std::path::PathBuf::from(t) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or("Unknown".to_string()) + }); + Tabs::new(tab_titles) + .select(self.current_editor) + .divider("|") + .block( + Block::default() + .borders(Borders::NONE) + .padding(Padding::new(0, 0, 0, 0)), + ) + .highlight_style(Style::default().fg(Color::LightRed)) + .render(tabs_area, buf); + Widget::render(self, editor_area, buf); + } +} + +impl Widget for &mut EditorTab { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + if let Some(editor) = self.current_editor_mut() { + editor.render(area, buf); + } + } +} + +impl Component for EditorTab { + fn handle_event(&mut self, event: Event) -> Result { + if let Some(key) = event.as_key_event() { + let action = self.handle_key_events(key)?; + match action { + Action::Quit | Action::Handled => return Ok(action), + _ => {} + } + } + self.current_editor_mut() + .context("Failed to get current editor")? + .handle_event(event) + } + + fn handle_key_events(&mut self, key: KeyEvent) -> Result { + match key { + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::ALT, + .. + } + | KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::ALT, + .. + } => { + self.prev_editor(); + Ok(Action::Handled) + } + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::ALT, + .. + } + | KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::ALT, + .. + } => { + self.next_editor(); + Ok(Action::Handled) + } + _ => Ok(Action::Noop), + } + } +} diff --git a/src/tui/explorer.rs b/src/tui/explorer.rs index dc77c6e..a44b9b0 100644 --- a/src/tui/explorer.rs +++ b/src/tui/explorer.rs @@ -23,19 +23,20 @@ impl<'a> Explorer<'a> { "Explorer" } - pub fn new(path: &std::path::PathBuf) -> Result { + pub fn new(path: &PathBuf) -> Result { let explorer = Explorer { root_path: path.to_owned(), tree_items: Self::build_tree_from_path(path.to_owned())?, tree_state: TreeState::default(), - component_state: ComponentState::default().with_help_text( - "(↑/k)/(↓/j): Select item | ←/h: Close folder | →/l/Enter: Open folder", - ), + component_state: ComponentState::default().with_help_text(concat!( + "(↑/k)/(↓/j): Select item | ←/h: Close folder | →/l: Open folder |", + " Enter: Open editor tab" + )), }; Ok(explorer) } - fn build_tree_from_path(path: std::path::PathBuf) -> Result> { + fn build_tree_from_path(path: PathBuf) -> Result> { let mut children = vec![]; if let Ok(entries) = fs::read_dir(&path) { let mut paths = entries @@ -126,6 +127,7 @@ impl<'a> Component for Explorer<'a> { // Handle events here that should not be passed on to the vim emulation handler. match self.handle_key_events(key_event)? { Action::Handled => return Ok(Action::Handled), + Action::OpenTab => return Ok(Action::OpenTab), _ => {} } } @@ -139,6 +141,15 @@ impl<'a> Component for Explorer<'a> { } fn handle_key_events(&mut self, key: KeyEvent) -> Result { + if key.code == KeyCode::Enter { + if let Ok(selected) = self.selected() { + if PathBuf::from(&selected).is_file() { + return Ok(Action::OpenTab); + } + } + return Ok(Action::Noop); + } + let changed = match key.code { KeyCode::Up | KeyCode::Char('k') => self.tree_state.key_up(), KeyCode::Down | KeyCode::Char('j') => self.tree_state.key_down(), @@ -148,7 +159,6 @@ impl<'a> Component for Explorer<'a> { self.tree_state.close(key.as_ref()) } KeyCode::Right | KeyCode::Char('l') => self.tree_state.key_right(), - KeyCode::Enter => self.tree_state.toggle_selected(), _ => false, }; if changed { diff --git a/src/tui/menu_bar.rs b/src/tui/menu_bar.rs index d724782..aa96009 100644 --- a/src/tui/menu_bar.rs +++ b/src/tui/menu_bar.rs @@ -1,4 +1,3 @@ -use crate::tui::component::Action::Pass; use crate::tui::component::{Action, Component, ComponentState}; use crate::tui::menu_bar::MenuBarItemOption::{ About, Exit, Reload, Save, ShowHideExplorer, ShowHideLogger, @@ -33,12 +32,12 @@ enum MenuBarItemOption { impl MenuBarItemOption { fn id(&self) -> &str { match self { - MenuBarItemOption::Save => "Save", - MenuBarItemOption::Reload => "Reload", - MenuBarItemOption::Exit => "Exit", - MenuBarItemOption::ShowHideExplorer => "Show / hide explorer", - MenuBarItemOption::ShowHideLogger => "Show / hide logger", - MenuBarItemOption::About => "About", + Save => "Save", + Reload => "Reload", + Exit => "Exit", + ShowHideExplorer => "Show / hide explorer", + ShowHideLogger => "Show / hide logger", + About => "About", } } } @@ -116,7 +115,7 @@ impl MenuBar { buf: &mut Buffer, opened: MenuBarItem, ) { - let popup_area = Self::rect_under_option(title_bar_anchor, area, 40, 10); + let popup_area = Self::rect_under_option(title_bar_anchor, area, 27, 10); Clear::default().render(popup_area, buf); let options = opened.options().iter().map(|i| ListItem::new(i.id())); StatefulWidget::render(