2026-01-19 09:23:12 -05:00
|
|
|
use crate::tui::component::{Action, Component};
|
2026-01-18 10:09:28 -05:00
|
|
|
use crate::tui::editor::Editor;
|
2026-01-17 17:09:42 -05:00
|
|
|
use crate::tui::explorer::Explorer;
|
2026-01-20 20:14:25 -05:00
|
|
|
use crate::tui::logger::Logger;
|
2026-01-21 20:28:24 -05:00
|
|
|
use anyhow::{Context, Result, bail};
|
2026-01-20 20:14:25 -05:00
|
|
|
use log::{debug, error, info, trace, warn};
|
2026-01-17 14:04:02 -05:00
|
|
|
use ratatui::buffer::Buffer;
|
2026-01-18 10:09:28 -05:00
|
|
|
use ratatui::crossterm::event;
|
|
|
|
|
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
|
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
2026-01-17 14:04:02 -05:00
|
|
|
use ratatui::prelude::{Color, Style, Widget};
|
2026-01-21 20:28:24 -05:00
|
|
|
use ratatui::widgets::{Block, Borders, Padding, Tabs};
|
2026-01-17 14:04:02 -05:00
|
|
|
use ratatui::{DefaultTerminal, symbols};
|
2026-01-19 18:01:35 -05:00
|
|
|
use std::path::PathBuf;
|
2026-01-18 10:09:28 -05:00
|
|
|
use std::time::Duration;
|
2026-01-17 15:07:05 -05:00
|
|
|
|
2026-01-20 20:14:25 -05:00
|
|
|
// TODO: Need a way to dynamically run Widget::render on all widgets.
|
|
|
|
|
// TODO: + Need a way to map Rect to Component::id() to position each widget?
|
|
|
|
|
// TODO: Need a way to dynamically run Component methods on all widgets.
|
2026-01-20 16:03:38 -05:00
|
|
|
pub enum AppComponents<'a> {
|
|
|
|
|
AppEditor(Editor),
|
|
|
|
|
AppExplorer(Explorer<'a>),
|
2026-01-20 20:14:25 -05:00
|
|
|
AppLogger(Logger),
|
2026-01-20 16:03:38 -05:00
|
|
|
AppComponent(Box<dyn Component>),
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 20:43:01 -05:00
|
|
|
/// Usage: get_component_mut::<Editor>() OR get_component::<Editor>()
|
|
|
|
|
///
|
|
|
|
|
/// Implementing this trait for each AppComponent allows for easy lookup in the vector.
|
2026-01-21 20:28:24 -05:00
|
|
|
pub(crate) trait ComponentOf<T> {
|
2026-01-20 20:43:01 -05:00
|
|
|
fn as_ref(&self) -> Option<&T>;
|
|
|
|
|
fn as_mut(&mut self) -> Option<&mut T>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 14:04:02 -05:00
|
|
|
pub struct App<'a> {
|
2026-01-20 16:03:38 -05:00
|
|
|
components: Vec<AppComponents<'a>>,
|
2026-01-17 14:04:02 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<'a> App<'a> {
|
2026-01-20 17:19:13 -05:00
|
|
|
pub fn new(root_path: PathBuf) -> Result<Self> {
|
2026-01-19 15:03:50 -05:00
|
|
|
let mut app = Self {
|
2026-01-20 16:03:38 -05:00
|
|
|
components: vec![
|
2026-01-20 17:19:13 -05:00
|
|
|
AppComponents::AppExplorer(Explorer::new(&root_path)?),
|
2026-01-20 16:03:38 -05:00
|
|
|
AppComponents::AppEditor(Editor::new()),
|
2026-01-20 20:14:25 -05:00
|
|
|
AppComponents::AppLogger(Logger::new()),
|
2026-01-20 16:03:38 -05:00
|
|
|
],
|
2026-01-19 15:03:50 -05:00
|
|
|
};
|
2026-01-20 20:43:01 -05:00
|
|
|
app.get_component_mut::<Editor>()
|
2026-01-20 16:03:38 -05:00
|
|
|
.unwrap()
|
2026-01-19 15:03:50 -05:00
|
|
|
.set_contents(&root_path.join("src/tui/app.rs"))
|
2026-01-20 17:19:13 -05:00
|
|
|
.context(format!(
|
2026-01-21 20:28:24 -05:00
|
|
|
"Failed to initialize editor contents to path: {root_path:?}"
|
2026-01-20 17:19:13 -05:00
|
|
|
))?;
|
|
|
|
|
Ok(app)
|
2026-01-17 14:04:02 -05:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 20:43:01 -05:00
|
|
|
fn get_component<T>(&self) -> Option<&T>
|
|
|
|
|
where
|
|
|
|
|
AppComponents<'a>: ComponentOf<T>,
|
|
|
|
|
{
|
|
|
|
|
self.components.iter().find_map(|c| c.as_ref())
|
2026-01-20 16:03:38 -05:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 20:43:01 -05:00
|
|
|
fn get_component_mut<T>(&mut self) -> Option<&mut T>
|
|
|
|
|
where
|
|
|
|
|
AppComponents<'a>: ComponentOf<T>,
|
|
|
|
|
{
|
|
|
|
|
self.components.iter_mut().find_map(|c| c.as_mut())
|
2026-01-20 16:03:38 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
|
2026-01-17 14:04:02 -05:00
|
|
|
loop {
|
2026-01-20 17:19:13 -05:00
|
|
|
self.refresh_editor_contents()
|
|
|
|
|
.context("Failed to refresh editor contents.")?;
|
|
|
|
|
|
2026-01-17 17:09:42 -05:00
|
|
|
terminal.draw(|f| {
|
2026-01-18 10:09:28 -05:00
|
|
|
f.render_widget(&mut self, f.area());
|
2026-01-17 17:09:42 -05:00
|
|
|
})?;
|
|
|
|
|
|
2026-01-20 17:19:13 -05:00
|
|
|
// TODO: Handle events based on which component is active.
|
|
|
|
|
if event::poll(Duration::from_millis(250)).context("event poll failed")? {
|
|
|
|
|
match self.handle_event(event::read()?)? {
|
2026-01-18 11:02:41 -05:00
|
|
|
Action::Quit => break,
|
2026-01-19 15:03:50 -05:00
|
|
|
Action::Handled => {}
|
|
|
|
|
_ => {
|
2026-01-20 17:37:15 -05:00
|
|
|
// bail!("Unhandled event: {:?}", event);
|
2026-01-19 15:03:50 -05:00
|
|
|
}
|
2026-01-18 10:09:28 -05:00
|
|
|
}
|
2026-01-17 14:04:02 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 17:09:42 -05:00
|
|
|
fn draw_status(&self, area: Rect, buf: &mut Buffer) {
|
|
|
|
|
// TODO: Status bar should have drop down menus
|
2026-01-17 14:04:02 -05:00
|
|
|
Tabs::new(["File", "Edit", "View", "Help"])
|
|
|
|
|
.style(Style::default())
|
|
|
|
|
.block(Block::default().borders(Borders::ALL))
|
|
|
|
|
.render(area, buf);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 17:09:42 -05:00
|
|
|
fn draw_tabs(&self, area: Rect, buf: &mut Buffer) {
|
2026-01-20 12:00:24 -05:00
|
|
|
// Determine the tab title from the current file (or use a fallback).
|
2026-01-20 16:03:38 -05:00
|
|
|
let mut title: Option<&str> = None;
|
2026-01-20 20:43:01 -05:00
|
|
|
if let Some(editor) = self.get_component::<Editor>() {
|
2026-01-20 16:03:38 -05:00
|
|
|
title = editor
|
|
|
|
|
.file_path
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|p| p.file_name())
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Tabs::new(vec![title.unwrap_or("Unknown")])
|
2026-01-17 14:04:02 -05:00
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 18:01:35 -05:00
|
|
|
/// 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.
|
2026-01-20 16:03:38 -05:00
|
|
|
fn refresh_editor_contents(&mut self) -> Result<()> {
|
|
|
|
|
// Use the currently selected TreeItem or get an absolute path to this source file.
|
2026-01-20 20:43:01 -05:00
|
|
|
let selected_pathbuf = match self.get_component::<Explorer>().unwrap().selected() {
|
2026-01-20 16:03:38 -05:00
|
|
|
Ok(path) => PathBuf::from(path),
|
|
|
|
|
Err(_) => PathBuf::from(std::path::absolute(file!())?.to_string_lossy().to_string()),
|
|
|
|
|
};
|
|
|
|
|
let editor = self
|
2026-01-20 20:43:01 -05:00
|
|
|
.get_component_mut::<Editor>()
|
2026-01-20 17:19:13 -05:00
|
|
|
.context("Failed to get active editor while refreshing contents.")?;
|
2026-01-20 16:03:38 -05:00
|
|
|
if let Some(current_file_path) = editor.file_path.clone() {
|
|
|
|
|
if selected_pathbuf == current_file_path || !selected_pathbuf.is_file() {
|
|
|
|
|
return Ok(());
|
2026-01-19 18:01:35 -05:00
|
|
|
}
|
2026-01-20 16:03:38 -05:00
|
|
|
return editor.set_contents(&selected_pathbuf);
|
2026-01-19 18:01:35 -05:00
|
|
|
}
|
2026-01-20 17:37:15 -05:00
|
|
|
bail!("Failed to refresh editor contents")
|
2026-01-19 18:01:35 -05:00
|
|
|
}
|
2026-01-17 14:04:02 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: Separate complex components into their own widgets.
|
2026-01-18 10:09:28 -05:00
|
|
|
impl<'a> Widget for &mut App<'a> {
|
2026-01-17 14:04:02 -05:00
|
|
|
fn render(self, area: Rect, buf: &mut Buffer)
|
|
|
|
|
where
|
|
|
|
|
Self: Sized,
|
|
|
|
|
{
|
|
|
|
|
let vertical = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Length(3), // status bar
|
|
|
|
|
Constraint::Percentage(70), // horizontal layout
|
|
|
|
|
Constraint::Percentage(30), // terminal
|
|
|
|
|
])
|
|
|
|
|
.split(area);
|
|
|
|
|
|
|
|
|
|
let horizontal = Layout::default()
|
|
|
|
|
.direction(Direction::Horizontal)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Max(30), // File explorer with a max width of 30 characters.
|
|
|
|
|
Constraint::Fill(1), // Editor fills the remaining space.
|
|
|
|
|
])
|
|
|
|
|
.split(vertical[1]);
|
|
|
|
|
|
|
|
|
|
let editor_layout = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Length(1), // Editor tabs.
|
2026-01-17 17:09:42 -05:00
|
|
|
Constraint::Fill(1), // Editor contents.
|
2026-01-17 14:04:02 -05:00
|
|
|
])
|
|
|
|
|
.split(horizontal[1]);
|
|
|
|
|
|
|
|
|
|
self.draw_status(vertical[0], buf);
|
|
|
|
|
self.draw_tabs(editor_layout[0], buf);
|
2026-01-20 20:43:01 -05:00
|
|
|
let id = self.id().to_string();
|
2026-01-20 20:14:25 -05:00
|
|
|
for component in &mut self.components {
|
|
|
|
|
match component {
|
|
|
|
|
AppComponents::AppEditor(editor) => editor.render(editor_layout[1], buf),
|
|
|
|
|
AppComponents::AppExplorer(explorer) => {
|
|
|
|
|
explorer
|
|
|
|
|
.render(horizontal[0], buf)
|
2026-01-20 20:43:01 -05:00
|
|
|
.context("Failed to render Explorer")
|
|
|
|
|
.unwrap_or_else(|e| error!(target:id.as_str(), "{}", e));
|
2026-01-20 20:14:25 -05:00
|
|
|
}
|
|
|
|
|
AppComponents::AppLogger(logger) => logger.render(vertical[2], buf),
|
|
|
|
|
AppComponents::AppComponent(_) => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-17 14:04:02 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-17 17:09:42 -05:00
|
|
|
|
2026-01-19 09:23:12 -05:00
|
|
|
impl<'a> Component for App<'a> {
|
2026-01-19 15:03:50 -05:00
|
|
|
fn id(&self) -> &str {
|
2026-01-20 20:14:25 -05:00
|
|
|
"App"
|
2026-01-19 15:03:50 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// TODO: Get active widget with some Component trait function helper?
|
|
|
|
|
/// trait Component { fn get_state() -> ComponentState; }
|
|
|
|
|
/// if component.get_state() = ComponentState::Active { component.handle_event(); }
|
|
|
|
|
///
|
|
|
|
|
/// App could then provide helpers for altering Component state based on TUI grouping..
|
|
|
|
|
/// (such as editor tabs, file explorer, status bars, etc..)
|
2026-01-20 16:03:38 -05:00
|
|
|
///
|
|
|
|
|
/// Handles events for the App and delegates to attached Components.
|
2026-01-20 17:19:13 -05:00
|
|
|
fn handle_event(&mut self, event: Event) -> Result<Action> {
|
2026-01-19 15:03:50 -05:00
|
|
|
// Handle events in the primary application.
|
|
|
|
|
if let Some(key_event) = event.as_key_event() {
|
2026-01-20 17:19:13 -05:00
|
|
|
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,
|
2026-01-19 15:03:50 -05:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle events for all components.
|
2026-01-20 16:03:38 -05:00
|
|
|
for component in &mut self.components {
|
|
|
|
|
let action = match component {
|
2026-01-20 17:19:13 -05:00
|
|
|
AppComponents::AppEditor(editor) => editor.handle_event(event.clone())?,
|
|
|
|
|
AppComponents::AppExplorer(explorer) => explorer.handle_event(event.clone())?,
|
|
|
|
|
AppComponents::AppComponent(comp) => comp.handle_event(event.clone())?,
|
2026-01-20 20:14:25 -05:00
|
|
|
AppComponents::AppLogger(logger) => logger.handle_event(event.clone())?,
|
2026-01-20 16:03:38 -05:00
|
|
|
};
|
|
|
|
|
// Actions returned here abort the input handling iteration.
|
|
|
|
|
match action {
|
2026-01-20 17:19:13 -05:00
|
|
|
Action::Quit | Action::Handled => return Ok(action),
|
2026-01-20 16:03:38 -05:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-20 17:19:13 -05:00
|
|
|
Ok(Action::Noop)
|
2026-01-19 15:03:50 -05:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 16:03:38 -05:00
|
|
|
/// Handles key events for the App Component only.
|
2026-01-20 17:19:13 -05:00
|
|
|
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
|
2026-01-18 10:09:28 -05:00
|
|
|
match key {
|
2026-01-20 20:14:25 -05:00
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('l'),
|
|
|
|
|
modifiers: KeyModifiers::CONTROL,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
|
|
|
|
} => {
|
|
|
|
|
// Some example logs for testing.
|
|
|
|
|
error!(target:self.id(), "an error");
|
|
|
|
|
warn!(target:self.id(), "a warning");
|
|
|
|
|
info!(target:self.id(), "a two line info\nsecond line");
|
|
|
|
|
debug!(target:self.id(), "a debug");
|
|
|
|
|
trace!(target:self.id(), "a trace");
|
|
|
|
|
Ok(Action::Noop)
|
|
|
|
|
}
|
2026-01-18 10:09:28 -05:00
|
|
|
KeyEvent {
|
|
|
|
|
code: KeyCode::Char('c'),
|
|
|
|
|
modifiers: KeyModifiers::CONTROL,
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
state: _state,
|
2026-01-20 17:19:13 -05:00
|
|
|
} => Ok(Action::Quit),
|
|
|
|
|
_ => Ok(Action::Noop),
|
2026-01-18 10:09:28 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|