23 Commits

Author SHA1 Message Date
9fc7864f31 Update README. 2026-01-25 15:46:17 -05:00
439d3af7d3 [tui] Open application with no editor tab.
The explorer can be used to open the first tab.
2026-01-25 15:22:47 -05:00
ae9f787c81 [tui] Add About page within Help MenuBar. 2026-01-25 14:57:34 -05:00
76fe09f39b [tui] Implement View MenuBar actions.
You can show / hide the Logger and the Explorer now.
2026-01-25 12:45:52 -05:00
fa36a633ee [tui] Add File MenuBar options Reload and Close. 2026-01-25 12:04:31 -05:00
6c2f3f9005 [tui] Highlight border of active widget. 2026-01-25 10:16:08 -05:00
a3d850acd9 [tui] Clean up logger and editor.
Filter some noisy system logs in the Logger by default.
Use LLD for linking to silence warning for deprecated gold linker.
2026-01-25 10:16:03 -05:00
01eeb9f0ac [tui] Add more logging. 2026-01-25 09:07:41 -05:00
711f92b7dd [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
2026-01-25 01:32:46 -05:00
5d2a7fa0a1 [tui] Rename title_bar.rs 2026-01-24 16:31:36 -05:00
45d665f8f6 [tui] Implement Widget for Explorer and MenuBar. 2026-01-24 16:30:22 -05:00
f531886255 [tui] Handle MenuBar actions.
Fixes #7.
2026-01-24 16:06:50 -05:00
4e9aedd34c [tui] Renames. 2026-01-24 15:40:19 -05:00
78c13f5766 [tui] Add TitleBar popups for drop-down menus. 2026-01-24 15:33:48 -05:00
82ad3ab29f [tui] Add TitleBar struct to handle rendering.
This will support adding drop-down menus.
For now, the widget just highlights which item you selected in the title
bar with left / right keys.
2026-01-24 14:22:42 -05:00
dd55d7fc5f [tui] Handle mouse input for all widgets.
This way you can still click to interact with the file explorer while
editing a file, for example, without changing widget focus.
2026-01-24 12:49:33 -05:00
aa4bf8aea6 [tui] Add help text for last focused widget.
+ Fill in TODO help text for all widgets.
2026-01-24 12:32:08 -05:00
029e0b2952 [tui] Remove AppComponent data.
It just seems to be simpler this way.
2026-01-24 11:46:00 -05:00
a3c1065f96 [tui] Add bottom status bar with help text.
Fixes #3
2026-01-22 20:36:26 -05:00
0c87fda795 [tui] Add basic support for focusing widgets.
It's pretty bad but it allows to control which widget accepts input.
2026-01-22 19:47:59 -05:00
a4413cd052 [tui] Clean up logging. 2026-01-21 20:28:24 -05:00
4d81cd51a6 [tui] Add ComponentOf trait.
I think it will help with fetching a component by type from the
Components vector attached to App?
2026-01-20 20:50:36 -05:00
7149ad0118 [tui] Add debug console.
The input will not be handled correctly until #8 is complete, but the
input logic is there and was tested.

Fixes #5.
2026-01-20 20:50:27 -05:00
17 changed files with 1377 additions and 303 deletions

3
.cargo/config.toml Normal file
View File

@@ -0,0 +1,3 @@
[build]
rustflags = [ "-C", "link-arg=-fuse-ld=lld", ]

29
Cargo.lock generated
View File

@@ -299,6 +299,7 @@ dependencies = [
"edtui",
"log",
"ratatui",
"strum",
"syntect",
"tui-logger",
"tui-tree-widget",
@@ -451,9 +452,9 @@ dependencies = [
[[package]]
name = "cxx"
version = "1.0.192"
version = "1.0.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbda285ba6e5866529faf76352bdf73801d9b44a6308d7cd58ca2379f378e994"
checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e"
dependencies = [
"cc",
"cxx-build",
@@ -466,9 +467,9 @@ dependencies = [
[[package]]
name = "cxx-build"
version = "1.0.192"
version = "1.0.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9efde466c5d532d57efd92f861da3bdb7f61e369128ce8b4c3fe0c9de4fa4d"
checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e"
dependencies = [
"cc",
"codespan-reporting 0.13.1",
@@ -481,9 +482,9 @@ dependencies = [
[[package]]
name = "cxx-gen"
version = "0.7.192"
version = "0.7.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee08d1131e8f050a1d1acbb7c699e5c8d29c325dffc382331c280d99f98c2618"
checksum = "035b6c61a944483e8a4b2ad4fb8b13830d63491bd004943716ad16d85dcc64bc"
dependencies = [
"codespan-reporting 0.13.1",
"indexmap",
@@ -564,9 +565,9 @@ dependencies = [
[[package]]
name = "cxxbridge-cmd"
version = "1.0.192"
version = "1.0.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3efb93799095bccd4f763ca07997dc39a69e5e61ab52d2c407d4988d21ce144d"
checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328"
dependencies = [
"clap",
"codespan-reporting 0.13.1",
@@ -578,15 +579,15 @@ dependencies = [
[[package]]
name = "cxxbridge-flags"
version = "1.0.192"
version = "1.0.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3092010228026e143b32a4463ed9fa8f86dca266af4bf5f3b2a26e113dbe4e45"
checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a"
[[package]]
name = "cxxbridge-macro"
version = "1.0.192"
version = "1.0.194"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d72ebfcd351ae404fb00ff378dfc9571827a00722c9e735c9181aec320ba0a"
checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf"
dependencies = [
"indexmap",
"proc-macro2",
@@ -1599,9 +1600,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.105"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]

View File

@@ -16,6 +16,7 @@ anyhow = "1.0.100"
tui-tree-widget = "0.24.0"
tui-logger = "0.18.1"
edtui = "0.11.1"
strum = "0.27.2"
[build-dependencies]
# The link_qt_object_files feature is required for statically linking Qt 6.

View File

@@ -1,15 +1,10 @@
# CLIDE
CLIDE is a barebones but extendable IDE written in Rust using the Qt UI framework that supports both full and headless Linux environments.
The core application will provide you with a text editor that can be extended with plugins written in Rust.
CLIDE is an extendable command-line driven development environment written in Rust using the Qt UI framework that supports both full and headless Linux environments.
The GUI is written in QML compiled through Rust using the cxx-qt crate, while the TUI was implemented using the ratatui crate.
The UI is written in QML and compiled to C++ using `cxx`, which is then linked into the Rust application.
It's up to you to build your own development environment for your tools.
This project is intended to be a light-weight core application with no language-specific tools or features.
To add tools for your purposes, create a plugin that implements the `ClidePlugin` trait. (This is currently under development and not yet available.)
Once you've created your plugin, you can submit a pull request to add your plugin to the final section in this README if you'd like to contribute.
If this section becomes too large, we may explore other options to distribute plugins.
It's up to you to build your own development environment for your tools. Plugins are planned to be supported in the future for bringing your own language-specific tools or features.
Once you've created your plugin, you can submit a pull request to add a link to the git repository for your plugin to the final section in this README if you'd like to contribute.
The following packages must be installed before the application will build.
In the future, we may provide a minimal installation option that only includes dependencies for the headless TUI.
@@ -24,6 +19,68 @@ And of course, [Rust](https://www.rust-lang.org/tools/install).
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
## Usage
To install and run clide
```bash
git clone https://git.shaunreed.com/shaunrd0/clide
cd clide
cargo install --path .
```
After installation `clide` can be used directly
```bash
clide --help
Extendable command-line driven development environment written in Rust using the Qt UI framework.
If no flags are provided, the GUI editor is launched in a separate process.
If no path is provided, the current directory is used.
Usage: clide [OPTIONS] [PATH]
Arguments:
[PATH] The root directory for the project to open with the clide editor
Options:
-t, --tui Run clide in headless mode
-g, --gui Run the clide GUI in the current process, blocking the terminal and showing all output streams
-h, --help Print help
```
### TUI
The TUI is implemented using the ratatui crate and has the typical features you would expect from a text editor.
You can browse your project tree, open / close new editor tabs, and save / reload files.
Controls for the TUI are listed at the bottom of the window, and update depending on which widget you have focused.
For now, there are no language-specific features or plugins available for the TUI it is only a text editor.
To run the TUI, pass the `-t` or `--tui` flags.
```bash
# With cargo from the project root
cargo run -- -t
# Or via clide directly after installation
clide -t
```
![image](./resources/tui.png)
### GUI
The GUI is still in development. It is at this point a text viewer, instead of a text editor.
There are many placeholder buttons and features in the GUI that do nothing when used.
The GUI is run by default when executing the `clide` application.
```bash
# With cargo from the project root
cargo run
# Or via clide directly after installation
clide
```
## Development
It's recommended to use RustRover or Qt Creator for development.

BIN
resources/tui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

View File

@@ -1,11 +1,12 @@
use anyhow::Result;
use cxx_qt_lib::QString;
use log::trace;
pub mod colors;
pub mod filesystem;
pub fn run(root_path: std::path::PathBuf) -> Result<()> {
println!("Starting the GUI editor at {:?}", root_path);
trace!(target:"gui::run()", "Starting the GUI editor at {root_path:?}");
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl};

View File

@@ -73,10 +73,10 @@ impl qobject::FileSystem {
return QString::default();
}
if !fs::metadata(path.to_string())
.expect(format!("Failed to get file metadata {}", path).as_str())
.expect(format!("Failed to get file metadata {path:?}").as_str())
.is_file()
{
warn!("Attempted to open file {} that is not a valid file", path);
warn!(target:"FileSystem", "Attempted to open file {path:?} that is not a valid file");
return QString::default();
}
let ss = SyntaxSet::load_defaults_nonewlines();
@@ -118,7 +118,7 @@ impl qobject::FileSystem {
fn set_directory(self: std::pin::Pin<&mut Self>, path: &QString) -> QModelIndex {
if !path.is_empty()
&& fs::metadata(path.to_string())
.expect(format!("Failed to get metadata for path {}", path).as_str())
.expect(format!("Failed to get metadata for path {path:?}").as_str())
.is_dir()
{
self.set_root_path(path)

View File

@@ -1,14 +1,12 @@
use crate::tui::Tui;
use anyhow::{Context, Result};
use clap::Parser;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io::stdout;
use log::{info, trace};
use std::process::{Command, Stdio};
use crate::tui::Tui;
pub mod gui;
pub mod tui;
/// Command line interface IDE with full GUI and headless modes.
/// Extendable command-line driven development environment written in Rust using the Qt UI framework.
/// If no flags are provided, the GUI editor is launched in a separate process.
/// If no path is provided, the current directory is used.
#[derive(Parser, Debug)]
@@ -39,14 +37,21 @@ fn main() -> Result<()> {
dirs::home_dir().context("Failed to obtain home directory")?,
),
};
info!(target:"main()", "Root path detected: {root_path:?}");
match args.gui {
true => gui::run(root_path),
true => {
trace!(target:"main()", "Starting GUI");
gui::run(root_path)
}
false => match args.tui {
// Open the TUI editor if requested, otherwise use the QML GUI by default.
true => Ok(Tui::new(root_path)?.start()?),
true => {
trace!(target:"main()", "Starting TUI");
Ok(Tui::new(root_path)?.start()?)
}
false => {
// Relaunch the CLIDE GUI in a separate process.
trace!(target:"main()", "Starting GUI in a new process");
Command::new(std::env::current_exe()?)
.args(&["--gui", root_path.to_str().unwrap()])
.stdout(Stdio::null())

View File

@@ -1,11 +1,14 @@
mod about;
mod app;
mod component;
mod editor;
mod editor_tab;
mod explorer;
mod logger;
mod menu_bar;
use anyhow::{Context, Result};
use log::{LevelFilter, debug};
use log::{LevelFilter, debug, info, trace};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
@@ -26,19 +29,27 @@ pub struct Tui {
}
impl Tui {
pub fn id() -> &'static str {
"Tui"
}
pub fn new(root_path: std::path::PathBuf) -> Result<Self> {
trace!(target:Self::id(), "Building {}", Self::id());
init_logger(LevelFilter::Trace)?;
set_default_level(LevelFilter::Trace);
debug!(target:"Tui", "Logging initialized");
debug!(target:Self::id(), "Logging initialized");
let mut dir = env::temp_dir();
dir.push("clide.log");
let file_options = TuiLoggerFile::new(dir.to_str().unwrap())
.output_level(Some(TuiLoggerLevelOutput::Abbreviated))
.output_file(false)
.output_separator(':');
let file_options = TuiLoggerFile::new(
dir.to_str()
.context("Failed to set temp directory for file logging")?,
)
.output_level(Some(TuiLoggerLevelOutput::Abbreviated))
.output_file(false)
.output_separator(':');
set_log_file(file_options);
debug!(target:"Tui", "Logging to file: {}", dir.to_str().unwrap());
debug!(target:Self::id(), "Logging to file: {dir:?}");
Ok(Self {
terminal: Terminal::new(CrosstermBackend::new(stdout()))?,
@@ -47,7 +58,7 @@ impl Tui {
}
pub fn start(self) -> Result<()> {
println!("Starting the TUI editor at {:?}", self.root_path);
info!(target:Self::id(), "Starting the TUI editor at {:?}", self.root_path);
ratatui::crossterm::execute!(
stdout(),
EnterAlternateScreen,
@@ -64,6 +75,7 @@ impl Tui {
}
fn stop() -> Result<()> {
info!(target:Self::id(), "Stopping the TUI editor");
disable_raw_mode()?;
ratatui::crossterm::execute!(
stdout(),

138
src/tui/about.rs Normal file
View File

@@ -0,0 +1,138 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};
pub struct About {}
impl About {
#[allow(unused)]
pub fn id() -> &'static str {
"About"
}
pub fn new() -> Self {
// trace!(target:Self::id(), "Building {}", Self::id());
Self {}
}
}
impl Widget for About {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Clear::default().render(area, buf);
// Split main area
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(2), // image column
Constraint::Fill(1), // image column
Constraint::Fill(2), // text column
])
.split(area);
let top_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Fill(3),
Constraint::Fill(1),
])
.split(chunks[1]);
let bottom_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Fill(3),
Constraint::Fill(1),
])
.split(chunks[2]);
// ---------- IMAGE ----------
let kilroy_art = [
" * ",
" |.===. ",
" {}o o{} ",
"-----------------------ooO--(_)--Ooo---------------------------",
"# #",
"# CLIDE WAS HERE #",
"# #",
"# https://git.shaunreed.com/shaunred/clide #",
"# https://shaunreed.com/shaunred/clide #",
"# #",
];
let kilroy_lines: Vec<Line> = kilroy_art
.iter()
.map(|l| Line::from(Span::raw(*l)))
.collect();
Paragraph::new(kilroy_lines)
.block(
Block::default()
.borders(Borders::NONE)
.padding(Padding::bottom(0)),
)
.wrap(Wrap { trim: false })
.centered()
.render(top_chunks[1], buf);
// ---------- TEXT ----------
let about_text = vec![
Line::from(vec![Span::styled(
"clide\n",
Style::default().add_modifier(Modifier::BOLD),
)])
.centered(),
Line::from(""),
Line::from(vec![
Span::styled("Author: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("Shaun Reed"),
])
.left_aligned(),
Line::from(vec![
Span::styled("Email: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("shaunrd0@gmail.com"),
])
.left_aligned(),
Line::from(vec![
Span::styled("URL: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("https://git.shaunreed.com/shaunrd0/clide"),
])
.left_aligned(),
Line::from(vec![
Span::styled("Blog: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("https://shaunreed.com"),
])
.left_aligned(),
Line::from(""),
Line::from(vec![Span::styled(
"Description\n",
Style::default().add_modifier(Modifier::BOLD),
)])
.left_aligned(),
Line::from(concat!(
"CLIDE is an extendable command-line driven development environment written in Rust using the Qt UI framework that supports both full and headless Linux environments. ",
"The GUI is written in QML compiled through Rust using the cxx-qt crate, while the TUI was implemented using the ratatui crate. ",
))
.style(Style::default())
.left_aligned(),
];
Block::bordered().render(area, buf);
let paragraph = Paragraph::new(about_text)
.block(
Block::default()
.title("About")
.borders(Borders::ALL)
.padding(Padding::top(0)),
)
.wrap(Wrap { trim: true });
paragraph.render(bottom_chunks[1], buf);
}
}

View File

@@ -1,129 +1,74 @@
use crate::tui::component::{Action, Component};
use crate::tui::editor::Editor;
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 anyhow::{Context, Result, anyhow, bail};
use log::{debug, error, info, trace, warn};
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};
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;
// 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.
pub enum AppComponents<'a> {
AppEditor(Editor),
AppExplorer(Explorer<'a>),
AppLogger(Logger),
AppComponent(Box<dyn Component>),
}
/// Usage: get_component_mut::<Editor>() OR get_component::<Editor>()
///
/// Implementing this trait for each AppComponent allows for easy lookup in the vector.
trait ComponentOf<T> {
fn as_ref(&self) -> Option<&T>;
fn as_mut(&mut self) -> Option<&mut T>;
}
impl<'a> ComponentOf<Logger> for AppComponents<'a> {
fn as_ref(&self) -> Option<&Logger> {
if let AppComponents::AppLogger(ref e) = *self {
return Some(e);
}
None
}
fn as_mut(&mut self) -> Option<&mut Logger> {
if let AppComponents::AppLogger(ref mut e) = *self {
return Some(e);
}
None
}
}
impl<'a> ComponentOf<Editor> for AppComponents<'a> {
fn as_ref(&self) -> Option<&Editor> {
if let AppComponents::AppEditor(ref e) = *self {
return Some(e);
}
None
}
fn as_mut(&mut self) -> Option<&mut Editor> {
if let AppComponents::AppEditor(ref mut e) = *self {
return Some(e);
}
None
}
}
impl<'a> ComponentOf<Explorer<'a>> for AppComponents<'a> {
fn as_ref(&self) -> Option<&Explorer<'a>> {
if let AppComponents::AppExplorer(ref e) = *self {
return Some(e);
}
None
}
fn as_mut(&mut self) -> Option<&mut Explorer<'a>> {
if let AppComponents::AppExplorer(ref mut e) = *self {
return Some(e);
}
None
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppComponent {
AppEditor,
AppExplorer,
AppLogger,
AppMenuBar,
}
pub struct App<'a> {
components: Vec<AppComponents<'a>>,
editor_tabs: EditorTab,
explorer: Explorer<'a>,
logger: Logger,
menu_bar: MenuBar,
last_active: AppComponent,
about: bool,
}
impl<'a> App<'a> {
pub fn id() -> &'static str {
"App"
}
pub fn new(root_path: PathBuf) -> Result<Self> {
let mut app = Self {
components: vec![
AppComponents::AppExplorer(Explorer::new(&root_path)?),
AppComponents::AppEditor(Editor::new()),
AppComponents::AppLogger(Logger::new()),
],
trace!(target:Self::id(), "Building {}", Self::id());
let app = Self {
editor_tabs: EditorTab::new(None),
explorer: Explorer::new(&root_path)?,
logger: Logger::new(),
menu_bar: MenuBar::new(),
last_active: AppEditor,
about: false,
};
app.get_component_mut::<Editor>()
.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)
}
fn get_component<T>(&self) -> Option<&T>
where
AppComponents<'a>: ComponentOf<T>,
{
self.components.iter().find_map(|c| c.as_ref())
}
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())
/// Logic that should be executed once on application startup.
pub fn start(&mut self) -> Result<()> {
trace!(target:Self::id(), "Starting App");
Ok(())
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
self.start()?;
trace!(target:Self::id(), "Entering App run loop");
loop {
self.refresh_editor_contents()
.context("Failed to refresh editor contents.")?;
terminal.draw(|f| {
f.render_widget(&mut self, f.area());
})?;
// 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,
@@ -137,119 +82,189 @@ impl<'a> App<'a> {
Ok(())
}
fn draw_status(&self, area: Rect, buf: &mut Buffer) {
// TODO: Status bar should have drop down menus
Tabs::new(["File", "Edit", "View", "Help"])
.style(Style::default())
.block(Block::default().borders(Borders::ALL))
.render(area, buf);
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 => match self.editor_tabs.current_editor() {
Some(editor) => editor.component_state.help_text.clone(),
None => {
if !self.editor_tabs.is_empty() {
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(),
};
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 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_component::<Editor>() {
title = editor
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
fn clear_focus(&mut self) {
info!(target:Self::id(), "Clearing all widget focus");
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);
match self.editor_tabs.current_editor_mut() {
None => {
error!(target:Self::id(), "Failed to get current Editor while clearing focus")
}
Some(editor) => editor.component_state.set_focus(Focus::Inactive),
}
}
Tabs::new(vec![title.unwrap_or("Unknown")])
.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);
fn change_focus(&mut self, focus: AppComponent) {
info!(target:Self::id(), "Changing widget focus to {:?}", focus);
self.clear_focus();
match focus {
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),
}
self.last_active = focus;
}
/// 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.
#[allow(unused)]
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.get_component::<Explorer>().unwrap().selected() {
Ok(path) => PathBuf::from(path),
Err(_) => PathBuf::from(std::path::absolute(file!())?.to_string_lossy().to_string()),
};
let editor = self
.get_component_mut::<Editor>()
.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);
}
bail!("Failed to refresh editor contents")
// 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(())
}
}
// TODO: Separate complex components into their own widgets.
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([
Constraint::Length(3), // status bar
Constraint::Percentage(70), // horizontal layout
Constraint::Percentage(30), // terminal
])
.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([
Constraint::Max(30), // File explorer with a max width of 30 characters.
Constraint::Fill(1), // Editor fills the remaining space.
])
.constraints(horizontal_constraints)
.split(vertical[1]);
let editor_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Editor tabs.
Constraint::Fill(1), // Editor contents.
])
.split(horizontal[1]);
self.draw_status(vertical[0], buf);
self.draw_tabs(editor_layout[0], buf);
let id = self.id().to_string();
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)
.context("Failed to render Explorer")
.unwrap_or_else(|e| error!(target:id.as_str(), "{}", e));
}
AppComponents::AppLogger(logger) => logger.render(vertical[2], buf),
AppComponents::AppComponent(_) => {}
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]);
self.editor_tabs
.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]);
self.editor_tabs
.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 {
let about_area = area.centered(Constraint::Percentage(50), Constraint::Percentage(45));
About::new().render(about_area, buf);
}
}
}
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.
@@ -262,40 +277,131 @@ impl<'a> Component for App<'a> {
_ => {}
}
}
// 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())?,
AppComponents::AppLogger(logger) => logger.handle_event(event.clone())?,
};
// Actions returned here abort the input handling iteration.
match action {
Action::Quit | Action::Handled => return Ok(action),
_ => {}
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())?,
};
// Components should always handle mouse events for click interaction.
if let Some(mouse) = event.as_mouse_event() {
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
if let Some(editor) = self.editor_tabs.current_editor_mut() {
editor.handle_mouse_events(mouse)?;
}
self.explorer.handle_mouse_events(mouse)?;
self.logger.handle_mouse_events(mouse)?;
}
}
Ok(Action::Noop)
// Handle actions returned from widgets that may need context on other widgets or app state.
match action {
Action::Quit | Action::Handled => Ok(action),
Action::Save => match self.editor_tabs.current_editor_mut() {
None => {
error!(target:Self::id(), "Failed to get current editor while handling App Action::Save");
Ok(Action::Noop)
}
Some(editor) => match editor.save() {
Ok(_) => Ok(Action::Handled),
Err(e) => {
error!(target:Self::id(), "Failed to save editor contents: {e}");
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)
}
}
Action::CloseTab => match self.editor_tabs.close_current_tab() {
Ok(_) => Ok(Action::Handled),
Err(_) => Ok(Action::Noop),
},
Action::ReloadFile => {
trace!(target:Self::id(), "Reloading file for current editor");
if let Some(editor) = self.editor_tabs.current_editor_mut() {
editor
.reload_contents()
.map(|_| Action::Handled)
.context("Failed to handle Action::ReloadFile")
} else {
error!(target:Self::id(), "Failed to get current editor while handling App Action::ReloadFile");
Ok(Action::Noop)
}
}
Action::ShowHideLogger => {
self.logger.component_state.togget_visible();
Ok(Action::Handled)
}
Action::ShowHideExplorer => {
self.explorer.component_state.togget_visible();
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('l'),
modifiers: KeyModifiers::CONTROL,
code: KeyCode::Char('q'),
modifiers: KeyModifiers::ALT,
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)
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'),

View File

@@ -1,7 +1,11 @@
#![allow(dead_code, unused_variables)]
use crate::tui::component::Focus::Inactive;
use Focus::Active;
use anyhow::Result;
use log::trace;
use ratatui::crossterm::event::{Event, KeyEvent, MouseEvent};
use ratatui::style::Color;
pub enum Action {
/// Exit the application.
@@ -19,13 +23,15 @@ pub enum Action {
/// The input was handled by a Component and should not be passed to the next component.
Handled,
OpenTab,
ReloadFile,
ShowHideExplorer,
ShowHideLogger,
ShowHideAbout,
CloseTab,
}
pub trait Component {
/// Returns a unique identifier for the component.
/// This is used for lookup in a container of Components.
fn id(&self) -> &str;
fn handle_event(&mut self, event: Event) -> Result<Action> {
match event {
Event::Key(key_event) => self.handle_key_events(key_event),
@@ -44,4 +50,118 @@ pub trait Component {
fn update(&mut self, action: Action) -> Result<Action> {
Ok(Action::Noop)
}
/// Override this method for creating components that conditionally handle input.
fn is_active(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Default)]
pub struct ComponentState {
pub(crate) focus: Focus,
pub(crate) vis: Visibility,
pub(crate) help_text: String,
}
impl ComponentState {
pub fn id() -> &'static str {
"ComponentState"
}
fn new() -> Self {
trace!(target:Self::id(), "Building {}", Self::id());
Self {
focus: Active,
vis: Visibility::Visible,
help_text: String::new(),
}
}
pub(crate) fn with_help_text(mut self, help_text: &str) -> Self {
self.help_text = help_text.into();
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum Focus {
Active,
#[default]
Inactive,
}
impl Focus {
pub(crate) fn get_active_color(&self) -> Color {
match self {
Active => Color::LightYellow,
Inactive => Color::White,
}
}
}
pub trait FocusState {
fn with_focus(self, focus: Focus) -> Self;
fn set_focus(&mut self, focus: Focus);
fn toggle_focus(&mut self);
fn get_active_color(&self) -> Color;
}
impl FocusState for ComponentState {
fn with_focus(self, focus: Focus) -> Self {
Self {
focus,
vis: Visibility::Visible,
help_text: self.help_text,
}
}
fn set_focus(&mut self, focus: Focus) {
self.focus = focus;
}
fn toggle_focus(&mut self) {
match self.focus {
Active => self.set_focus(Inactive),
Inactive => self.set_focus(Active),
}
}
fn get_active_color(&self) -> Color {
self.focus.get_active_color()
}
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
pub enum Visibility {
#[default]
Visible,
Hidden,
}
pub trait VisibleState {
fn with_visible(self, vis: Visibility) -> Self;
fn set_visible(&mut self, vis: Visibility);
fn togget_visible(&mut self);
}
impl VisibleState for ComponentState {
fn with_visible(self, vis: Visibility) -> Self {
Self {
focus: self.focus,
vis,
help_text: self.help_text,
}
}
fn set_visible(&mut self, vis: Visibility) {
self.vis = vis;
}
fn togget_visible(&mut self) {
match self.vis {
Visibility::Visible => self.set_visible(Visibility::Hidden),
Visibility::Hidden => self.set_visible(Visibility::Visible),
}
}
}

View File

@@ -1,9 +1,9 @@
use crate::tui::component::{Action, Component};
use crate::tui::component::{Action, Component, ComponentState, Focus, FocusState};
use anyhow::{Context, Result, bail};
use edtui::{
EditorEventHandler, EditorState, EditorTheme, EditorView, LineNumbers, Lines, SyntaxHighlighter,
};
use log::{error, trace};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Rect};
@@ -11,28 +11,46 @@ use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, Borders, Padding, Widget};
use syntect::parsing::SyntaxSet;
// TODO: Consider using editor-command https://docs.rs/editor-command/latest/editor_command/
// TODO: Title should be detected programming language name
// TODO: Content should be file contents
// TODO: Vimrc should be used
pub struct Editor {
pub state: EditorState,
pub event_handler: EditorEventHandler,
pub file_path: Option<std::path::PathBuf>,
syntax_set: SyntaxSet,
pub(crate) component_state: ComponentState,
}
impl Editor {
pub fn new() -> Self {
pub fn id() -> &'static str {
"Editor"
}
pub fn new(path: &std::path::PathBuf) -> Self {
trace!(target:Self::id(), "Building {}", Self::id());
Editor {
state: EditorState::default(),
event_handler: EditorEventHandler::default(),
file_path: None,
file_path: Some(path.to_owned()),
syntax_set: SyntaxSet::load_defaults_nonewlines(),
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"
)),
}
}
pub fn reload_contents(&mut self) -> Result<()> {
trace!(target:Self::id(), "Reloading editor file contents {:?}", self.file_path);
match self.file_path.clone() {
None => {
error!(target:Self::id(), "Failed to reload editor contents with None file_path");
bail!("Failed to reload editor contents with None file_path")
}
Some(path) => self.set_contents(&path),
}
}
pub fn set_contents(&mut self, path: &std::path::PathBuf) -> Result<()> {
trace!(target:Self::id(), "Setting Editor contents from path {:?}", path);
if let Ok(contents) = std::fs::read_to_string(path) {
let lines: Vec<_> = contents
.lines()
@@ -40,14 +58,18 @@ impl Editor {
.collect();
self.file_path = Some(path.clone());
self.state.lines = Lines::new(lines);
self.state.cursor.row = 0;
self.state.cursor.col = 0;
}
Ok(())
}
pub fn save(&self) -> Result<()> {
if let Some(path) = &self.file_path {
trace!(target:Self::id(), "Saving Editor contents {:?}", path);
return std::fs::write(path, self.state.lines.to_string()).map_err(|e| e.into());
};
error!(target:Self::id(), "Failed saving Editor contents; file_path was None");
bail!("File not saved. No file path set.")
}
}
@@ -75,7 +97,8 @@ impl Widget for &mut Editor {
.title_style(Style::default().fg(Color::Yellow))
.title_alignment(Alignment::Right)
.borders(Borders::ALL)
.padding(Padding::new(0, 0, 0, 1)),
.padding(Padding::new(0, 0, 0, 1))
.style(Style::default().fg(self.component_state.get_active_color())),
),
)
.syntax_highlighter(SyntaxHighlighter::new("dracula", lang).ok())
@@ -86,10 +109,6 @@ impl Widget for &mut Editor {
}
impl Component for Editor {
fn id(&self) -> &str {
"Editor"
}
fn handle_event(&mut self, event: Event) -> Result<Action> {
if let Some(key_event) = event.as_key_event() {
// Handle events here that should not be passed on to the vim emulation handler.
@@ -118,4 +137,8 @@ impl Component for Editor {
_ => Ok(Action::Noop),
}
}
fn is_active(&self) -> bool {
self.component_state.focus == Focus::Active
}
}

250
src/tui/editor_tab.rs Normal file
View File

@@ -0,0 +1,250 @@
use crate::tui::component::{Action, Component, Focus, FocusState};
use crate::tui::editor::Editor;
use anyhow::{Context, Result, anyhow};
use log::{error, info, trace, warn};
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<String, Editor>,
tab_order: Vec<String>,
current_editor: usize,
}
impl EditorTab {
fn id() -> &'static str {
"EditorTab"
}
pub fn new(path: Option<&std::path::PathBuf>) -> Self {
trace!(target:Self::id(), "Building EditorTab with path {path:?}");
match path {
None => Self {
editors: HashMap::new(),
tab_order: Vec::new(),
current_editor: 0,
},
Some(path) => {
let tab_order = vec![path.to_string_lossy().to_string()];
Self {
editors: HashMap::from([(
tab_order.first().unwrap().to_owned(),
Editor::new(path),
)]),
tab_order,
current_editor: 0,
}
}
}
}
pub fn next_editor(&mut self) {
let next = (self.current_editor + 1) % self.tab_order.len();
trace!(target:Self::id(), "Moving from {} to next editor tab at {}", self.current_editor, next);
self.set_tab_focus(Focus::Active, next);
self.current_editor = next;
}
pub fn prev_editor(&mut self) {
let prev = self
.current_editor
.checked_sub(1)
.unwrap_or(self.tab_order.len() - 1);
trace!(target:Self::id(), "Moving from {} to previous editor tab at {}", self.current_editor, prev);
self.set_tab_focus(Focus::Active, prev);
self.current_editor = prev;
}
pub fn get_editor_key(&self, index: usize) -> Option<String> {
match self.tab_order.get(index) {
None => {
if !self.tab_order.is_empty() {
error!(target:Self::id(), "Failed to get editor tab key with invalid index {index}");
}
None
}
Some(key) => Some(key.to_owned()),
}
}
pub fn current_editor(&self) -> Option<&Editor> {
self.editors.get(&self.get_editor_key(self.current_editor)?)
}
pub fn current_editor_mut(&mut self) -> Option<&mut Editor> {
self.editors
.get_mut(&self.get_editor_key(self.current_editor)?)
}
pub fn set_current_tab_focus(&mut self, focus: Focus) {
trace!(target:Self::id(), "Setting current tab {} focus to {:?}", self.current_editor, focus);
self.set_tab_focus(focus, self.current_editor)
}
pub fn set_tab_focus(&mut self, focus: Focus, index: usize) {
trace!(target:Self::id(), "Setting tab {} focus to {:?}", index, focus);
if focus == Focus::Active && index != self.current_editor {
// If we are setting another tab to active, disable the current one.
trace!(
target:Self::id(),
"New tab {} focus set to Active; Setting current tab {} to Inactive",
index,
self.current_editor
);
self.set_current_tab_focus(Focus::Inactive);
}
match self.get_editor_key(index) {
None => {
error!(target:Self::id(), "Failed setting tab focus for invalid key {index}");
}
Some(key) => match self.editors.get_mut(&key) {
None => {
error!(
target:Self::id(),
"Failed to update tab focus at index {} with invalid key: {}",
self.current_editor,
self.tab_order[self.current_editor]
)
}
Some(editor) => editor.component_state.set_focus(focus),
},
}
}
pub fn open_tab(&mut self, path: &std::path::PathBuf) -> Result<()> {
trace!(target:Self::id(), "Opening new EditorTab with path {:?}", path);
if self
.editors
.contains_key(&path.to_string_lossy().to_string())
{
warn!(target:Self::id(), "EditorTab already opened with this file");
return Ok(());
}
let path_str = path.to_string_lossy().to_string();
self.tab_order.push(path_str.clone());
let mut editor = Editor::new(path);
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 close_current_tab(&mut self) -> Result<()> {
self.close_tab(self.current_editor)
}
pub fn close_tab(&mut self, index: usize) -> Result<()> {
let key = self
.tab_order
.get(index)
.ok_or(anyhow!(
"Failed to get tab order with invalid index {index}"
))?
.to_owned();
match self.editors.remove(&key) {
None => {
error!(target:Self::id(), "Failed to remove editor tab {key} with invalid index {index}")
}
Some(_) => {
self.prev_editor();
self.tab_order.remove(index);
info!(target:Self::id(), "Closed editor tab {key} at index {index}")
}
}
Ok(())
}
pub fn is_empty(&self) -> bool {
self.editors.is_empty()
}
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())
});
// Don't set border color based on ComponentState::focus, the Editor renders the border.
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<Action> {
if let Some(key) = event.as_key_event() {
let action = self.handle_key_events(key)?;
match action {
Action::Quit | Action::Handled => return Ok(action),
_ => {}
}
}
if let Some(editor) = self.current_editor_mut() {
return editor.handle_event(event);
}
Ok(Action::Noop)
}
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
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),
}
}
}

