clide/src/tui/app.rs

271 lines
9.5 KiB
Rust
Raw Normal View History

2026-01-19 09:23:12 -05:00
use crate::tui::component::{Action, Component};
use crate::tui::editor::Editor;
2026-01-17 17:09:42 -05:00
use crate::tui::explorer::Explorer;
2026-01-20 17:37:15 -05:00
use anyhow::{Context, Result, anyhow, bail};
2026-01-17 14:04:02 -05:00
use ratatui::buffer::Buffer;
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};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Tabs, Wrap};
use ratatui::{DefaultTerminal, symbols};
use std::path::PathBuf;
use std::time::Duration;
pub enum AppComponents<'a> {
AppEditor(Editor),
AppExplorer(Explorer<'a>),
AppComponent(Box<dyn Component>),
}
2026-01-17 14:04:02 -05:00
pub struct App<'a> {
components: Vec<AppComponents<'a>>,
2026-01-17 14:04:02 -05:00
}
impl<'a> App<'a> {
pub fn new(root_path: PathBuf) -> Result<Self> {
let mut app = Self {
components: vec![
AppComponents::AppExplorer(Explorer::new(&root_path)?),
AppComponents::AppEditor(Editor::new()),
],
};
app.get_editor_mut()
.unwrap()
.set_contents(&root_path.join("src/tui/app.rs"))
.context(format!(
"Failed to initialize editor contents to path: {}",
root_path.to_string_lossy()
))?;
Ok(app)
2026-01-17 14:04:02 -05:00
}
fn get_explorer(&self) -> Result<&Explorer<'a>> {
for component in &self.components {
if let AppComponents::AppExplorer(explorer) = component {
return Ok(explorer);
}
}
2026-01-20 17:37:15 -05:00
bail!("Failed to find project explorer widget.")
}
fn get_explorer_mut(&mut self) -> Result<&mut Explorer<'a>> {
for component in &mut self.components {
if let AppComponents::AppExplorer(explorer) = component {
return Ok(explorer);
}
}
2026-01-20 17:37:15 -05:00
bail!("Failed to find project explorer widget.")
}
fn get_editor(&self) -> Option<&Editor> {
for component in &self.components {
if let AppComponents::AppEditor(editor) = component {
return Some(editor);
}
}
// There is no editor currently opened.
None
}
fn get_editor_mut(&mut self) -> Option<&mut Editor> {
for component in &mut self.components {
if let AppComponents::AppEditor(editor) = component {
return Some(editor);
}
}
// There is no editor currently opened.
None
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
2026-01-17 14:04:02 -05:00
loop {
self.refresh_editor_contents()
.context("Failed to refresh editor contents.")?;
2026-01-17 17:09:42 -05:00
terminal.draw(|f| {
f.render_widget(&mut self, f.area());
2026-01-17 17:09:42 -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()?)? {
Action::Quit => break,
Action::Handled => {}
_ => {
2026-01-20 17:37:15 -05:00
// bail!("Unhandled event: {:?}", event);
}
}
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) {
// Determine the tab title from the current file (or use a fallback).
let mut title: Option<&str> = None;
if let Some(editor) = self.get_editor() {
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-17 17:09:42 -05:00
fn draw_terminal(&self, area: Rect, buf: &mut Buffer) {
2026-01-17 14:04:02 -05:00
// TODO: Title should be detected shell name
// TODO: Contents should be shell output
Paragraph::new("shaun@pc:~/Code/clide$ ")
2026-01-17 14:04:02 -05:00
.style(Style::default())
.block(
Block::default()
.title("Bash")
.title_style(Style::default().fg(Color::DarkGray))
.borders(Borders::ALL),
)
.wrap(Wrap { trim: false })
.render(area, buf);
}
/// 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<()> {
// Use the currently selected TreeItem or get an absolute path to this source file.
let selected_pathbuf = match self.get_explorer()?.selected() {
Ok(path) => PathBuf::from(path),
Err(_) => PathBuf::from(std::path::absolute(file!())?.to_string_lossy().to_string()),
};
let editor = self
.get_editor_mut()
.context("Failed to get active editor while refreshing contents.")?;
if let Some(current_file_path) = editor.file_path.clone() {
if selected_pathbuf == current_file_path || !selected_pathbuf.is_file() {
return Ok(());
}
return editor.set_contents(&selected_pathbuf);
}
2026-01-20 17:37:15 -05:00
bail!("Failed to refresh editor contents")
}
2026-01-17 14:04:02 -05:00
}
// TODO: Separate complex components into their own widgets.
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_terminal(vertical[2], buf);
if let Ok(explorer) = self.get_explorer_mut() {
2026-01-20 17:37:15 -05:00
// TODO: What to do about errors during rendering?
// Once there is a debug console, maybe log it and discard? Panic isn't great.
explorer
.render(horizontal[0], buf)
.expect("Failed to render Explorer");
}
2026-01-17 14:04:02 -05:00
self.draw_tabs(editor_layout[0], buf);
self.get_editor_mut().unwrap().render(editor_layout[1], buf);
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> {
fn id(&self) -> &str {
"app"
}
/// 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..)
///
/// 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.
for component in &mut self.components {
let action = match component {
AppComponents::AppEditor(editor) => editor.handle_event(event.clone())?,
AppComponents::AppExplorer(explorer) => explorer.handle_event(event.clone())?,
AppComponents::AppComponent(comp) => comp.handle_event(event.clone())?,
};
// Actions returned here abort the input handling iteration.
match action {
Action::Quit | Action::Handled => return Ok(action),
_ => {}
}
}
Ok(Action::Noop)
}
/// Handles key events for the App Component only.
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
match key {
KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: _state,
} => Ok(Action::Quit),
_ => Ok(Action::Noop),
}
}
}