41 Commits

Author SHA1 Message Date
e3bf484a5d Remove TODO 2026-02-08 16:21:52 -05:00
bedd510b0a Update GUI screenshot. 2026-02-08 15:46:17 -05:00
a30ea2b457 Remove TODOs. 2026-02-08 15:37:24 -05:00
52ceda5b06 Reorganize files. 2026-02-08 15:29:34 -05:00
a37fd74d32 Fix spacing. 2026-02-08 15:25:21 -05:00
6ed4ca11f4 Fix width for file tree selection. 2026-02-08 15:21:06 -05:00
41fab2847d Update GUI screenshot in README. 2026-02-08 15:10:06 -05:00
c76529b3cb Fix first line background.
Added a comment if the background in desired in the future.
2026-02-08 15:02:28 -05:00
5f5b1a4d39 Add frame around the application view. 2026-02-08 14:52:57 -05:00
4bb4ce1882 Clean up GUI. 2026-02-08 14:26:26 -05:00
344efc0042 Clean up ClideHandle. 2026-02-08 10:08:47 -05:00
a63acb18fc WIP 2026-02-08 00:03:50 -05:00
f918d65888 Add clickable bread crumbs.
Clicking a parent path changes the root project directory.
2026-02-07 21:18:59 -05:00
aa42ec6072 Get file explorer icon based on file type.
Fixes #15
2026-02-07 18:41:51 -05:00
6f2a655497 Clean up context menus. 2026-02-07 12:59:44 -05:00
c0f38b531d Add arrow to show expanded folder. 2026-02-07 12:43:15 -05:00
4aad91416e Fix selection flickering when expanding folder. 2026-02-07 11:16:57 -05:00
ff1b5ab2e6 Hide handles when not in use. 2026-02-07 10:51:37 -05:00
491087a6c1 Add environment variables to config.toml 2026-02-07 10:37:05 -05:00
c170b3b20d WIP 2026-02-06 22:10:20 -05:00
125041f469 Add ClideEditorView. 2026-02-06 19:36:14 -05:00
67bf82d0cb Add qmllint.ini 2026-02-06 18:59:38 -05:00
39377b32f0 WIP 2026-02-06 18:59:02 -05:00
176efb97b7 Clean up ClideScrollBar. 2026-02-04 19:52:05 -05:00
755066d847 Clean up ClideHandle. 2026-02-04 19:29:52 -05:00
773d7818b5 Auto scroll logger. 2026-02-04 18:35:43 -05:00
7e58e3ee03 Split basic components for reuse. 2026-02-02 23:05:53 -05:00
0f50577d78 Fix scrollbar animations. 2026-02-02 18:21:28 -05:00
29024e3999 Format with qmlformat. 2026-02-02 18:08:37 -05:00
5af09485a3 Add trace logs. 2026-02-02 18:01:53 -05:00
e5b91eaed8 Add basic debug logger. 2026-02-01 20:23:23 -05:00
be383869b2 Add context menu on breadcrumbs.
The only option is to reset the root directory.
2026-02-01 19:10:15 -05:00
b9eee50e52 Fix menu bar colors. 2026-02-01 18:42:21 -05:00
4cc43916cb Update breadcrumbs when root directory changes. 2026-02-01 18:23:44 -05:00
a5bed9ed2c Fix panic when loading bad text in the GUI. 2026-02-01 17:15:21 -05:00
0fac2b71ab Fix TreeView nesting for ColumnLayout. 2026-02-01 15:35:40 -05:00
db2f878018 Fix about page image loading. 2026-02-01 13:44:43 -05:00
048d40eb83 Fix TreeView root index.
Parent folders will now be hidden based on the root folder selection in
the GUI.
2026-02-01 13:07:27 -05:00
c70bba16e4 Revert "Try to use QSortFilterProxyModel via cxx-qt."
This reverts commit 325cf285fc.
2026-02-01 13:07:10 -05:00
325cf285fc Try to use QSortFilterProxyModel via cxx-qt.
This may be easier if I just wrap a QAbstractItemModel in C++ and use
cxx-qt to pull it into QML? That way I could do C++ pointer things in
C++ and rust things in rust.
2026-01-31 21:47:19 -05:00
aa8590cd5c Add license headers. 2026-01-31 08:02:16 -05:00
27 changed files with 279 additions and 730 deletions