View File

@@ -1,32 +1,44 @@
use crate::tui::component::{Action, Component};
use crate::tui::component::{Action, Component, ComponentState, Focus, FocusState};
use anyhow::{Context, Result, bail};
use log::trace;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::layout::{Alignment, Position, Rect};
use ratatui::prelude::Style;
use ratatui::style::{Color, Modifier};
use ratatui::widgets::{Block, Borders, StatefulWidget};
use ratatui::widgets::{Block, Borders, StatefulWidget, Widget};
use std::fs;
use std::path::PathBuf;
use tui_tree_widget::{Tree, TreeItem, TreeState};
#[derive(Debug)]
pub struct Explorer<'a> {
root_path: std::path::PathBuf,
pub(crate) root_path: PathBuf,
tree_items: TreeItem<'a, String>,
tree_state: TreeState<String>,
pub(crate) component_state: ComponentState,
}
impl<'a> Explorer<'a> {
pub fn new(path: &std::path::PathBuf) -> Result<Self> {
pub fn id() -> &'static str {
"Explorer"
}
pub fn new(path: &PathBuf) -> Result<Self> {
trace!(target:Self::id(), "Building {}", Self::id());
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(concat!(
"(↑/k)/(↓/j): Select item | ←/h: Close folder | →/l: Open folder |",
" Space: Open / close folder | Enter: Open file in new editor tab"
)),
};
Ok(explorer)
}
fn build_tree_from_path(path: std::path::PathBuf) -> Result<TreeItem<'static, String>> {
fn build_tree_from_path(path: PathBuf) -> Result<TreeItem<'static, String>> {
let mut children = vec![];
if let Ok(entries) = fs::read_dir(&path) {
let mut paths = entries
@@ -73,20 +85,27 @@ impl<'a> Explorer<'a> {
.context("Failed to build tree from path.")
}
pub fn render(&mut self, area: Rect, buf: &mut Buffer) -> Result<()> {
StatefulWidget::render(
Tree::new(&self.tree_items.children())
.context("Failed to build file Explorer Tree.")?
.style(Style::default())
.block(
pub fn selected(&self) -> Result<String> {
if let Some(path) = self.tree_state.selected().last() {
return Ok(std::path::absolute(path)?
.to_str()
.context("Failed to get absolute path to selected TreeItem")?
.to_string());
}
bail!("Failed to get selected TreeItem")
}
}
impl<'a> Widget for &mut Explorer<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if let Ok(tree) = Tree::new(&self.tree_items.children()) {
let file_name = self.root_path.file_name().unwrap_or("Unknown".as_ref());
StatefulWidget::render(
tree.block(
Block::default()
.borders(Borders::ALL)
.title(
self.root_path
.file_name()
.context("Failed to get file name from path.")?
.to_string_lossy(),
)
.title(file_name.to_string_lossy())
.border_style(Style::default().fg(self.component_state.get_active_color()))
.title_style(Style::default().fg(Color::Green))
.title_alignment(Alignment::Center),
)
@@ -96,30 +115,21 @@ impl<'a> Explorer<'a> {
.bg(Color::Rgb(57, 59, 64))
.add_modifier(Modifier::BOLD),
),
area,
buf,
&mut self.tree_state,
);
Ok(())
}
pub fn selected(&self) -> Result<String> {
if let Some(path) = self.tree_state.selected().last() {
return Ok(std::path::absolute(path)?.to_str().unwrap().to_string());
area,
buf,
&mut self.tree_state,
);
}
bail!("Failed to get selected TreeItem")
}
}
impl<'a> Component for Explorer<'a> {
fn id(&self) -> &str {
"Explorer"
}
fn handle_event(&mut self, event: Event) -> Result<Action> {
if let Some(key_event) = event.as_key_event() {
// 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),
_ => {}
}
}
@@ -133,16 +143,27 @@ impl<'a> Component for Explorer<'a> {
}
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
if key.code == KeyCode::Enter {
if let Ok(selected) = self.selected() {
if PathBuf::from(&selected).is_file() {
return Ok(Action::OpenTab);
}
}
// Otherwise fall through and handle Enter in the next match case.
}
let changed = match key.code {
KeyCode::Up => self.tree_state.key_up(),
KeyCode::Char('k') => self.tree_state.key_up(),
KeyCode::Down => self.tree_state.key_down(),
KeyCode::Char('j') => self.tree_state.key_down(),
KeyCode::Left => self.tree_state.key_left(),
KeyCode::Char('h') => self.tree_state.key_left(),
KeyCode::Right => self.tree_state.key_right(),
KeyCode::Char('l') => self.tree_state.key_right(),
KeyCode::Enter => self.tree_state.toggle_selected(),
KeyCode::Up | KeyCode::Char('k') => self.tree_state.key_up(),
KeyCode::Down | KeyCode::Char('j') => self.tree_state.key_down(),
KeyCode::Left | KeyCode::Char('h') => {
// Do not call key_left(); Calling it on a closed folder clears the selection.
let key = self.tree_state.selected().to_owned();
self.tree_state.close(key.as_ref())
}
KeyCode::Char(' ') | KeyCode::Enter => self
.tree_state
.toggle(self.tree_state.selected().to_owned()),
KeyCode::Right | KeyCode::Char('l') => self.tree_state.key_right(),
_ => false,
};
if changed {
@@ -165,4 +186,8 @@ impl<'a> Component for Explorer<'a> {
}
Ok(Action::Noop)
}
fn is_active(&self) -> bool {
self.component_state.focus == Focus::Active
}
}

