22 Commits

Author SHA1 Message Date
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
1e635ee059 [tui] Use anyhow::bail!() macro. 2026-01-20 19:14:34 -05:00
42a40fe7f3 [tui] Remove most usage of expect().
Still not quite sure what to do about some pieces in QML bindings for
the GUI.
2026-01-20 17:20:37 -05:00
ce2949159c [tui] Add AppComponent enum for storing all components. 2026-01-20 16:03:38 -05:00
3ffdcc2865 [gui] Update cxx-qt dependencies to 0.8.0. 2026-01-20 12:44:13 -05:00
ecd94a2621 Update to use clap.
Structopt is deprecated.
Also removed some unused dependencies.
2026-01-20 12:24:20 -05:00
2713d29285 [tui] Store SyntaxSet in the Editor. 2026-01-20 12:06:06 -05:00
d2846e1e4e [tui] Set tab title to file name.
Also update to use anyhow::Result in some places.
2026-01-20 12:00:24 -05:00
bccc5a35e2 [tui] Add function for refreshing editor contents.
It's still temporary, but at least it isn't done ad-hoc.
2026-01-19 18:37:45 -05:00
e65eb20048 [tui] File explorer controls editor contents. 2026-01-19 17:41:46 -05:00
f10d4cd41d [tui] Allow saving file with CTRL+S.
+ Improved event handling in general.
2026-01-19 15:03:50 -05:00
507a4d8651 [tui] Cleanup and renames. 2026-01-19 10:27:06 -05:00
ce6c12f068 [tui] Move default input logic into ClideComponent. 2026-01-18 11:02:41 -05:00
fe6390c1cd [tui] Add edtui editor for basic vim emulation. 2026-01-18 10:09:28 -05:00
a8de77f370 [tui] WIP neovim editor. 2026-01-17 19:21:14 -05:00
b35b98743b [tui] Clean up Border titles. 2026-01-17 17:39:13 -05:00
733a43ccde [tui] Add basic component trait. 2026-01-17 17:18:34 -05:00
b65565adfa [tui] Add Explorer widget for left panel. 2026-01-17 15:07:26 -05:00
fac6ea6bcd Create App struct for TUI. 2026-01-17 14:04:02 -05:00
7fe3e3e14d WIP ratatui. 2026-01-17 11:41:48 -05:00
cf59fdfcca Embed SVG icons. 2025-04-19 13:49:29 -04:00
16 changed files with 2801 additions and 447 deletions

2206
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,18 @@ edition = "2024"
[dependencies]
cxx = "1.0.95"
cxx-qt = "0.7"
cxx-qt-lib = { version="0.7", features = ["qt_full", "qt_gui"] }
cxx-qt = "0.8.0"
cxx-qt-lib = { version = "0.8.0", features = ["qt_full", "qt_gui", "qt_qml"] }
log = { version = "0.4.27", features = [] }
dirs = "6.0.0"
syntect = "5.2.0"
structopt = "0.3.26"
clap = { version = "4.5.54", features = ["derive"] }
ratatui = "0.30.0"
anyhow = "1.0.100"
tui-tree-widget = "0.24.0"
tui-logger = "0.18.1"
edtui = "0.11.1"
[build-dependencies]
# The link_qt_object_files feature is required for statically linking Qt 6.
cxx-qt-build = { version = "0.7", features = [ "link_qt_object_files" ] }
cxx-qt-build = { version = "0.8.0", features = ["link_qt_object_files"] }

View File