View File

@@ -1,2 +1,6 @@
[build]
rustflags = [ "-C", "link-arg=-fuse-ld=lld", ]
[env]
QMAKE="/opt/Qt/6.7.3/gcc_64/bin/qmake6"
LD_LIBRARY_PATH="/opt/Qt/6.7.3/gcc_64/lib"

View File

@@ -1,20 +0,0 @@
name: "Setup Qt"
description: "Install clide dependencies"
inputs:
qt-version:
description: "Qt version to install"
required: true
runs:
using: "composite"
steps:
- name: Install apt packages
run: |
sudo apt update -y
sudo apt install -y build-essential cmake curl libgl1-mesa-dev python3 python3-pip
shell: bash
- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
version: ${{ inputs.qt-version }}

View File

@@ -1,85 +0,0 @@
name: Check
on:
push:
branches:
- '**'
tags:
- 'v*'
pull_request:
env:
QT_VERSION: 6.7.3
jobs:
Build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Qt
uses: ./.github/actions/setup-qt
with:
qt-version: ${{ env.QT_VERSION }}
- name: Build libclide
run: |
cargo b -p libclide --release
- name: Build clide
run: |
cargo b --release
Test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Qt
uses: ./.github/actions/setup-qt
with:
qt-version: ${{ env.QT_VERSION }}
- name: Test libclide
run: |
cargo test -p libclide
- name: Test clide
run: |
cargo test
Lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Qt
uses: ./.github/actions/setup-qt
with:
qt-version: ${{ env.QT_VERSION }}
- name: Lint libclide
run: |
cargo clippy -p libclide -- -D warnings
- name: Lint clide
run: |
cargo clippy -- -D warnings
Format:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Qt
uses: ./.github/actions/setup-qt
with:
qt-version: ${{ env.QT_VERSION }}
- name: Format libclide
run: |
cargo fmt -p libclide --verbose -- --check
- name: Format clide
run: |
cargo fmt --verbose -- --check

3
.gitignore vendored
View File