92
src/tui/logger.rs Normal file
View File

@@ -0,0 +1,92 @@
use crate::tui::component::{Action, Component, ComponentState, Focus, FocusState};
use log::{LevelFilter, trace};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::Widget;
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget, TuiWidgetEvent, TuiWidgetState};
/// Any log written as info!(target:self.id(), "message") will work with this logger.
/// The logger is bound to info!, debug!, error!, trace! macros within Tui::new().
pub struct Logger {
state: TuiWidgetState,
pub(crate) component_state: ComponentState,
}
impl Logger {
pub fn id() -> &'static str {
"Logger"
}
pub fn new() -> Self {
trace!(target:Self::id(), "Building {}", Self::id());
let state = TuiWidgetState::new();
state.transition(TuiWidgetEvent::HideKey);
Self {
state: state
.set_level_for_target("arboard::platform::linux::x11", LevelFilter::Off)
.set_level_for_target("mio::poll", LevelFilter::Off),
component_state: ComponentState::default().with_help_text(concat!(
"Space: Hide/show logging target selector panel | (↑/k)/(↓/j): Select target |",
" (←/h)/(→/l): Display level | f: Focus target | +/-: Filter level |",
" v: Toggle filtered targets visibility | PageUp/Down: Scroll | Esc: Cancel scroll"
)),
}
}
}
impl Widget for &Logger {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
TuiLoggerSmartWidget::default()
.border_style(Style::default().fg(self.component_state.get_active_color()))
.style_error(Style::default().fg(Color::Red))
.style_debug(Style::default().fg(Color::Green))
.style_warn(Style::default().fg(Color::Yellow))
.style_trace(Style::default().fg(Color::Magenta))
.style_info(Style::default().fg(Color::Cyan))
.output_separator(':')
.output_timestamp(Some("%H:%M:%S".to_string()))
.output_level(Some(TuiLoggerLevelOutput::Abbreviated))
.output_target(true)
.output_file(true)
.output_line(true)
.state(&self.state)
.render(area, buf);
}
}
impl Component for Logger {
fn handle_event(&mut self, event: Event) -> anyhow::Result<Action> {
if let Some(key_event) = event.as_key_event() {
return self.handle_key_events(key_event);
}
Ok(Action::Noop)
}
fn handle_key_events(&mut self, key: KeyEvent) -> anyhow::Result<Action> {
match key.code {
KeyCode::Char('v') => self.state.transition(TuiWidgetEvent::SpaceKey),
KeyCode::Esc => self.state.transition(TuiWidgetEvent::EscapeKey),
KeyCode::PageUp => self.state.transition(TuiWidgetEvent::PrevPageKey),
KeyCode::PageDown => self.state.transition(TuiWidgetEvent::NextPageKey),
KeyCode::Up | KeyCode::Char('k') => self.state.transition(TuiWidgetEvent::UpKey),
KeyCode::Down | KeyCode::Char('j') => self.state.transition(TuiWidgetEvent::DownKey),
KeyCode::Left | KeyCode::Char('h') => self.state.transition(TuiWidgetEvent::LeftKey),
KeyCode::Right | KeyCode::Char('l') => self.state.transition(TuiWidgetEvent::RightKey),
KeyCode::Char('+') => self.state.transition(TuiWidgetEvent::PlusKey),
KeyCode::Char('-') => self.state.transition(TuiWidgetEvent::MinusKey),
KeyCode::Char(' ') => self.state.transition(TuiWidgetEvent::HideKey),
KeyCode::Char('f') => self.state.transition(TuiWidgetEvent::FocusKey),
_ => (),
}
Ok(Action::Pass)
}
fn is_active(&self) -> bool {
self.component_state.focus == Focus::Active
}
}