@@ -1,8 +1,18 @@
# CLIDE
CLIDE is an IDE written in Rust that supports both full and headless Linux environments.
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.
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.
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.
```bash
sudo apt install qt6-base-dev qt6-declarative-dev qt6-tools-dev qml6-module-qtquick-controls qml6-module-qtquick-layouts qml6-module-qtquick-window qml6-module-qtqml-workerscript qml6-module-qtquick-templates qml6-module-qtquick qml6-module-qtquick-dialogs qt6-svg-dev
@@ -22,8 +32,8 @@ The [Qt Installer](https://www.qt.io/download-qt-installer) will provide the lat
If using RustRover be sure to set your QML binaries path in the settings menu.
If Qt was installed to its default directory this will be `$HOME/Qt/6.8.3/gcc_64/bin/`.
Viewing documentation in the web browser is possible, but you will end up in a mess of tabs.
Using Qt Assistant is recommended. It comes with Qt6 when installed. Run the following command to start it.
Viewing documentation in the web browser is possible, but using Qt Assistant is recommended.
It comes with Qt6 when installed. Run the following command to start it.
```bash
nohup $HOME/Qt/6.8.3/gcc_64/bin/assistant > /dev/null 2>&1 &
@@ -58,3 +68,8 @@ Some helpful links for reading up on QML if you're just getting started.
* [All QML Controls Types](https://doc.qt.io/qt-6/qtquick-controls-qmlmodule.html)
* [KDAB CXX-Qt Book](https://kdab.github.io/cxx-qt/book/)
* [github.com/KDAB/cxx-qt](https://github.com/KDAB/cxx-qt)
### Plugins
TODO: Add a list of plugins here. The first example will be C++ with CMake functionality.

View File

@@ -1,28 +1,23 @@
use cxx_qt_build::{CxxQtBuilder, QmlModule};
fn main() {
CxxQtBuilder::new()
// Link Qt's Network library
// - Qt Core is always linked
// - Qt Gui is linked by enabling the qt_gui Cargo feature of cxx-qt-lib.
// - Qt Qml is linked by enabling the qt_qml Cargo feature of cxx-qt-lib.
// - Qt Qml requires linking Qt Network on macOS
.qt_module("Network")
.qt_module("Gui")
.qt_module("Svg")
.qt_module("Xml")
.qml_module(QmlModule {
uri: "clide.module",
rust_files: &["src/gui/colors.rs", "src/gui/filesystem.rs"],
qml_files: &[
"qml/main.qml",
"qml/ClideAboutWindow.qml",
"qml/ClideTreeView.qml",
"qml/ClideProjectView.qml",
"qml/ClideEditor.qml",
"qml/ClideMenuBar.qml",
],
..Default::default()
})
.build();
CxxQtBuilder::new_qml_module(QmlModule::new("clide.module").qml_files(&[
"qml/main.qml",
"qml/ClideAboutWindow.qml",
"qml/ClideTreeView.qml",
"qml/ClideProjectView.qml",
"qml/ClideEditor.qml",
"qml/ClideMenuBar.qml",
]))
// Link Qt's Network library
// - Qt Core is always linked
// - Qt Gui is linked by enabling the qt_gui Cargo feature of cxx-qt-lib.
// - Qt Qml is linked by enabling the qt_qml Cargo feature of cxx-qt-lib.
// - Qt Qml requires linking Qt Network on macOS
.qt_module("Network")
.qt_module("Gui")
.qt_module("Svg")
.qt_module("Xml")
.files(["src/gui/colors.rs", "src/gui/filesystem.rs"])
.build();
}

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="-10 0 1792 1792"
id="svg51"
sodipodi:docname="folder_closed.svg"
width="1792"
height="1792"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs55" />
<sodipodi:namedview
id="namedview53"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="0.45033482"
inkscape:cx="842.70632"
inkscape:cy="896"
inkscape:window-width="1846"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg51" />
<path
fill="currentColor"
d="m 1718,672 v 704 q 0,92 -66,158 -66,66 -158,66 H 278 q -92,0 -158,-66 -66,-66 -66,-158 V 416 q 0,-92 66,-158 66,-66 158,-66 h 320 q 92,0 158,66 66,66 66,158 v 32 h 672 q 92,0 158,66 66,66 66,158 z"
id="path49" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="-10 0 1792 1792"
id="svg139"
sodipodi:docname="folder_open.svg"
width="1792"
height="1792"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs143" />
<sodipodi:namedview
id="namedview141"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="0.24358259"
inkscape:cx="149.84651"
inkscape:cy="1098.1901"
inkscape:window-width="1846"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg139" />
<path
fill="currentColor"
d="M 1590,1376 V 672 q 0,-40 -28,-68 -28,-28 -68,-28 H 790 q -40,0 -68,-28 -28,-28 -28,-68 v -64 q 0,-40 -28,-68 -28,-28 -68,-28 H 278 q -40,0 -68,28 -28,28 -28,68 v 960 q 0,40 28,68 28,28 68,28 h 1216 q 40,0 68,-28 28,-28 28,-68 z m 128,-704 v 704 q 0,92 -66,158 -66,66 -158,66 H 278 q -92,0 -158,-66 -66,-66 -66,-158 V 416 q 0,-92 66,-158 66,-66 158,-66 h 320 q 92,0 158,66 66,66 66,158 v 32 h 672 q 92,0 158,66 66,66 66,158 z"
id="path137" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="-10 0 1792 1792"
id="svg147"
sodipodi:docname="generic_file.svg"
width="1792"
height="1792"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs151" />
<sodipodi:namedview
id="namedview149"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="0.12179129"
inkscape:cx="-578.85911"
inkscape:cy="1687.3127"
inkscape:window-width="1846"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg147" />
<path
fill="currentColor"
d="m 1586,476 q 14,14 28,36 H 1142 V 40 q 22,14 36,28 z m -476,164 h 544 v 1056 q 0,40 -28,68 -28,28 -68,28 H 214 q -40,0 -68,-28 -28,-28 -28,-68 V 96 Q 118,56 146,28 174,0 214,0 h 800 v 544 q 0,40 28,68 28,28 68,28 z m 160,736 v -64 q 0,-14 -9,-23 -9,-9 -23,-9 H 534 q -14,0 -23,9 -9,9 -9,23 v 64 q 0,14 9,23 9,9 23,9 h 704 q 14,0 23,-9 9,-9 9,-23 z m 0,-256 v -64 q 0,-14 -9,-23 -9,-9 -23,-9 H 534 q -14,0 -23,9 -9,9 -9,23 v 64 q 0,14 9,23 9,9 23,9 h 704 q 14,0 23,-9 9,-9 9,-23 z m 0,-256 v -64 q 0,-14 -9,-23 -9,-9 -23,-9 H 534 q -14,0 -23,9 -9,9 -9,23 v 64 q 0,14 9,23 9,9 23,9 h 704 q 14,0 23,-9 9,-9 9,-23 z"
id="path145" />
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -45,12 +45,16 @@ Rectangle {
x: treeDelegate.leftMargin + (treeDelegate.depth * treeDelegate.indentation)
anchors.verticalCenter: parent.verticalCenter
source: {
let folderOpen = "data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 576 512\"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d=\"M88.7 223.8L0 375.8 0 96C0 60.7 28.7 32 64 32l117.5 0c17 0 33.3 6.7 45.3 18.7l26.5 26.5c12 12 28.3 18.7 45.3 18.7L416 96c35.3 0 64 28.7 64 64l0 32-336 0c-22.8 0-43.8 12.1-55.3 31.8zm27.6 16.1C122.1 230 132.6 224 144 224l400 0c11.5 0 22 6.1 27.7 16.1s5.7 22.2-.1 32.1l-112 192C453.9 474 443.4 480 432 480L32 480c-11.5 0-22-6.1-27.7-16.1s-5.7-22.2 .1-32.1l112-192z\"/></svg>";
let folderClosed = "data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d=\"M64 480H448c35.3 0 64-28.7 64-64V160c0-35.3-28.7-64-64-64H288c-10.1 0-19.6-4.7-25.6-12.8L243.2 57.6C231.1 41.5 212.1 32 192 32H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64z\"/></svg>";
let file = "data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 384 512\"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d=\"M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128z\"/></svg>";
// If the item has children, it's a directory.
if (treeDelegate.hasChildren) {
return treeDelegate.expanded ?
"../icons/folder-open-solid.svg" : "../icons/folder-solid.svg";
folderOpen : folderClosed;
} else {
return file
}
return "../icons/file-solid.svg"
}
sourceSize.width: 15
sourceSize.height: 15

View File

@@ -1,10 +1,10 @@
use anyhow::Result;
use cxx_qt_lib::QString;
use std::error::Error;
pub mod colors;
pub mod filesystem;
pub fn run(root_path: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
pub fn run(root_path: std::path::PathBuf) -> Result<()> {
println!("Starting the GUI editor at {:?}", root_path);
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl};

View File

@@ -1,48 +1,50 @@
// TODO: Header
use std::error::Error;
use anyhow::{Context, Result};
use clap::Parser;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io::stdout;
use std::process::{Command, Stdio};
use structopt::StructOpt;
use crate::tui::Tui;
pub mod gui;
pub mod tui;
/// Command line interface IDE with full GUI and headless modes.
/// 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(StructOpt, Debug)]
/// 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)]
#[structopt(name = "clide", verbatim_doc_comment)]
struct Cli {
/// The root directory for the project to open with the clide editor.
#[structopt(parse(from_os_str))]
#[arg(value_parser = clap::value_parser!(std::path::PathBuf))]
pub path: Option<std::path::PathBuf>,
/// Run clide in headless mode.
#[structopt(name = "tui", short, long)]
#[arg(value_name = "tui", short, long)]
pub tui: bool,
/// Run the clide GUI in the current process, blocking the terminal and showing all output streams.
#[structopt(name = "gui", short, long)]
#[arg(value_name = "gui", short, long)]
pub gui: bool,
}
fn main() -> Result<(), Box<dyn Error>> {
let args = Cli::from_args();
fn main() -> Result<()> {
let args = Cli::parse();
let root_path = match args.path {
// If the CLI was provided a directory convert it to absolute.
// If the CLI was provided a directory, convert it to absolute.
Some(path) => std::path::absolute(path)?,
// If no path was provided, use current directory.
None => std::env::current_dir().unwrap_or_else(|_|
// If we can't find the CWD attempt to open the home directory.
dirs::home_dir().expect("Failed to access filesystem.")),
// If no path was provided, use the current directory.
None => std::env::current_dir().unwrap_or(
// If we can't find the CWD, attempt to open the home directory.
dirs::home_dir().context("Failed to obtain home directory")?,
),
};
match args.gui {
true => gui::run(root_path),
false => match args.tui {
// Open the TUI editor if requested, otherwise use the QML GUI by default.
true => tui::run(root_path),
true => Ok(Tui::new(root_path)?.start()?),
false => {
// Relaunch the CLIDE GUI in a separate process.
Command::new(std::env::current_exe()?)

View File

@@ -1,6 +1,76 @@
use std::error::Error;
mod app;
mod component;
mod editor;
mod explorer;
mod logger;
pub fn run(root_path: std::path::PathBuf) -> Result<(), Box<dyn Error>> {
println!("Starting the TUI editor at {:?}", root_path);
Ok(())
use anyhow::{Context, Result};
use log::{LevelFilter, debug};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
};
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use std::env;
use std::io::{Stdout, stdout};
use tui_logger::{
TuiLoggerFile, TuiLoggerLevelOutput, init_logger, set_default_level, set_log_file,
};
pub struct Tui {
terminal: Terminal<CrosstermBackend<Stdout>>,
root_path: std::path::PathBuf,
}
impl Tui {
pub fn new(root_path: std::path::PathBuf) -> Result<Self> {
init_logger(LevelFilter::Trace)?;
set_default_level(LevelFilter::Trace);
debug!(target:"Tui", "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(':');
set_log_file(file_options);
debug!(target:"Tui", "Logging to file: {}", dir.to_str().unwrap());
Ok(Self {
terminal: Terminal::new(CrosstermBackend::new(stdout()))?,
root_path,
})
}
pub fn start(self) -> Result<()> {
println!("Starting the TUI editor at {:?}", self.root_path);
ratatui::crossterm::execute!(
stdout(),
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
enable_raw_mode()?;
let app_result = app::App::new(self.root_path)?
.run(self.terminal)
.context("Failed to start the TUI editor.");
Self::stop()?;
app_result
}
fn stop() -> Result<()> {
disable_raw_mode()?;
ratatui::crossterm::execute!(
stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
Ok(())
}
}

309
src/tui/app.rs Normal file
View File

@@ -0,0 +1,309 @@
use crate::tui::component::{Action, Component};
use crate::tui::editor::Editor;
use crate::tui::explorer::Explorer;
use crate::tui::logger::Logger;
use anyhow::{Context, Result, anyhow, bail};
use log::{debug, error, info, trace, warn};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
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 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
}
}
pub struct App<'a> {
components: Vec<AppComponents<'a>>,
}
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()),
AppComponents::AppLogger(Logger::new()),
],
};
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())
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
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,
Action::Handled => {}
_ => {
// bail!("Unhandled event: {:?}", event);
}
}
}
}
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_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())
}
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);
}
/// 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_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")
}
}
// 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 = 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.
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(_) => {}
}
}
}
}
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())?,
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),
_ => {}
}
}
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('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)
}
KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: _state,
} => Ok(Action::Quit),
_ => Ok(Action::Noop),
}
}
}

