2026-01-25 20:57:36 +00:00
|
|
|
use crate::tui::about::About;
|
|
|
|
|
use crate::tui::app::AppComponent::{AppEditor, AppExplorer, AppLogger};
|
|
|
|
|
use crate::tui::component::{Action, Component, Focus, FocusState, Visibility, VisibleState};
|
|
|
|
|
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::{error, info, trace};
|
|
|
|
|
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, Widget};
|
|
|
|
|
use ratatui::widgets::{Paragraph, Wrap};
|
2026-01-31 04:25:14 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-01-25 20:57:36 +00:00
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
|
|
|
pub enum AppComponent {
|
|
|
|
|
AppEditor,
|
|
|
|
|
AppExplorer,
|
|
|
|
|
AppLogger,
|
|
|
|
|
AppMenuBar,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct App<'a> {
|
2026-01-31 04:25:14 +00:00
|
|
|
editor_tab: EditorTab,
|
2026-01-25 20:57:36 +00:00
|
|
|
explorer: Explorer<'a>,
|
|
|
|
|
logger: Logger,
|
|
|
|
|
menu_bar: MenuBar,
|
|
|
|
|
last_active: AppComponent,
|
|
|
|
|
about: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> App<'a> {
|
2026-01-31 04:25:14 +00:00
|
|
|
pub const ID: &'static str = "App";
|
2026-01-25 20:57:36 +00:00
|
|
|
|
|
|
|
|
pub fn new(root_path: PathBuf) -> Result<Self> {
|
2026-01-31 04:25:14 +00:00
|
|
|
trace!(target:Self::ID, "Building {}", Self::ID);
|
2026-01-25 20:57:36 +00:00
|
|
|
let app = Self {
|
2026-01-31 04:25:14 +00:00
|
|
|
editor_tab: EditorTab::new(),
|
2026-01-25 20:57:36 +00:00
|
|
|
explorer: Explorer::new(&root_path)?,
|
|
|
|
|
logger: Logger::new(),
|
|
|
|
|
menu_bar: MenuBar::new(),
|
|
|
|
|
last_active: AppEditor,
|
|
|
|
|
about: false,
|
|
|
|
|
};
|
|
|
|
|
Ok(app)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Logic that should be executed once on application startup.
|
|
|
|
|
pub fn start(&mut self) -> Result<()> {
|
2026-01-31 04:25:14 +00:00
|
|
|
trace!(target:Self::ID, "Starting App");
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
|
|
|
|
|
self.start()?;
|
2026-01-31 04:25:14 +00:00
|
|
|
trace!(target:Self::ID, "Entering App run loop");
|
2026-01-25 20:57:36 +00:00
|
|
|
loop {
|
|
|
|
|
terminal.draw(|f| {
|
|
|
|
|
f.render_widget(&mut self, f.area());
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
if event::poll(Duration::from_millis(250)).context("event poll failed")? {
|
|
|
|
|
match self.handle_event(event::read()?)? {
|
|
|
|
|
Action::Quit => break,
|
|
|
|
|
Action::Handled => {}
|
|
|
|
|
_ => {
|
|
|
|
|
// bail!("Unhandled event: {:?}", event);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-01-31 04:25:14 +00:00
|
|
|
AppEditor => match self.editor_tab.current_editor() {
|
2026-01-25 20:57:36 +00:00
|
|
|
Some(editor) => editor.component_state.help_text.clone(),
|
|
|
|
|
None => {
|
2026-01-31 04:25:14 +00:00
|
|
|
if !self.editor_tab.is_empty() {
|
|
|
|
|
error!(target:Self::ID, "Failed to get Editor while drawing bottom status bar");
|
2026-01-25 20:57:36 +00:00
|
|
|
}
|
|
|
|
|
"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(),
|
|
|
|
|
};
|
|
|
|
|
Paragraph::new(
|
|
|
|
|
concat!(
|
|
|
|
|
"ALT+Q: Focus project explorer | ALT+W: Focus editor | ALT+E: Focus logger |",
|
|
|
|
|
" ALT+R: Focus menu bar | CTRL+C: Quit\n"
|
|
|
|
|
)
|
|
|
|
|
.to_string()
|
|
|
|
|
+ help.as_str(),
|
|
|
|
|
)
|
|
|
|
|
.style(Color::Gray)
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.centered()
|
|
|
|
|
.render(area, buf);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn clear_focus(&mut self) {
|
2026-01-31 04:25:14 +00:00
|
|
|
info!(target:Self::ID, "Clearing all widget focus");
|
2026-01-25 20:57:36 +00:00
|
|
|
self.explorer.component_state.set_focus(Focus::Inactive);
|
|
|
|
|
self.explorer.component_state.set_focus(Focus::Inactive);
|
|
|
|
|
self.logger.component_state.set_focus(Focus::Inactive);
|
|
|
|
|
self.menu_bar.component_state.set_focus(Focus::Inactive);
|
2026-01-31 04:25:14 +00:00
|
|
|
match self.editor_tab.current_editor_mut() {
|
2026-01-25 20:57:36 +00:00
|
|
|
None => {
|
2026-01-31 04:25:14 +00:00
|
|
|
error!(target:Self::ID, "Failed to get current Editor while clearing focus")
|
2026-01-25 20:57:36 +00:00
|
|
|
}
|
|
|
|
|
Some(editor) => editor.component_state.set_focus(Focus::Inactive),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn change_focus(&mut self, focus: AppComponent) {
|
2026-01-31 04:25:14 +00:00
|
|
|
info!(target:Self::ID, "Changing widget focus to {:?}", focus);
|
2026-01-25 20:57:36 +00:00
|
|
|
self.clear_focus();
|
|
|
|
|
match focus {
|
2026-01-31 04:25:14 +00:00
|
|
|
AppEditor => match self.editor_tab.current_editor_mut() {
|
2026-01-25 20:57:36 +00:00
|
|
|
None => {
|
2026-01-31 04:25:14 +00:00
|
|
|
error!(target:Self::ID, "Failed to get current Editor while changing focus")
|
2026-01-25 20:57:36 +00:00
|
|
|
}
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
self.last_active = focus;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> Widget for &mut App<'a> {
|
|
|
|
|
fn render(self, area: Rect, buf: &mut Buffer)
|
|
|
|
|
where
|
|
|
|
|
Self: Sized,
|
|
|
|
|
{
|
|
|
|
|
let vertical_constraints = match self.logger.component_state.vis {
|
|
|
|
|
Visibility::Visible => {
|
|
|
|
|
vec![
|
|
|
|
|
Constraint::Length(3), // top status bar
|
|
|
|
|
Constraint::Percentage(70), // horizontal layout
|
|
|
|
|
Constraint::Fill(1), // terminal
|
|
|
|
|
Constraint::Length(3), // bottom status bar
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
Visibility::Hidden => {
|
|
|
|
|
vec![
|
|
|
|
|
Constraint::Length(3), // top status bar
|
|
|
|
|
Constraint::Fill(1), // horizontal layout
|
|
|
|
|
Constraint::Length(3), // bottom status bar
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let vertical = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints(vertical_constraints)
|
|
|
|
|
.split(area);
|
|
|
|
|
|
|
|
|
|
let horizontal_constraints = match self.explorer.component_state.vis {
|
|
|
|
|
Visibility::Visible => {
|
|
|
|
|
vec![
|
|
|
|
|
Constraint::Max(30), // File explorer with a max width of 30 characters.
|
|
|
|
|
Constraint::Fill(1), // Editor fills the remaining space.
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
Visibility::Hidden => {
|
|
|
|
|
vec![
|
|
|
|
|
Constraint::Fill(1), // Editor fills the remaining space.
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The index used for vertical here does not care if the Logger is Visible or not.
|
|
|
|
|
let horizontal = Layout::default()
|
|
|
|
|
.direction(Direction::Horizontal)
|
|
|
|
|
.constraints(horizontal_constraints)
|
|
|
|
|
.split(vertical[1]);
|
|
|
|
|
match self.explorer.component_state.vis {
|
|
|
|
|
Visibility::Visible => {
|
|
|
|
|
let editor_layout = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Length(1), // Editor tabs.
|
|
|
|
|
Constraint::Fill(1), // Editor contents.
|
|
|
|
|
])
|
|
|
|
|
.split(horizontal[1]);
|
2026-01-31 04:25:14 +00:00
|
|
|
self.editor_tab
|
2026-01-25 20:57:36 +00:00
|
|
|
.render(editor_layout[0], editor_layout[1], buf);
|
|
|
|
|
self.explorer.render(horizontal[0], buf);
|
|
|
|
|
}
|
|
|
|
|
Visibility::Hidden => {
|
|
|
|
|
let editor_layout = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Length(1), // Editor tabs.
|
|
|
|
|
Constraint::Fill(1), // Editor contents.
|
|
|
|
|
])
|
|
|
|
|
.split(horizontal[0]);
|
2026-01-31 04:25:14 +00:00
|
|
|
self.editor_tab
|
2026-01-25 20:57:36 +00:00
|
|
|
.render(editor_layout[0], editor_layout[1], buf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match self.logger.component_state.vis {
|
|
|
|
|
// Index 1 of vertical is rendered with the horizontal layout above.
|
|
|
|
|
Visibility::Visible => {
|
|
|
|
|
self.logger.render(vertical[2], buf);
|
|
|
|
|
self.draw_bottom_status(vertical[3], buf);
|
|
|
|
|
// The title bar is rendered last to overlay any popups created for drop-down menus.
|
|
|
|
|
self.menu_bar.render(vertical[0], buf);
|
|
|
|
|
}
|
|
|
|
|
Visibility::Hidden => {
|
|
|
|
|
self.draw_bottom_status(vertical[2], buf);
|
|
|
|
|
// The title bar is rendered last to overlay any popups created for drop-down menus.
|
|
|
|
|
self.menu_bar.render(vertical[0], buf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if self.about {
|
2026-01-31 04:25:14 +00:00
|
|
|
let about_area = area.centered(Constraint::Percentage(40), Constraint::Percentage(50));
|
2026-01-25 20:57:36 +00:00
|
|
|
About::new().render(about_area, buf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> Component for App<'a> {
|
|
|
|
|
/// Handles events for the App and delegates to attached Components.
|
|
|
|
|
fn handle_event(&mut self, event: Event) -> Result<Action> {
|
|
|
|
|
// Handle events in the primary application.
|
|
|
|
|
if let Some(key_event) = event.as_key_event() {
|
|
|
|
|
let res = self
|
|
|
|
|
.handle_key_events(key_event)
|
|
|
|
|
.context("Failed to handle key events for primary App Component.");
|
|
|
|
|
match res {
|
|
|
|
|
Ok(Action::Quit) | Ok(Action::Handled) => return res,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Handle events for all components.
|
|
|
|
|
let action = match self.last_active {
|
2026-01-31 04:25:14 +00:00
|
|
|
AppEditor => self.editor_tab.handle_event(event.clone())?,
|
2026-01-25 20:57:36 +00:00
|
|
|
AppExplorer => self.explorer.handle_event(event.clone())?,
|
|
|
|
|
AppLogger => self.logger.handle_event(event.clone())?,
|
|
|
|
|
AppMenuBar => self.menu_bar.handle_event(event.clone())?,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Components should always handle mouse events for click interaction.
|
|
|
|
|
if let Some(mouse) = event.as_mouse_event() {
|
|
|
|
|
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
|
2026-01-31 04:25:14 +00:00
|
|
|
if let Some(editor) = self.editor_tab.current_editor_mut() {
|
2026-01-25 20:57:36 +00:00
|
|
|
editor.handle_mouse_events(mouse)?;
|
|
|
|
|
}
|
|
|
|
|
self.explorer.handle_mouse_events(mouse)?;
|
|
|
|
|
self.logger.handle_mouse_events(mouse)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle actions returned from widgets that may need context on other widgets or app state.
|
|
|
|
|
match action {
|
|
|
|
|
Action::Quit | Action::Handled => Ok(action),
|
2026-01-31 04:25:14 +00:00
|
|
|
Action::Save => match self.editor_tab.current_editor_mut() {
|
2026-01-25 20:57:36 +00:00
|
|
|
None => {
|
2026-01-31 04:25:14 +00:00
|
|
|
error!(target:Self::ID, "Failed to get current editor while handling App Action::Save");
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(Action::Noop)
|
|
|
|
|
}
|
|
|
|
|
Some(editor) => match editor.save() {
|
|
|
|
|
Ok(_) => Ok(Action::Handled),
|
|
|
|
|
Err(e) => {
|
2026-01-31 04:25:14 +00:00
|
|
|
error!(target:Self::ID, "Failed to save editor contents: {e}");
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(Action::Noop)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Action::OpenTab => {
|
|
|
|
|
if let Ok(path) = self.explorer.selected() {
|
2026-01-31 04:25:14 +00:00
|
|
|
let path_buf = Path::new(&path);
|
|
|
|
|
self.editor_tab.open_tab(path_buf)?;
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(Action::Handled)
|
|
|
|
|
} else {
|
|
|
|
|
Ok(Action::Noop)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-31 04:25:14 +00:00
|
|
|
Action::CloseTab => match self.editor_tab.close_current_tab() {
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(_) => Ok(Action::Handled),
|
|
|
|
|
Err(_) => Ok(Action::Noop),
|
|
|
|
|
},
|
|
|
|
|
Action::ReloadFile => {
|
2026-01-31 04:25:14 +00:00
|
|
|
trace!(target:Self::ID, "Reloading file for current editor");
|
|
|
|
|
if let Some(editor) = self.editor_tab.current_editor_mut() {
|
2026-01-25 20:57:36 +00:00
|
|
|
editor
|
|
|
|
|
.reload_contents()
|
|
|
|
|
.map(|_| Action::Handled)
|
|
|
|
|
.context("Failed to handle Action::ReloadFile")
|
|
|
|
|
} else {
|
2026-01-31 04:25:14 +00:00
|
|
|
error!(target:Self::ID, "Failed to get current editor while handling App Action::ReloadFile");
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(Action::Noop)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Action::ShowHideLogger => {
|
2026-01-31 04:25:14 +00:00
|
|
|
self.logger.component_state.toggle_visible();
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
Action::ShowHideExplorer => {
|
2026-01-31 04:25:14 +00:00
|
|
|
self.explorer.component_state.toggle_visible();
|
2026-01-25 20:57:36 +00:00
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
Action::ShowHideAbout => {
|
|
|
|
|
self.about = !self.about;
|
|
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
_ => Ok(Action::Noop),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handles key events for the App Component only.
|
|
|
|
|
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
|
|
|
|
|
match key.code {
|
|
|
|
|
// If the ESC key is pressed with the About page open, hide it.
|
|
|
|
|
KeyCode::Esc | KeyCode::Char('q') => {
|
|
|
|
|
if self.about {
|
|
|
|
|
self.about = false;
|
|
|
|
|
return Ok(Action::Handled);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match key {
|
|
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('q'),
|
|
|
|
|
modifiers: KeyModifiers::ALT,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
|
|
|
|
} => {
|
|
|
|
|
self.change_focus(AppExplorer);
|
|
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('w'),
|
|
|
|
|
modifiers: KeyModifiers::ALT,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
|
|
|
|
} => {
|
|
|
|
|
self.change_focus(AppEditor);
|
|
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('e'),
|
|
|
|
|
modifiers: KeyModifiers::ALT,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
|
|
|
|
} => {
|
|
|
|
|
self.change_focus(AppLogger);
|
|
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('r'),
|
|
|
|
|
modifiers: KeyModifiers::ALT,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
|
|
|
|
} => {
|
|
|
|
|
self.change_focus(AppMenuBar);
|
|
|
|
|
Ok(Action::Handled)
|
|
|
|
|
}
|
|
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('c'),
|
|
|
|
|
modifiers: KeyModifiers::CONTROL,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
|
|
|
|
} => Ok(Action::Quit),
|
|
|
|
|
_ => Ok(Action::Noop),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|