240
src/tui/menu_bar.rs Normal file
View File

@@ -0,0 +1,240 @@
use crate::tui::component::{Action, Component, ComponentState, FocusState};
use crate::tui::menu_bar::MenuBarItemOption::{
About, CloseTab, Exit, Reload, Save, ShowHideExplorer, ShowHideLogger,
};
use log::trace;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
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 MenuBarItem {
File,
View,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromRepr, EnumIter)]
enum MenuBarItemOption {
Save,
CloseTab,
Reload,
Exit,
ShowHideExplorer,
ShowHideLogger,
About,
}
impl MenuBarItemOption {
fn id(&self) -> &str {
match self {
Save => "Save",
Reload => "Reload",
Exit => "Exit",
ShowHideExplorer => "Show / hide explorer",
ShowHideLogger => "Show / hide logger",
About => "About",
CloseTab => "Close tab",
}
}
}
impl MenuBarItem {
pub fn next(self) -> Self {
let cur = self as usize;
let next = cur.saturating_add(1);
Self::from_repr(next).unwrap_or(self)
}
pub fn prev(self) -> Self {
let cur = self as usize;
let prev = cur.saturating_sub(1);
Self::from_repr(prev).unwrap_or(self)
}
pub fn id(&self) -> &str {
match self {
MenuBarItem::File => "File",
MenuBarItem::View => "View",
MenuBarItem::Help => "Help",
}
}
pub fn options(&self) -> &[MenuBarItemOption] {
match self {
MenuBarItem::File => &[Save, CloseTab, Reload, Exit],
MenuBarItem::View => &[ShowHideExplorer, ShowHideLogger],
MenuBarItem::Help => &[About],
}
}
}
pub struct MenuBar {
selected: MenuBarItem,
opened: Option<MenuBarItem>,
pub(crate) component_state: ComponentState,
list_state: ListState,
}
impl MenuBar {
pub fn id() -> &'static str {
"MenuBar"
}
const DEFAULT_HELP: &str = "(←/h)/(→/l): Select option | Enter: Choose selection";
pub fn new() -> Self {
trace!(target:Self::id(), "Building {}", Self::id());
Self {
selected: MenuBarItem::File,
opened: None,
component_state: ComponentState::default().with_help_text(Self::DEFAULT_HELP),
list_state: ListState::default().with_selected(Some(0)),
}
}
fn render_title_bar(&self, area: Rect, buf: &mut Buffer) {
let titles: Vec<Line> = MenuBarItem::iter()
.map(|item| Line::from(item.id().to_owned()))
.collect();
let tabs_style = Style::default();
let highlight_style = if self.opened.is_some() {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default().bg(Color::Cyan).fg(Color::Black)
};
Tabs::new(titles)
.style(tabs_style)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.component_state.get_active_color())),
)
.highlight_style(highlight_style)
.select(self.selected as usize)
.render(area, buf);
}
fn render_drop_down(
&mut self,
title_bar_anchor: Rect,
area: Rect,
buf: &mut Buffer,
opened: MenuBarItem,
) {
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(
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 {
let rect = Rect {
x: anchor.x,
y: anchor.y + anchor.height,
width: width.min(area.width),
height,
};
// TODO: X offset for item option? It's fine as-is, but it might look nicer.
// trace!(target:Self::id(), "Building Rect under MenuBar popup {}", rect);
rect
}
}
impl Widget for &mut MenuBar {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let title_bar_area = Rect {
x: area.x,
y: area.y,
width: area.width,
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 MenuBar {
fn handle_key_events(&mut self, key: KeyEvent) -> anyhow::Result<Action> {
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 => {
if let Some(selected) = self.list_state.selected() {
let selection = self.selected.options()[selected];
return match selection {
Save => Ok(Action::Save),
Exit => Ok(Action::Quit),
Reload => Ok(Action::ReloadFile),
ShowHideExplorer => Ok(Action::ShowHideExplorer),
ShowHideLogger => Ok(Action::ShowHideLogger),
About => Ok(Action::ShowHideAbout),
CloseTab => Ok(Action::CloseTab),
};
}
Ok(Action::Noop)
}
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),
}
} 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),
}
}
}
}