47
src/tui/component.rs Normal file
View File

@@ -0,0 +1,47 @@
#![allow(dead_code, unused_variables)]
use anyhow::Result;
use ratatui::crossterm::event::{Event, KeyEvent, MouseEvent};
pub enum Action {
/// Exit the application.
Quit,
/// The input was checked by the Component and had no effect.
Noop,
/// Pass input to another component or external handler.
/// Similar to Noop with the added context that externally handled input may have had an impact.
Pass,
/// Save the current file.
Save,
/// The input was handled by a Component and should not be passed to the next component.
Handled,
}
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),
_ => Ok(Action::Noop),
}
}
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
Ok(Action::Noop)
}
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Action> {
Ok(Action::Noop)
}
fn update(&mut self, action: Action) -> Result<Action> {
Ok(Action::Noop)
}
}

121
src/tui/editor.rs Normal file
View File

@@ -0,0 +1,121 @@
use crate::tui::component::{Action, Component};
use anyhow::{Context, Result, bail};
use edtui::{
EditorEventHandler, EditorState, EditorTheme, EditorView, LineNumbers, Lines, SyntaxHighlighter,
};
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Rect};
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,
}
impl Editor {
pub fn new() -> Self {
Editor {
state: EditorState::default(),
event_handler: EditorEventHandler::default(),
file_path: None,
syntax_set: SyntaxSet::load_defaults_nonewlines(),
}
}
pub fn set_contents(&mut self, path: &std::path::PathBuf) -> Result<()> {
if let Ok(contents) = std::fs::read_to_string(path) {
let lines: Vec<_> = contents
.lines()
.map(|line| line.chars().collect::<Vec<char>>())
.collect();
self.file_path = Some(path.clone());
self.state.lines = Lines::new(lines);
}
Ok(())
}
pub fn save(&self) -> Result<()> {
if let Some(path) = &self.file_path {
return std::fs::write(path, self.state.lines.to_string()).map_err(|e| e.into());
};
bail!("File not saved. No file path set.")
}
}
impl Widget for &mut Editor {
fn render(self, area: Rect, buf: &mut Buffer) {
let lang = self
.file_path
.as_ref()
.and_then(|p| p.extension())
.map(|e| e.to_str().unwrap_or("md"))
.unwrap_or("md");
let lang_name = self
.syntax_set
.find_syntax_by_extension(lang)
.map(|s| s.name.to_string())
.unwrap_or("Unknown".to_string());
EditorView::new(&mut self.state)
.wrap(true)
.theme(
EditorTheme::default().block(
Block::default()
.title(lang_name.to_owned())
.title_style(Style::default().fg(Color::Yellow))
.title_alignment(Alignment::Right)
.borders(Borders::ALL)
.padding(Padding::new(0, 0, 0, 1)),
),
)
.syntax_highlighter(SyntaxHighlighter::new("dracula", lang).ok())
.tab_width(2)
.line_numbers(LineNumbers::Absolute)
.render(area, buf);
}
}
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.
match self.handle_key_events(key_event)? {
Action::Handled => return Ok(Action::Handled),
_ => {}
}
}
self.event_handler.on_event(event, &mut self.state);
Ok(Action::Pass)
}
/// The events for the vim emulation should be handled by EditorEventHandler::on_event.
/// These events are custom to the clide application.
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
match key {
KeyEvent {
code: KeyCode::Char('s'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.save().context("Failed to save file.")?;
Ok(Action::Handled)
}
// For other events not handled here, pass to the vim emulation handler.
_ => Ok(Action::Noop),
}
}
}