@@ -1,5 +1,4 @@
**/target/**
**/.qtcreator/**
**/.idea/**
**/*.autosave/**
**/.qmlls.ini
**/*.autosave/**

483
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,6 @@ name = "clide"
version = "0.1.0"
edition = "2024"
[workspace]
resolver = "3"
members = [
".",
"libclide",
]
[workspace.dependencies]
anyhow = "1.0.100"
strum = "0.27.2"
[dependencies]
cxx = "1.0.95"
cxx-qt = "0.8.0"
@@ -23,14 +12,14 @@ dirs = "6.0.0"
syntect = "5.2.0"
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"
strum = "0.27.2"
uuid = { version = "1.19.0", features = ["v4"] }
devicons = "0.6.12"
libclide = { path = "./libclide" }
anyhow = { workspace = true }
strum = { workspace = true }
[build-dependencies]
# The link_qt_object_files feature is required for statically linking Qt 6.
cxx-qt-build = { version = "0.8.0", features = ["link_qt_object_files"] }
cxx-qt-build = { version = "0.8.0", features = ["link_qt_object_files"] }

View File

@@ -1,7 +1,5 @@
# CLIDE
[![Check](https://github.com/shaunrd0/clide/actions/workflows/check.yaml/badge.svg)](https://github.com/shaunrd0/clide/actions/workflows/check.yaml)
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.

View File

@@ -1,7 +1,7 @@
use cxx_qt_build::{CxxQtBuilder, QmlModule};
fn main() {
CxxQtBuilder::new_qml_module(QmlModule::new("clide.module").qml_files([
CxxQtBuilder::new_qml_module(QmlModule::new("clide.module").qml_files(&[
"qml/ClideApplicationView.qml",
"qml/ClideEditorView.qml",
"qml/ClideExplorerView.qml",

16
libclide/Cargo.lock generated
View File

@@ -1,16 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "libclide"
version = "0.1.0"
dependencies = [
"anyhow",
]

View File

@@ -1,8 +0,0 @@
[package]
name = "libclide"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
strum = { workspace = true }

View File

@@ -1,5 +0,0 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
pub mod entry_meta;

View File

@@ -1,50 +0,0 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct EntryMeta {
pub abs_path: String,
pub file_name: String,
pub is_dir: bool,
}
impl EntryMeta {
/// Normalizes a path, returning an absolute from the root of the filesystem.
/// Does not resolve symlinks and extracts `./` or `../` segments.
fn normalize<P: AsRef<Path>>(p: P) -> PathBuf {
let path = p.as_ref();
let mut buf = PathBuf::new();
for comp in path.components() {
match comp {
std::path::Component::ParentDir => {
buf.pop();
}
std::path::Component::CurDir => {}
_ => buf.push(comp),
}
}
buf
}
pub fn new<P: AsRef<Path>>(p: P) -> Result<Self> {
let path = p.as_ref();
let is_dir = path.is_dir();
let abs_path = Self::normalize(path).to_string_lossy().to_string();
let file_name = Path::new(&abs_path)
.file_name()
.context(format!("Failed to get file name for path: {abs_path:?}"))?
.to_string_lossy()
.to_string();
Ok(EntryMeta {
abs_path,
file_name,
is_dir,
})
}
}

View File

@@ -1,6 +0,0 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
pub mod fs;
pub mod theme;

View File

@@ -1,5 +0,0 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
pub mod colors;

View File

@@ -1,52 +0,0 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
pub struct Colors {}
impl Colors {
pub const HOVERED: &str = "#303234";
pub const UNHOVERED: &str = "#3c3f41";
pub const PRESSED: &str = "#4b4f51";
pub const MENUBAR: &str = "#262626";
pub const MENUBAR_BORDER: &str = "#575757";
pub const SCROLLBAR: &str = "#4b4f51";
pub const SCROLLBAR_ACTIVE: &str = "#4b4f51";
pub const SCROLLBAR_GUTTER: &str = "#3b3b3b";
pub const LINENUMBER: &str = "#94989b";
pub const ACTIVE: &str = "#a9acb0";
pub const INACTIVE: &str = "#FFF";
pub const EDITOR_BACKGROUND: &str = "#1E1F22";
pub const EDITOR_TEXT: &str = "#acaea3";
pub const EDITOR_HIGHLIGHTED_TEXT: &str = "#ccced3";
pub const EDITOR_HIGHLIGHT: &str = "#ccced3";
pub const GUTTER: &str = "#1e1f22";
pub const EXPLORER_HOVERED: &str = "#4c5053";
pub const EXPLORER_TEXT: &str = "#FFF";
pub const EXPLORER_TEXT_SELECTED: &str = "#262626";
pub const EXPLORER_BACKGROUND: &str = "#1E1F22";
pub const EXPLORER_FOLDER: &str = "#54585b";
pub const EXPLORER_FOLDER_OPEN: &str = "#393B40";
pub const TERMINAL_BACKGROUND: &str = "#111111";
pub const INFO_LOG: &str = "#C4FFFF";
pub const DEBUG_LOG: &str = "#9148AF";
pub const WARN_LOG: &str = "#C4A958";
pub const ERROR_LOG: &str = "#ff5555";
pub const TRACE_LOG: &str = "#ffaa00";
pub fn css_to_u32(css: &str) -> Result<u32, String> {
let hex = css.trim_start_matches('#');
// Expand shorthand #RGB to #RRGGBB
let hex_full = match hex.len() {
3 => hex.chars()
.map(|c| std::iter::repeat(c).take(2).collect::<String>())
.collect::<String>(),
6 => hex.to_string(),
_ => return Err(format!("Invalid hex color length: {}", hex)),
};
// Parse the hex string as u32, masking to ensure the top byte is 0x00.
u32::from_str_radix(&hex_full, 16)
.map(|rgb| rgb & 0x00FF_FFFF)
.map_err(|e| format!("Failed to parse hex: {}", e))
}
}

View File

@@ -1,7 +1,3 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15

View File

@@ -1,7 +1,3 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls.Basic

View File

@@ -1,7 +1,3 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls.Basic

View File

@@ -2,10 +2,8 @@
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use cxx_qt_lib::QColor;
use libclide::theme::colors::Colors;
#[cxx_qt::bridge]
pub mod qobject {
unsafe extern "C++" {
include!("cxx-qt-lib/qcolor.h");
@@ -48,6 +46,8 @@ pub mod qobject {
}
}
use cxx_qt_lib::QColor;
pub struct RustColorsImpl {
hovered: QColor,
unhovered: QColor,
@@ -82,34 +82,34 @@ pub struct RustColorsImpl {
impl Default for RustColorsImpl {
fn default() -> Self {
Self {
hovered: QColor::try_from(Colors::HOVERED).unwrap(),
unhovered: QColor::try_from(Colors::UNHOVERED).unwrap(),
pressed: QColor::try_from(Colors::PRESSED).unwrap(),
menubar: QColor::try_from(Colors::MENUBAR).unwrap(),
menubar_border: QColor::try_from(Colors::MENUBAR_BORDER).unwrap(),
scrollbar: QColor::try_from(Colors::SCROLLBAR).unwrap(),
scrollbar_active: QColor::try_from(Colors::SCROLLBAR_ACTIVE).unwrap(),
scrollbar_gutter: QColor::try_from(Colors::SCROLLBAR_GUTTER).unwrap(),
linenumber: QColor::try_from(Colors::LINENUMBER).unwrap(),
active: QColor::try_from(Colors::ACTIVE).unwrap(),
inactive: QColor::try_from(Colors::INACTIVE).unwrap(),
editor_background: QColor::try_from(Colors::EDITOR_BACKGROUND).unwrap(),
editor_text: QColor::try_from(Colors::EDITOR_TEXT).unwrap(),
editor_highlighted_text: QColor::try_from(Colors::EDITOR_HIGHLIGHTED_TEXT).unwrap(),
editor_highlight: QColor::try_from(Colors::EDITOR_HIGHLIGHT).unwrap(),
gutter: QColor::try_from(Colors::GUTTER).unwrap(),
explorer_hovered: QColor::try_from(Colors::EXPLORER_HOVERED).unwrap(),
explorer_text: QColor::try_from(Colors::EXPLORER_TEXT).unwrap(),
explorer_text_selected: QColor::try_from(Colors::EXPLORER_TEXT_SELECTED).unwrap(),
explorer_background: QColor::try_from(Colors::EXPLORER_BACKGROUND).unwrap(),
explorer_folder: QColor::try_from(Colors::EXPLORER_FOLDER).unwrap(),
explorer_folder_open: QColor::try_from(Colors::EXPLORER_FOLDER_OPEN).unwrap(),
terminal_background: QColor::try_from(Colors::TERMINAL_BACKGROUND).unwrap(),
info_log: QColor::try_from(Colors::INFO_LOG).unwrap(),
debug_log: QColor::try_from(Colors::DEBUG_LOG).unwrap(),
warn_log: QColor::try_from(Colors::WARN_LOG).unwrap(),
error_log: QColor::try_from(Colors::ERROR_LOG).unwrap(),
trace_log: QColor::try_from(Colors::TRACE_LOG).unwrap(),
hovered: QColor::try_from("#303234").unwrap(),
unhovered: QColor::try_from("#3c3f41").unwrap(),
pressed: QColor::try_from("#4b4f51").unwrap(),
menubar: QColor::try_from("#262626").unwrap(),
menubar_border: QColor::try_from("#575757").unwrap(),
scrollbar: QColor::try_from("#4b4f51").unwrap(),
scrollbar_active: QColor::try_from("#4b4f51").unwrap(),
scrollbar_gutter: QColor::try_from("#3b3b3b").unwrap(),
linenumber: QColor::try_from("#94989b").unwrap(),
active: QColor::try_from("#a9acb0").unwrap(),
inactive: QColor::try_from("#FFF").unwrap(),
editor_background: QColor::try_from("#1E1F22").unwrap(),
editor_text: QColor::try_from("#acaea3").unwrap(),
editor_highlighted_text: QColor::try_from("#ccced3").unwrap(),
editor_highlight: QColor::try_from("#ccced3").unwrap(),
gutter: QColor::try_from("#1e1f22").unwrap(),
explorer_hovered: QColor::try_from("#4c5053").unwrap(),
explorer_text: QColor::try_from("#FFF").unwrap(),
explorer_text_selected: QColor::try_from("#262626").unwrap(),
explorer_background: QColor::try_from("#1E1F22").unwrap(),
explorer_folder: QColor::try_from("#54585b").unwrap(),
explorer_folder_open: QColor::try_from("#393B40").unwrap(),
terminal_background: QColor::try_from("#111111").unwrap(),
info_log: QColor::try_from("#C4FFFF").unwrap(),
debug_log: QColor::try_from("#9148AF").unwrap(),
warn_log: QColor::try_from("#C4A958").unwrap(),
error_log: QColor::try_from("#ff5555").unwrap(),
trace_log: QColor::try_from("#ffaa00").unwrap(),
}
}
}

View File

@@ -76,7 +76,7 @@ impl qobject::FileSystem {
return QString::default();
}
let meta = fs::metadata(path.to_string())
.unwrap_or_else(|_| panic!("Failed to get file metadata {path:?}"));
.expect(format!("Failed to get file metadata {path:?}").as_str());
if !meta.is_file() {
warn!(target:"FileSystem", "Attempted to open file {path:?} that is not a valid file");
return QString::default();
@@ -114,7 +114,7 @@ impl qobject::FileSystem {
output.push_str("</pre>\n");
QString::from(output)
} else {
QString::default()
return QString::default();
}
}
@@ -126,7 +126,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())
.unwrap_or_else(|_| panic!("Failed to get metadata for path {path:?}"))
.expect(format!("Failed to get metadata for path {path:?}").as_str())
.is_dir()
{
self.set_root_path(path)
@@ -147,7 +147,7 @@ impl qobject::FileSystem {
if Path::new(&str).is_dir() {
// Ensures directories are given a folder icon and not mistakenly resolved to a language.
// For example, a directory named `cpp` would otherwise return a C++ icon.
return QString::from(FileIcon::from("dir/").to_string());
return QString::from(FileIcon::from("dir/").to_string())
}
let icon = FileIcon::from(str);
QString::from(icon.to_string())

View File

@@ -82,7 +82,7 @@ fn main() -> Result<()> {
RunMode::Gui => {
trace!(target:"main()", "Starting GUI in a new process");
Command::new(std::env::current_exe()?)
.args(["--gui", app_context.path.to_str().unwrap()])
.args(&["--gui", app_context.path.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())

View File

@@ -68,8 +68,8 @@ impl Widget for About {
.map(|l| Line::from(Span::raw(*l)))
.collect();
Clear.render(kilroy_rect, buf);
Clear.render(chunks[1], buf);
Clear::default().render(kilroy_rect, buf);
Clear::default().render(chunks[1], buf);
Paragraph::new(about_lines)
.block(
Block::default()

View File

@@ -3,11 +3,13 @@
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use crate::tui::about::About;
use crate::tui::app::AppComponent::{AppEditor, AppExplorer, AppLogger};
use crate::tui::component::{Action, Component, Focus, FocusState, Visibility, VisibleState};
use crate::tui::editor_tab::EditorTab;
use crate::tui::explorer::Explorer;
use crate::tui::logger::Logger;
use crate::tui::menu_bar::MenuBar;
use AppComponent::AppMenuBar;
use anyhow::{Context, Result};
use log::{error, info, trace};
use ratatui::DefaultTerminal;
@@ -24,10 +26,10 @@ use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppComponent {
Editor,
Explorer,
Logger,
MenuBar,
AppEditor,
AppExplorer,
AppLogger,
AppMenuBar,
}
pub struct App<'a> {
@@ -49,7 +51,7 @@ impl<'a> App<'a> {
explorer: Explorer::new(&root_path)?,
logger: Logger::new(),
menu_bar: MenuBar::new(),
last_active: AppComponent::Editor,
last_active: AppEditor,
about: false,
};
Ok(app)
@@ -85,7 +87,7 @@ impl<'a> App<'a> {
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 {
AppComponent::Editor => match self.editor_tab.current_editor() {
AppEditor => match self.editor_tab.current_editor() {
Some(editor) => editor.component_state.help_text.clone(),
None => {
if !self.editor_tab.is_empty() {
@@ -94,9 +96,9 @@ impl<'a> App<'a> {
"Failed to get current Editor while getting widget help text".to_string()
}
},
AppComponent::Explorer => self.explorer.component_state.help_text.clone(),
AppComponent::Logger => self.logger.component_state.help_text.clone(),
AppComponent::MenuBar => self.menu_bar.component_state.help_text.clone(),
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!(
@@ -130,15 +132,15 @@ impl<'a> App<'a> {
info!(target:Self::ID, "Changing widget focus to {:?}", focus);
self.clear_focus();
match focus {
AppComponent::Editor => match self.editor_tab.current_editor_mut() {
AppEditor => match self.editor_tab.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),
},
AppComponent::Explorer => self.explorer.component_state.set_focus(Focus::Active),
AppComponent::Logger => self.logger.component_state.set_focus(Focus::Active),
AppComponent::MenuBar => self.menu_bar.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;
}
@@ -253,21 +255,21 @@ impl<'a> Component for App<'a> {
}
// Handle events for all components.
let action = match self.last_active {
AppComponent::Editor => self.editor_tab.handle_event(event.clone())?,
AppComponent::Explorer => self.explorer.handle_event(event.clone())?,
AppComponent::Logger => self.logger.handle_event(event.clone())?,
AppComponent::MenuBar => self.menu_bar.handle_event(event.clone())?,
AppEditor => self.editor_tab.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()
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
{
if let Some(editor) = self.editor_tab.current_editor_mut() {
editor.handle_mouse_events(mouse)?;
if let Some(mouse) = event.as_mouse_event() {
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
if let Some(editor) = self.editor_tab.current_editor_mut() {
editor.handle_mouse_events(mouse)?;
}
self.explorer.handle_mouse_events(mouse)?;
self.logger.handle_mouse_events(mouse)?;
}
self.explorer.handle_mouse_events(mouse)?;
self.logger.handle_mouse_events(mouse)?;
}
// Handle actions returned from widgets that may need context on other widgets or app state.
@@ -347,7 +349,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppComponent::Explorer);
self.change_focus(AppExplorer);
Ok(Action::Handled)
}
KeyEvent {
@@ -356,7 +358,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppComponent::Editor);
self.change_focus(AppEditor);
Ok(Action::Handled)
}
KeyEvent {
@@ -365,7 +367,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppComponent::Logger);
self.change_focus(AppLogger);
Ok(Action::Handled)
}
KeyEvent {
@@ -374,7 +376,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppComponent::MenuBar);
self.change_focus(AppMenuBar);
Ok(Action::Handled)
}
KeyEvent {

View File

@@ -7,7 +7,6 @@
use crate::tui::component::Focus::Inactive;
use Focus::Active;
use anyhow::Result;
use libclide::theme::colors::Colors;
use log::trace;
use ratatui::crossterm::event::{Event, KeyEvent, MouseEvent};
use ratatui::style::Color;
@@ -99,8 +98,8 @@ pub enum Focus {
impl Focus {
pub(crate) fn get_active_color(&self) -> Color {
match self {
Active => Color::from_u32(Colors::css_to_u32(Colors::ACTIVE)?),
Inactive => Color::from_u32(Colors::css_to_u32(Colors::INACTIVE)?),
Active => Color::LightYellow,
Inactive => Color::White,
}
}
}

View File

@@ -115,8 +115,9 @@ impl Component for 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.
if let Action::Handled = self.handle_key_events(key_event)? {
return Ok(Action::Handled);
match self.handle_key_events(key_event)? {
Action::Handled => return Ok(Action::Handled),
_ => {}
}
}
self.event_handler.on_event(event, &mut self.state);

View File

@@ -4,7 +4,6 @@
use crate::tui::component::{Action, Component, ComponentState, Focus, FocusState};
use anyhow::{Context, Result, bail};
use libclide::fs::entry_meta::EntryMeta;
use log::trace;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, MouseEvent, MouseEventKind};
@@ -12,13 +11,14 @@ use ratatui::layout::{Alignment, Position, Rect};
use ratatui::prelude::Style;
use ratatui::style::{Color, Modifier};
use ratatui::widgets::{Block, Borders, StatefulWidget, Widget};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use tui_tree_widget::{Tree, TreeItem, TreeState};
#[derive(Debug)]
pub struct Explorer<'a> {
root_path: EntryMeta,
pub(crate) root_path: PathBuf,
tree_items: TreeItem<'a, String>,
tree_state: TreeState<String>,
pub(crate) component_state: ComponentState,
@@ -30,8 +30,8 @@ impl<'a> Explorer<'a> {
pub fn new(path: &PathBuf) -> Result<Self> {
trace!(target:Self::ID, "Building {}", Self::ID);
let explorer = Explorer {
root_path: EntryMeta::new(path)?,
tree_items: Self::build_tree_from_path(path)?,
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 |",
@@ -41,46 +41,46 @@ impl<'a> Explorer<'a> {
Ok(explorer)
}
/// Builds the file tree from a path using recursion.
/// The identifiers used for the TreeItems are normalized. Symlinks are not resolved.
/// Resolving symlinks would cause collisions on the TreeItem unique identifiers within the set.
fn build_tree_from_path<P: AsRef<Path>>(p: P) -> Result<TreeItem<'static, String>> {
let path = p.as_ref();
fn build_tree_from_path(path: PathBuf) -> Result<TreeItem<'static, String>> {
let mut children = vec![];
let path_meta = EntryMeta::new(path)?;
if let Ok(entries) = fs::read_dir(&path_meta.abs_path) {
let clean_path = fs::canonicalize(path)?;
if let Ok(entries) = fs::read_dir(&clean_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_meta.abs_path
clean_path
))?;
paths.sort();
for entry_path in paths {
let entry_meta = EntryMeta::new(&entry_path)?;
if entry_meta.is_dir {
children.push(Self::build_tree_from_path(&entry_meta.abs_path)?);
for path in paths {
if path.is_dir() {
children.push(Self::build_tree_from_path(path)?);
} else {
children.push(TreeItem::new_leaf(
entry_meta.abs_path.clone(),
entry_meta.file_name.clone(),
));
if let Ok(path) = fs::canonicalize(&path) {
let path_str = path.to_string_lossy().to_string();
children.push(TreeItem::new_leaf(
path_str + uuid::Uuid::new_v4().to_string().as_str(),
path.file_name()
.context("Failed to get file name from path.")?
.to_string_lossy()
.to_string(),
));
}
}
}
}
// Note: The first argument is a unique identifier, where no 2 TreeItems may share the same.
// For a file tree this is fine because we shouldn't list the same object twice.
TreeItem::new(
path_meta.abs_path.clone(),
path_meta.file_name.clone(),
clean_path.to_string_lossy().to_string() + uuid::Uuid::new_v4().to_string().as_str(),
clean_path
.file_name()
.context(format!("Failed to get file name from path: {clean_path:?}"))?
.to_string_lossy()
.to_string(),
children,
)
.context(format!(
"Failed to build tree from path: {:?}",
path_meta.abs_path
))
.context(format!("Failed to build tree from path: {clean_path:?}"))
}
pub fn selected(&self) -> Result<String> {
@@ -96,12 +96,16 @@ impl<'a> Explorer<'a> {
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()) {
if let Ok(tree) = Tree::new(&self.tree_items.children()) {
let file_name = self
.root_path
.file_name()
.unwrap_or_else(|| OsStr::new("Unknown"));
StatefulWidget::render(
tree.block(
Block::default()
.borders(Borders::ALL)
.title(self.root_path.file_name.clone())
.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),
@@ -130,21 +134,23 @@ impl<'a> Component for Explorer<'a> {
_ => {}
}
}
if let Some(mouse_event) = event.as_mouse_event()
&& let Action::Handled = self.handle_mouse_events(mouse_event)?
{
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> {
if key.code == KeyCode::Enter
&& let Ok(selected) = self.selected()
&& Path::new(&selected).is_file()
{
// Open a tab if the selected item is a file.
return Ok(Action::OpenTab);
if key.code == KeyCode::Enter {
if let Ok(selected) = self.selected() {
if Path::new(&selected).is_file() {
return Ok(Action::OpenTab);
}
}
// Otherwise fall through and handle Enter in the next match case.
}
let changed = match key.code {

View File

@@ -131,7 +131,7 @@ impl MenuBar {
opened: MenuBarItem,
) {
let popup_area = Self::rect_under_option(title_bar_anchor, area, 27, 10);
Clear.render(popup_area, buf);
Clear::default().render(popup_area, buf);
let options = opened.options().iter().map(|i| ListItem::new(i.id()));
StatefulWidget::render(
List::new(options)
@@ -150,14 +150,15 @@ impl MenuBar {
}
fn rect_under_option(anchor: Rect, area: Rect, width: u16, height: u16) -> Rect {
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
}
}