168
src/tui/explorer.rs Normal file
View File

@@ -0,0 +1,168 @@
use crate::tui::component::{Action, Component};
use anyhow::{Context, Result, bail};
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 std::fs;
use tui_tree_widget::{Tree, TreeItem, TreeState};
#[derive(Debug)]
pub struct Explorer<'a> {
root_path: std::path::PathBuf,
tree_items: TreeItem<'a, String>,
tree_state: TreeState<String>,
}
impl<'a> Explorer<'a> {
pub fn new(path: &std::path::PathBuf) -> Result<Self> {
let explorer = Explorer {
root_path: path.to_owned(),
tree_items: Self::build_tree_from_path(path.to_owned())?,
tree_state: TreeState::default(),
};
Ok(explorer)
}
fn build_tree_from_path(path: std::path::PathBuf) -> Result<TreeItem<'static, String>> {
let mut children = vec![];
if let Ok(entries) = fs::read_dir(&path) {
let mut paths = entries
.map(|res| res.map(|e| e.path()))
.collect::<Result<Vec<_>, std::io::Error>>()
.context(format!(
"Failed to build vector of paths under directory: {:?}",
path
))?;
paths.sort();
for path in paths {
if path.is_dir() {
children.push(Self::build_tree_from_path(path)?);
} else {
if let Ok(path) = std::path::absolute(&path) {
let path_str = path.to_string_lossy().to_string();
children.push(TreeItem::new_leaf(
path_str,
path.file_name()
.context("Failed to get file name from path.")?
.to_string_lossy()
.to_string(),
));
}
}
}
}
let abs = std::path::absolute(&path)
.context(format!(
"Failed to find absolute path for TreeItem: {:?}",
path
))?
.to_string_lossy()
.to_string();
TreeItem::new(
abs,
path.file_name()
.expect("Failed to get file name from path.")
.to_string_lossy()
.to_string(),
children,
)
.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(
Block::default()
.borders(Borders::ALL)
.title(
self.root_path
.file_name()
.context("Failed to get file name from path.")?
.to_string_lossy(),
)
.title_style(Style::default().fg(Color::Green))
.title_alignment(Alignment::Center),
)
.highlight_style(
Style::new()
.fg(Color::Black)
.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());
}
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),
_ => {}
}
}
if let Some(mouse_event) = event.as_mouse_event() {
match self.handle_mouse_events(mouse_event)? {
Action::Handled => return Ok(Action::Handled),
_ => {}
}
}
Ok(Action::Pass)
}
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
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(),
_ => false,
};
if changed {
return Ok(Action::Handled);
}
Ok(Action::Noop)
}
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Action> {
let changed = match mouse.kind {
MouseEventKind::ScrollDown => self.tree_state.scroll_down(1),
MouseEventKind::ScrollUp => self.tree_state.scroll_up(1),
MouseEventKind::Down(_button) => self
.tree_state
.click_at(Position::new(mouse.column, mouse.row)),
_ => false,
};
if changed {
return Ok(Action::Handled);
}
Ok(Action::Noop)
}
}

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

@@ -0,0 +1,76 @@
use crate::tui::component::{Action, Component};
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,
}
impl Logger {
pub fn new() -> Self {
Self {
state: TuiWidgetState::new(),
}
}
}
impl Widget for &Logger {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
// TODO: Use output_file?
TuiLoggerSmartWidget::default()
.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 id(&self) -> &str {
"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(' ') => 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 => self.state.transition(TuiWidgetEvent::UpKey),
KeyCode::Down => self.state.transition(TuiWidgetEvent::DownKey),
KeyCode::Left => self.state.transition(TuiWidgetEvent::LeftKey),
KeyCode::Right => self.state.transition(TuiWidgetEvent::RightKey),
KeyCode::Char('+') => self.state.transition(TuiWidgetEvent::PlusKey),
KeyCode::Char('-') => self.state.transition(TuiWidgetEvent::MinusKey),
KeyCode::Char('h') => self.state.transition(TuiWidgetEvent::HideKey),
KeyCode::Char('f') => self.state.transition(TuiWidgetEvent::FocusKey),
_ => (),
}
Ok(Action::Pass)
}
}