21 Commits

Author SHA1 Message Date
fed1d43ac9 Loggable trait and derive macro. 2026-02-22 21:56:32 -05:00
b0ed2e6e1f Move icon helper. 2026-02-22 21:56:32 -05:00
2e67c01377 Add icons to TUI. 2026-02-22 21:56:32 -05:00
5b356781ba Fix comments. 2026-02-22 21:56:32 -05:00
be969ef335 Add macros for logging. 2026-02-22 21:56:32 -05:00
c21ede292c Share colors for GUI and TUI.
The shim is there but it isn't used yet.
2026-02-22 21:56:32 -05:00
11fd130171 Update CI badge. 2026-02-22 21:56:32 -05:00
65242c2ea9 Add workspace dependencies. 2026-02-22 21:56:32 -05:00
73c467e19e Renames. 2026-02-22 21:56:32 -05:00
8fd0bb48de Set up workspace, add formatting CI. 2026-02-22 21:56:32 -05:00
d5671a5590 Fix clide lints. 2026-02-22 21:56:32 -05:00
7490e36a2f Fix libclide lints. 2026-02-22 21:56:32 -05:00
0e8910807e Add CI. 2026-02-22 21:56:19 -05:00
289f94300c Move EntryMeta to libclide. 2026-02-21 14:59:48 -05:00
d95aa680ff Add missing headers. 2026-02-21 14:56:28 -05:00
1119b3db9b Add libclide. 2026-02-21 14:41:17 -05:00
7ad25af13d Ignore .qmlls.ini. 2026-02-21 14:40:22 -05:00
7d4f23d82a Fix bug showing project name in explorer. 2026-02-21 14:24:44 -05:00
b4e14f7f27 Fix bug preventing TUI editors from opening.
Also fix bugs building file tree for paths including `../`.
2026-02-21 14:21:28 -05:00
f6fdd19f73 Add basic GUI support (#17) 2026-02-08 21:25:03 +00:00
2340fd7652 Pass application context to GUI. (#11) 2026-01-31 04:25:14 +00:00
59 changed files with 2175 additions and 1024 deletions

View File

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

20
.github/actions/setup-qt/action.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
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 }}

97
.github/workflows/check.yaml vendored Normal file
View File

@@ -0,0 +1,97 @@
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 libclide-macros
run: |
cargo b -p libclide-macros --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 libclide-macros
run: |
cargo test -p libclide-macros
- 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 libclide-macros
run: |
cargo clippy -p libclide-macros -- -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 libclide-macros
run: |
cargo fmt -p libclide-macros -- --check
- name: Format clide
run: |
cargo fmt --verbose -- --check

3
.gitignore vendored
View File

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

33
.qmllint.ini Normal file
View File

@@ -0,0 +1,33 @@
[General]
DisableDefaultImports=false
[Warnings]
AccessSingletonViaObject=warning
AttachedPropertyReuse=disable
BadSignalHandlerParameters=warning
CompilerWarnings=disable
Deprecated=warning
DuplicatePropertyBinding=warning
DuplicatedName=warning
ImportFailure=warning
IncompatibleType=warning
InheritanceCycle=warning
InvalidLintDirective=warning
LintPluginWarnings=disable
MissingProperty=warning
MissingType=warning
MultilineStrings=info
NonListProperty=warning
PrefixedImportType=warning
PropertyAliasCycles=warning
ReadOnlyProperty=warning
RequiredProperty=warning
RestrictedType=warning
TopLevelComponent=warning
UncreatableType=warning
UnqualifiedAccess=warning
UnresolvedType=warning
UnusedImports=info
UseProperFunction=warning
VarUsedBeforeDeclaration=warning
WithStatement=warning

510
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,33 @@ name = "clide"
version = "0.1.0"
edition = "2024"
[workspace]
resolver = "3"
members = [".", "libclide", "libclide-macros", ]
[workspace.dependencies]
anyhow = "1.0.100"
strum = "0.27.2"
log = { version = "0.4.27", features = [] }
devicons = "0.6.12"
[dependencies]
cxx = "1.0.95"
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"
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"] }
libclide = { path = "./libclide" }
libclide-macros = { path = "./libclide-macros" }
anyhow = { workspace = true }
strum = { workspace = true }
log = { workspace = true }
devicons = { workspace = true }
[build-dependencies]
# The link_qt_object_files feature is required for statically linking Qt 6.

View File

@@ -1,5 +1,7 @@
# 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.
@@ -19,6 +21,31 @@ And of course, [Rust](https://www.rust-lang.org/tools/install).
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
This project requires at least Qt 6.7.3 To check your Qt version
```bash
qmake6 -query QT_VERSION
```
Use the [Qt Installer](https://www.qt.io/development/download) to download and install the Qt version of your choice.
If the installer is run with `sudo`, the default install location is `/opt/Qt`, otherwise Qt will be installed into your home directory.
**You must set the QMAKE variable before building clide**. This should be a path to `qmake6` binary installed on your system.
The following export is the default installation path for Qt 6.7 on Ubuntu 24.04
```bash
export QMAKE=$HOME/Qt/6.7.3/gcc_64/bin/qmake6
export LD_LIBRARY_PATH=$HOME/Qt/6.7.3/gcc_64/lib
```
Though environment variables set using `export` will take precedence, these can also be set in [.cargo/config.toml](./.cargo/config.toml) for conveinence
```toml
[env]
QMAKE="/opt/Qt/6.7.3/gcc_64/bin/qmake6"
LD_LIBRARY_PATH="/opt/Qt/6.7.3/gcc_64/lib"
```
## Usage
To install and run clide
@@ -82,6 +109,8 @@ cargo run
clide
```
![image](./resources/gui.png)
## Development
It's recommended to use RustRover or Qt Creator for development.
@@ -126,6 +155,7 @@ 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)
* [QML and C++ Intergration](https://doc.qt.io/qt-6/qtqml-cppintegration-overview.html)
### Plugins

View File

@@ -1,13 +1,22 @@
use cxx_qt_build::{CxxQtBuilder, QmlModule};
fn main() {
CxxQtBuilder::new_qml_module(QmlModule::new("clide.module").qml_files(&[
"qml/main.qml",
"qml/ClideAboutWindow.qml",
CxxQtBuilder::new_qml_module(QmlModule::new("clide.module").qml_files([
"qml/ClideApplicationView.qml",
"qml/ClideEditorView.qml",
"qml/ClideExplorerView.qml",
"qml/ClideTreeView.qml",
"qml/ClideProjectView.qml",
"qml/ClideEditor.qml",
"qml/ClideMenuBar.qml",
"qml/Components/ClideAboutWindow.qml",
"qml/Components/ClideBreadCrumbs.qml",
"qml/Components/ClideEditor.qml",
"qml/Components/ClideHandle.qml",
"qml/Components/ClideLogger.qml",
"qml/Components/ClideMenu.qml",
"qml/Components/ClideMenuBar.qml",
"qml/Components/ClideMenuItem.qml",
"qml/Components/ClideScrollBar.qml",
"qml/Logger/Logger.qml",
"qml/main.qml",
]))
// Link Qt's Network library
// - Qt Core is always linked
@@ -18,6 +27,7 @@ fn main() {
.qt_module("Gui")
.qt_module("Svg")
.qt_module("Xml")
.qrc("./resources.qrc")
.files(["src/gui/colors.rs", "src/gui/filesystem.rs"])
.build();
}

View File

@@ -0,0 +1,12 @@
[package]
name = "libclide-macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"

View File

@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemStruct, parse_macro_input};
#[proc_macro_derive(Loggable)]
pub fn loggable(item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemStruct);
let struct_name = &input.ident;
let generics = &input.generics;
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let struct_name_str = struct_name.to_string();
let expanded = quote! {
impl #impl_generics libclide::log::Loggable for #struct_name #type_generics #where_clause {
const ID: &'static str = #struct_name_str;
}
};
TokenStream::from(expanded)
}

16
libclide/Cargo.lock generated Normal file
View File

@@ -0,0 +1,16 @@
# 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",
]

11
libclide/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "libclide"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
strum = { workspace = true }
log = { workspace = true }
devicons = { workspace = true }
libclide-macros = { path = "../libclide-macros" }

7
libclide/src/fs.rs Normal file
View File

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

View File

@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use anyhow::{Context, Result};
use devicons::FileIcon;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct EntryMeta {
pub abs_path: String,
pub file_name: String,
pub is_dir: bool,
pub icon: FileIcon,
}
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();
let icon = crate::fs::icon(&abs_path);
Ok(EntryMeta {
abs_path,
file_name,
is_dir,
icon,
})
}
}
pub fn icon<P: AsRef<str>>(p: P) -> FileIcon {
let path = p.as_ref();
if Path::new(&path).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 FileIcon::from("dir/");
}
FileIcon::from(path)
}

7
libclide/src/lib.rs Normal file
View File

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

9
libclide/src/log.rs Normal file
View File

@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
pub mod macros;
pub trait Loggable {
const ID: &'static str;
}

View File

@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
//! Logging targets allow filtering of log messages by their source. By default, the log crate sets
//! the target to the module path where the log macro was invoked if no target is provided.
//!
//! These macros essentially disable using the default target and instead require the target to be
//! explicitly set. This is to avoid implicit pooling of log messages under the same default target,
//! which can make it difficult to filter log messages by their source.
//!
//! The Loggable trait can be implemented to automatically associate log messages with a struct.
//! ```
//! use libclide_macros::Loggable;
//!
//! #[derive(Loggable)]
//! struct MyStruct;
//! impl MyStruct {
//! fn my_method(&self) {
//! libclide::info!("This log message will use target <Self as Loggable>::ID, which is 'MyStruct'");
//! }
//! }
//! ```
//!
//! If the struct does not derive or implement Loggable, the target variant of the log macros must
//! be used instead.
//! ```
//! libclide::info!(target: "CustomTarget", "This log message will have the target 'CustomTarget'");
//! ```
//!
#[macro_export]
macro_rules! info {
(target: $target:expr, $($arg:tt)+) => ({
log::info!(target: $target, $($arg)+)
});
($($arg:tt)+) => (log::info!(target: <Self as libclide::log::Loggable>::ID, $($arg)+))
}
#[macro_export]
macro_rules! debug {
(target: $target:expr, $($arg:tt)+) => ({
log::debug!(target: $target, $($arg)+)
});
($($arg:tt)+) => (log::debug!(target: <Self as libclide::log::Loggable>::ID, $($arg)+))
}
#[macro_export]
macro_rules! warn {
(target: $target:expr, $($arg:tt)+) => ({
log::warn!(target: $target, $($arg)+)
});
($($arg:tt)+) => (log::warn!(target: <Self as libclide::log::Loggable>::ID, $($arg)+))
}
#[macro_export]
macro_rules! error {
(target: $target:expr, $($arg:tt)+) => ({
log::error!(target: $target, $($arg)+)
});
($($arg:tt)+) => (log::error!(target: <Self as libclide::log::Loggable>::ID, $($arg)+))
}
#[macro_export]
macro_rules! trace {
(target: $target:expr, $($arg:tt)+) => ({
log::trace!(target: $target, $($arg)+)
});
($($arg:tt)+) => (log::trace!(target: <Self as libclide::log::Loggable>::ID, $($arg)+))
}

5
libclide/src/theme.rs Normal file
View File

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

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
/// Colors shared between the TUI and GUI for the current theme.
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 = "#d1d33f";
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";
/// Converts a CSS hex color string (e.g., "#RRGGBB" or "#RGB") to u32 in 0x00RRGGBB format.
pub fn css_to_u32(css: &str) -> u32 {
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_n(c, 2).collect::<String>())
.collect::<String>(),
6 => hex.to_string(),
_ => panic!("Invalid hex color length: {hex:?}"),
};
// Parse the hex string as u32, masking to ensure the top (alpha) byte is 0x00.
u32::from_str_radix(&hex_full, 16)
.map(|rgb| rgb & 0x00FF_FFFF)
.unwrap_or_else(|e| panic!("Failed to parse hex: {e:?}"))
}
}

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
import Logger 1.0
SplitView {
id: root
// Path to the directory of the project opened in clide.
required property string projectDir
anchors.fill: parent
// Customized handle to drag between the Navigation and the Editor.
handle: ClideHandle {
hovered: SplitHandle.hovered
pressed: SplitHandle.pressed
}
ClideExplorerView {
SplitView.fillHeight: true
SplitView.preferredWidth: 200
projectDir: root.projectDir
// Open files when clicked in the explorer.
onFileClicked: path => {
Logger.trace("Setting editor path from ClideExplorerView signal: " + path)
clideEditorView.filePath = path;
}
}
ClideEditorView {
id: clideEditorView
SplitView.fillHeight: true
SplitView.fillWidth: true
// Provide a path to the file currently open in the text editor.
// Initialized using the Default trait in Rust QML singleton FileSystem.
filePath: FileSystem.filePath
}
}

View File

@@ -1,206 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
SplitView {
id: root
Layout.fillHeight: true
Layout.fillWidth: true
orientation: Qt.Vertical
// The path to the file to show in the text editor.
// This is updated by a signal caught within ClideProjectView.
// Initialized by the Default trait for the Rust QML singleton FileSystem.
required property string filePath
// Customized handle to drag between the Editor and the Console.
handle: Rectangle {
border.color: SplitHandle.pressed ? RustColors.pressed : SplitHandle.hovered ? RustColors.hovered : RustColors.gutter
color: SplitHandle.pressed ? RustColors.pressed : SplitHandle.hovered ? RustColors.hovered : RustColors.gutter
implicitHeight: 8
radius: 2.5
// Execute these behaviors when the color is changed.
Behavior on color {
ColorAnimation {
duration: 400
}
}
}
RowLayout {
// We use a flickable to synchronize the position of the editor and
// the line numbers. This is necessary because the line numbers can
// extend the available height.
Flickable {
id: lineNumbers
Layout.fillHeight: true
Layout.fillWidth: false
// Calculating the width correctly is important as the number grows.
// We need to ensure space required to show N line number digits.
// We use log10 to find how many digits are needed in a line number.
// log10(9) ~= .95; log10(10) = 1.0; log10(100) = 2.0 ...etc
// We +1 to ensure space for at least 1 digit, as floor(1.95) = 1.
// The +10 is additional spacing and can be adjusted.
Layout.preferredWidth: fontMetrics.averageCharacterWidth * (Math.floor(Math.log10(textArea.lineCount)) + 1) + 10
contentY: editorFlickable.contentY
interactive: false
Column {
anchors.fill: parent
topPadding: textArea.topPadding
Repeater {
id: repeatedLineNumbers
// TODO: Bug where text wrapping shows as new line number.
model: textArea.lineCount
// This Item is used for each line number in the gutter.
delegate: Item {
// Calculates the height of each line in the text area.
height: textArea.contentHeight / textArea.lineCount
width: parent.width
required property int index
// Show the line number.
Label {
id: numbers
color: RustColors.linenumber
font: textArea.font
height: parent.height
horizontalAlignment: Text.AlignLeft
text: parent.index + 1
verticalAlignment: Text.AlignVCenter
width: parent.width - indicator.width
}
// Draw edge along the right side of the line number.
Rectangle {
id: indicator
anchors.left: numbers.right
color: RustColors.linenumber
height: parent.height
width: 1
}
}
}
}
}
Flickable {
id: editorFlickable
Layout.fillHeight: true
Layout.fillWidth: true
boundsBehavior: Flickable.StopAtBounds
height: 650
ScrollBar.horizontal: MyScrollBar {
}
ScrollBar.vertical: MyScrollBar {
}
TextArea.flickable: TextArea {
id: textArea
focus: true
persistentSelection: true
antialiasing: true
selectByMouse: true
selectionColor: RustColors.editor_highlight
selectedTextColor: RustColors.editor_highlighted_text
textFormat: Qt.AutoText
wrapMode: TextArea.Wrap
text: FileSystem.readFile(root.filePath)
onLinkActivated: function (link) {
Qt.openUrlExternally(link);
}
// TODO: Handle saving
// Component.onCompleted: {
// if (Qt.application.arguments.length === 2)
// textDocument.source = "file:" + Qt.application.arguments[1]
// else
// textDocument.source = "qrc:/texteditor.html"
// }
// textDocument.onStatusChanged: {
// // a message lookup table using computed properties:
// // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer
// const statusMessages = {
// [ TextDocument.ReadError ]: qsTr("Failed to load “%1”"),
// [ TextDocument.WriteError ]: qsTr("Failed to save “%1”"),
// [ TextDocument.NonLocalFileError ]: qsTr("Not a local file: “%1”"),
// }
// const err = statusMessages[textDocument.status]
// if (err) {
// errorDialog.text = err.arg(textDocument.source)
// errorDialog.open()
// }
// }
}
FontMetrics {
id: fontMetrics
font: textArea.font
}
}
}
TextArea {
id: areaConsole
height: 100
placeholderText: qsTr("Placeholder for bash terminal.")
placeholderTextColor: "white"
readOnly: true
wrapMode: TextArea.Wrap
background: Rectangle {
color: RustColors.editor_background
implicitHeight: 100
// border.color: control.enabled ? RustColors.active : RustColors.inactive
}
}
// We use an inline component to customize the horizontal and vertical
// scroll-bars. This is convenient when the component is only used in one file.
component MyScrollBar: ScrollBar {
id: scrollBar
// Scroll bar gutter
background: Rectangle {
implicitHeight: scrollBar.interactive ? 8 : 4
implicitWidth: scrollBar.interactive ? 8 : 4
color: RustColors.scrollbar_gutter
// Fade the scrollbar gutter when inactive.
opacity: scrollBar.active && scrollBar.size < 1.0 ? 1.0 : 0.2
Behavior on opacity {
OpacityAnimator {
duration: 500
}
}
}
// Scroll bar
contentItem: Rectangle {
implicitHeight: scrollBar.interactive ? 8 : 4
implicitWidth: scrollBar.interactive ? 8 : 4
// If we don't need a scrollbar, fallback to the gutter color.
// If the scrollbar is required change it's color based on activity.
color: scrollBar.size < 1.0 ? scrollBar.active ? RustColors.scrollbar_active : RustColors.scrollbar : RustColors.scrollbar_gutter
// Smooth transition between color changes based on the state above.
Behavior on color {
ColorAnimation {
duration: 1000
}
}
// Fade the scrollbar when inactive.
opacity: scrollBar.active && scrollBar.size < 1.0 ? 1.0 : 0.35
Behavior on opacity {
OpacityAnimator {
duration: 500
}
}
}
}
}

49
qml/ClideEditorView.qml Normal file
View File

@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
import Logger 1.0
Rectangle {
id: root
// The path to the file to show in the text editor.
// This is updated by a signal caught within ClideApplicationView.
required property string filePath
clip: true
color: "transparent"
radius: 20
SplitView {
anchors.fill: parent
orientation: Qt.Vertical
spacing: 3
// Customized handle to drag between the Editor and the Console.
handle: ClideHandle {
hovered: SplitHandle.hovered
pressed: SplitHandle.pressed
}
Component.onCompleted: {
// Show logging is working.
Logger.info("Info logs");
Logger.warn("Warning logs");
Logger.debug("Debug logs");
Logger.error("Error logs");
Logger.trace("Trace logs");
}
ClideEditor {
SplitView.preferredHeight: 650
}
ClideLogger {
}
}
}

62
qml/ClideExplorerView.qml Normal file
View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
import Logger 1.0
Rectangle {
id: root
required property string projectDir
signal fileClicked(string path)
clip: true
color: RustColors.explorer_background
topLeftRadius: 10
ColumnLayout {
anchors.fill: parent
spacing: 5
ClideBreadCrumbs {
id: breadCrumb
Layout.fillWidth: true
Layout.leftMargin: 15
Layout.rightMargin: 15
Layout.topMargin: 10
path: clideTreeView.rootDirectory
onCrumbClicked: path => {
Logger.trace("Crumb clicked: " + path);
clideTreeView.rootDirectory = path;
}
onResetRoot: {
clideTreeView.rootDirectory = clideTreeView.originalRootDirectory;
}
}
ClideTreeView {
id: clideTreeView
Layout.fillHeight: true
Layout.fillWidth: true
// Path to the directory opened in the file explorer.
originalRootDirectory: root.projectDir
rootDirectory: root.projectDir
// Pass the signal to the parent component using another signal.
onFileClicked: path => root.fileClicked(path)
onRootDirectoryChanged: {
Logger.log("Setting root directory: " + clideTreeView.rootDirectory);
breadCrumb.path = clideTreeView.rootDirectory;
}
}
}
}

View File

@@ -1,186 +0,0 @@
import QtQuick
import QtQuick.Controls
import clide.module 1.0
MenuBar {
// Base settings for each Menu.
component ClideMenu : Menu {
background: Rectangle {
color: RustColors.menubar
implicitWidth: 100
radius: 2
}
}
// Base settings for each MenuItem.
component ClideMenuItem : MenuItem {
id: root
background: Rectangle {
color: root.hovered ? RustColors.hovered : RustColors.unhovered
radius: 2.5
}
contentItem: IconLabel {
color: "black"
font.family: "Helvetica"
text: root.text
}
}
// Background for this MenuBar.
background: Rectangle {
color: RustColors.menubar
border.color: RustColors.menubar_border
}
//
// File Menu
Action {
id: actionNewProject
text: qsTr("&New Project...")
}
Action {
id: actionOpen
text: qsTr("&Open...")
}
Action {
id: actionSave
text: qsTr("&Save")
}
Action {
id: actionExit
text: qsTr("&Exit")
onTriggered: Qt.quit()
}
ClideMenu {
title: qsTr("&File")
ClideMenuItem {
action: actionNewProject
}
ClideMenuItem {
action: actionOpen
onTriggered: FileSystem.setDirectory(FileSystem.filePath)
}
ClideMenuItem {
action: actionSave
}
MenuSeparator {
background: Rectangle {
border.color: color
color: RustColors.menubar_border
implicitHeight: 3
implicitWidth: 200
}
}
ClideMenuItem {
action: actionExit
}
}
//
// Edit Menu
Action {
id: actionUndo
text: qsTr("&Undo")
}
Action {
id: actionRedo
text: qsTr("&Redo")
}
Action {
id: actionCut
text: qsTr("&Cut")
}
Action {
id: actionCopy
text: qsTr("&Copy")
}
Action {
id: actionPaste
text: qsTr("&Paste")
}
ClideMenu {
title: qsTr("&Edit")
ClideMenuItem {
action: actionUndo
}
ClideMenuItem {
action: actionRedo
}
ClideMenuItem {
action: actionCut
}
ClideMenuItem {
action: actionCopy
}
ClideMenuItem {
action: actionPaste
}
}
//
// View Menu
Action {
id: actionToolWindows
text: qsTr("&Tool Windows")
}
Action {
id: actionAppearance
text: qsTr("&Appearance")
}
ClideMenu {
title: qsTr("&View")
ClideMenuItem {
action: actionToolWindows
}
ClideMenuItem {
action: actionAppearance
}
}
//
// Help Menu
ClideAboutWindow {
id: clideAbout
}
Action {
id: actionDocumentation
text: qsTr("&Documentation")
}
Action {
id: actionAbout
// Toggle the about window with the menu item is clicked.
onTriggered: clideAbout.visible = !clideAbout.visible
text: qsTr("&About")
}
ClideMenu {
title: qsTr("&Help")
ClideMenuItem {
action: actionDocumentation
}
ClideMenuItem {
action: actionAbout
}
}
}

View File

@@ -1,60 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
SplitView {
id: root
// Path to the directory of the project opened in clide.
required property string projectDir
Layout.fillHeight: true
Layout.fillWidth: true
anchors.fill: parent
// Customized handle to drag between the Navigation and the Editor.
handle: Rectangle {
id: verticalSplitHandle
border.color: SplitHandle.pressed ? RustColors.pressed : SplitHandle.hovered ? RustColors.hovered : RustColors.gutter
color: SplitHandle.pressed ? RustColors.pressed : SplitHandle.hovered ? RustColors.hovered : RustColors.gutter
implicitWidth: 8
radius: 2.5
// Execute these behaviors when the color is changed.
Behavior on color {
ColorAnimation {
duration: 400
}
}
}
Rectangle {
id: navigationView
color: RustColors.explorer_background
SplitView.fillHeight: true
SplitView.minimumWidth: 0
SplitView.preferredWidth: 200
SplitView.maximumWidth: 250
StackLayout {
anchors.fill: parent
ClideTreeView {
id: clideTreeView
onFileClicked: path => root.projectDir = path
// Path to the directory opened in the file explorer.
rootDirectory: root.projectDir
}
}
}
ClideEditor {
SplitView.fillWidth: true
// Provide a path to the file currently open in the text editor.
// Initialized using the Default trait in Rust QML singleton FileSystem.
filePath: FileSystem.filePath
}
}

View File

@@ -1,153 +1,166 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Effects
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
import Logger 1.0
Rectangle {
TreeView {
id: root
color: RustColors.explorer_background
required property string rootDirectory
property int lastIndex: -1
required property string originalRootDirectory
property string rootDirectory
property int rootIndent: 25
signal fileClicked(string filePath)
TreeView {
id: fileSystemTreeView
anchors.margins: 15
boundsBehavior: Flickable.StopAtBounds
boundsMovement: Flickable.StopAtBounds
clip: true
property int lastIndex: -1
// The model is implemented in filesystem.rs
model: FileSystem
// Set the root directory on the Rust model, returning the QModeIndex to use for the root of the tree view widget.
rootIndex: FileSystem.setDirectory(root.rootDirectory)
model: FileSystem
anchors.fill: parent
boundsBehavior: Flickable.StopAtBounds
boundsMovement: Flickable.StopAtBounds
clip: true
// Provide our own custom ScrollIndicator for the TreeView.
ScrollBar.horizontal: ClideScrollBar {
sizeModifier: 3
}
ScrollBar.vertical: ClideScrollBar {
sizeModifier: 3
}
Component.onCompleted: {
FileSystem.setDirectory(root.rootDirectory)
fileSystemTreeView.expandRecursively(0, -1)
// The delegate represents a single entry in the filesystem.
delegate: TreeViewDelegate {
id: treeDelegate
required property string fileName
required property url filePath
required property int index
implicitHeight: 25
implicitWidth: root.width
indentation: 12
background: Rectangle {
color: current ? RustColors.explorer_folder_open : "transparent"
radius: 20
width: root.width
}
// Item name.
contentItem: Text {
anchors.left: itemIcon.right
anchors.leftMargin: 5
color: RustColors.explorer_text
text: treeDelegate.fileName
}
// Item Icon.
indicator: Label {
id: itemIcon
anchors.verticalCenter: parent.verticalCenter
antialiasing: true
enabled: false
focus: false
font.family: localFont.font.family
font.pixelSize: 18
smooth: true
// Get the icon from Rust implementation.
text: root.model.icon(filePath)
x: root.rootIndent + (treeDelegate.depth * treeDelegate.indentation) + (carrotIndicator.visible ? carrotIndicator.width : 0)
}
// The delegate represents a single entry in the filesystem.
delegate: TreeViewDelegate {
id: treeDelegate
indentation: 8
implicitWidth: fileSystemTreeView.width > 0 ? fileSystemTreeView.width : 250
implicitHeight: 25
// Directory carrot indicator.
Label {
id: carrotIndicator
required property int index
required property url filePath
required property string fileName
anchors.verticalCenter: parent.verticalCenter
font.family: localFont.font.family
font.pixelSize: 10
font.weight: localFont.font.weight
text: expanded ? "⮟" : "⮞"
visible: isTreeNode && hasChildren
x: (root.rootIndent - implicitWidth) + (depth * indentation)
}
// Apply colorization effects to the icon for the item.
MultiEffect {
anchors.fill: itemIcon
brightness: 1.0
colorization: 1.0
colorizationColor: {
const isFile = !treeDelegate.hasChildren;
if (isFile)
return Qt.lighter(RustColors.explorer_folder, 2);
const isExpandedFolder = treeDelegate.expanded && treeDelegate.hasChildren;
if (isExpandedFolder)
return Qt.darker(RustColors.explorer_folder, 2);
else
return RustColors.explorer_folder;
}
source: itemIcon
}
HoverHandler {
id: hoverHandler
indicator: Image {
id: directoryIcon
acceptedDevices: PointerDevice.Mouse
}
TapHandler {
acceptedButtons: Qt.LeftButton | Qt.RightButton
function setSourceImage() {
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.
onSingleTapped: (eventPoint, button) => {
switch (button) {
case Qt.LeftButton:
if (treeDelegate.hasChildren) {
return treeDelegate.expanded ?
folderOpen : folderClosed;
root.toggleExpanded(treeDelegate.row);
} else {
return file
}
}
x: treeDelegate.leftMargin + (treeDelegate.depth * treeDelegate.indentation)
anchors.verticalCenter: parent.verticalCenter
source: setSourceImage()
sourceSize.width: 15
sourceSize.height: 15
fillMode: Image.PreserveAspectFit
smooth: true
antialiasing: true
asynchronous: true
}
contentItem: Text {
text: treeDelegate.fileName
color: RustColors.explorer_text
}
background: Rectangle {
// TODO: Fix flickering from color transition on states here.
color: (treeDelegate.index === fileSystemTreeView.lastIndex)
? RustColors.explorer_text_selected
: (hoverHandler.hovered ? RustColors.explorer_hovered : "transparent")
radius: 2.5
opacity: hoverHandler.hovered ? 0.75 : 1.0
Behavior on color {
ColorAnimation {
duration: 300
}
}
}
HoverHandler {
id: hoverHandler
}
TapHandler {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onSingleTapped: (eventPoint, button) => {
switch (button) {
case Qt.LeftButton:
fileSystemTreeView.toggleExpanded(treeDelegate.row)
fileSystemTreeView.lastIndex = treeDelegate.index
// If this model item doesn't have children, it means it's
// representing a file.
if (!treeDelegate.hasChildren)
root.fileClicked(treeDelegate.filePath)
break;
case Qt.RightButton:
if (treeDelegate.hasChildren)
contextMenu.popup();
break;
}
}
}
Menu {
id: contextMenu
Action {
text: qsTr("Set as root index")
onTriggered: {
console.log("Setting directory: " + treeDelegate.filePath)
FileSystem.setDirectory(treeDelegate.filePath)
}
}
Action {
text: qsTr("Reset root index")
onTriggered: {
FileSystem.setDirectory("")
// If this model item doesn't have children, it means it's representing a file.
root.fileClicked(treeDelegate.filePath);
}
break;
case Qt.RightButton:
contextMenu.popup();
break;
}
}
}
ClideMenu {
id: contextMenu
// Provide our own custom ScrollIndicator for the TreeView.
ScrollIndicator.vertical: ScrollIndicator {
active: true
implicitWidth: 15
ClideMenuItem {
action: Action {
enabled: treeDelegate.hasChildren
text: qsTr("Set root")
contentItem: Rectangle {
implicitWidth: 6
implicitHeight: 6
onTriggered: {
Logger.debug("Setting new root directory: " + treeDelegate.filePath);
root.rootDirectory = treeDelegate.filePath;
}
}
}
ClideMenuItem {
action: Action {
text: qsTr("Reset root")
color: RustColors.scrollbar
opacity: fileSystemTreeView.movingVertically ? 0.5 : 0.0
Behavior on opacity {
OpacityAnimator {
duration: 500
onTriggered: {
Logger.log("Resetting root directory: " + root.originalRootDirectory);
root.rootDirectory = root.originalRootDirectory;
}
}
}
}
}
selectionModel: ItemSelectionModel {
}
FontLoader {
id: localFont
source: "qrc:/fonts/saucecodepro-xlight.ttf"
}
}

View File

@@ -1,21 +1,27 @@
// TODO: Header
// 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
import clide.module 1.0
import Logger 1.0
ApplicationWindow {
id: root
width: 450
height: 350
// Create the window with no frame and keep it on top.
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
color: RustColors.gutter
// Create the window with no frame and keep it on top.
flags: Qt.Tool | Qt.FramelessWindowHint
height: 350
width: 450
visible: root.active
// Hide the window when it loses focus.
onActiveChanged: {
if (!active) {
Logger.debug("Setting active: " + root.active)
if (!root.active) {
root.visible = false;
}
}
@@ -25,48 +31,38 @@ ApplicationWindow {
id: logo
anchors.left: parent.left
anchors.margins: 20
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 20
source: "../icons/kilroy-256.png"
sourceSize.width: 80
sourceSize.height: 80
fillMode: Image.PreserveAspectFit
smooth: true
antialiasing: true
asynchronous: true
fillMode: Image.PreserveAspectFit
smooth: true
source: "qrc:/images/kilroy.png"
sourceSize.height: 80
sourceSize.width: 80
}
ScrollView {
anchors.top: logo.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.margins: 20
anchors.right: parent.right
anchors.top: logo.bottom
TextArea {
selectedTextColor: RustColors.editor_highlighted_text
selectionColor: RustColors.editor_highlight
horizontalAlignment: Text.AlignHCenter
textFormat: Text.RichText
text: qsTr("<h3>About CLIDE</h3>"
+ "<p>A simple text editor written in Rust and QML using CXX-Qt.</p>"
+ "<p>Personal website <a href=\"http://shaunreed.com\">shaunreed.com</a></p>"
+ "<p>Project notes <a href=\"http://knoats.com\">knoats.com</a></p>"
+ "<p>This project is developed at <a href=\"http://git.shaunreed.com/shaunrd0/clide\">git.shaunreed.com</a></p>"
+ "<p><a href=\"https://github.com/KDAB/cxx-qt\">KDAB CXX-Qt repository</a></p>"
+ "<p>Copyright (C) 2026 Shaun Reed, all rights reserved.</p>")
color: RustColors.editor_text
wrapMode: Text.WordWrap
readOnly: true
antialiasing: true
background: null
color: RustColors.editor_text
horizontalAlignment: Text.AlignHCenter
readOnly: true
selectedTextColor: RustColors.editor_highlighted_text
selectionColor: RustColors.editor_highlight
text: qsTr("<h3>About CLIDE</h3>" + "<p>A simple text editor written in Rust and QML using CXX-Qt.</p>" + "<p>Personal website <a href=\"http://shaunreed.com\">shaunreed.com</a></p>" + "<p>Project notes <a href=\"http://knoats.com\">knoats.com</a></p>" + "<p>This project is developed at <a href=\"http://git.shaunreed.com/shaunrd0/clide\">git.shaunreed.com</a></p>" + "<p><a href=\"https://github.com/KDAB/cxx-qt\">KDAB CXX-Qt repository</a></p>" + "<p>Copyright (C) 2026 Shaun Reed, all rights reserved.</p>")
textFormat: Text.RichText
wrapMode: Text.WordWrap
onLinkActivated: function (link) {
Qt.openUrlExternally(link)
Qt.openUrlExternally(link);
}
}
}

View File

@@ -0,0 +1,111 @@
// 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
import clide.module 1.0
import Logger 1.0
Rectangle {
id: root
property var fullPaths: []
required property string path
property var segments: []
signal crumbClicked(string path)
signal resetRoot
function rebuildSegments(): string {
let cleaned = path;
if (cleaned.endsWith("/"))
cleaned = cleaned.slice(0, -1);
Logger.trace("Building segments for path: " + cleaned);
segments = ["/"];
fullPaths = ["/"];
let parts = cleaned.split("/");
let current = "";
// We already pushed the root `/` path during initialization, so skip index 0.
for (let i = 1; i < parts.length; ++i) {
current += "/" + parts[i];
Logger.trace("Pushing path: " + parts[i] + " Current: " + current);
segments.push(parts[i]);
fullPaths.push(current);
}
// Update the model used in the Repeater to show the new segments.
repeater.model = segments;
}
color: "transparent"
implicitHeight: breadcrumbRow.implicitHeight
width: parent.width
Component.onCompleted: rebuildSegments()
onPathChanged: rebuildSegments()
Flow {
id: breadcrumbRow
anchors.fill: parent
spacing: 2
width: parent.width
Repeater {
id: repeater
model: root.segments
delegate: Text {
required property int index
required property string modelData
function getText(): string {
if (modelData === "/") {
return modelData;
}
return modelData + "/";
}
// Show blue underlined hyperlink text if the mouse is hovering a segment.
color: mouseArea.containsMouse ? "#2a7fff" : RustColors.explorer_text
font.underline: mouseArea.containsMouse
text: getText()
// Click events for each path segment call signal so the parent can set the file explorer root path.
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Logger.info(index + "] Breadcrumb clicked:" + root.fullPaths[index]);
crumbClicked(root.fullPaths[index]);
}
}
}
}
}
TapHandler {
acceptedButtons: Qt.RightButton
onSingleTapped: contextMenu.popup()
}
ClideMenu {
id: contextMenu
ClideMenuItem {
action: Action {
text: qsTr("Reset root")
onTriggered: {
Logger.info("Resetting root directory from ClideBreadCrumbs");
resetRoot();
}
}
}
}
}

View File

@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import clide.module 1.0
import Logger 1.0
Rectangle {
color: RustColors.editor_background
RowLayout {
anchors.fill: parent
// We use a flickable to synchronize the position of the editor and
// the line numbers. This is necessary because the line numbers can
// extend the available height.
Flickable {
id: lineNumbers
Layout.fillHeight: true
Layout.fillWidth: false
// Calculating the width correctly is important as the number grows.
// We need to ensure space required to show N line number digits.
// We use log10 to find how many digits are needed in a line number.
// log10(9) ~= .95; log10(10) = 1.0; log10(100) = 2.0 ...etc
// We +1 to ensure space for at least 1 digit, as floor(1.95) = 1.
// The +10 is additional spacing and can be adjusted.
Layout.preferredWidth: fontMetrics.averageCharacterWidth * (Math.floor(Math.log10(textArea.lineCount)) + 1) + 10
contentY: editorFlickable.contentY
interactive: false
Column {
anchors.fill: parent
topPadding: textArea.topPadding
Repeater {
id: repeatedLineNumbers
// TODO: Bug where text wrapping shows as new line number.
model: textArea.lineCount
// This Item is used for each line number in the gutter.
delegate: Item {
required property int index
// Calculates the height of each line in the text area.
height: textArea.contentHeight / textArea.lineCount
width: parent.width
// Show the line number.
Label {
id: numbers
color: RustColors.linenumber
font: textArea.font
height: parent.height
horizontalAlignment: Text.AlignLeft
text: parent.index + 1
verticalAlignment: Text.AlignVCenter
width: parent.width - indicator.width
}
// Draw an edge along the right side of the line number.
Rectangle {
id: indicator
anchors.left: numbers.right
color: RustColors.linenumber
height: parent.height
width: 1
}
}
}
}
}
Flickable {
id: editorFlickable
Layout.fillHeight: true
Layout.fillWidth: true
boundsBehavior: Flickable.StopAtBounds
height: 650
ScrollBar.horizontal: ClideScrollBar {
}
ScrollBar.vertical: ClideScrollBar {
}
TextArea.flickable: TextArea {
id: textArea
antialiasing: true
focus: true
persistentSelection: true
selectByMouse: true
selectedTextColor: RustColors.editor_highlighted_text
selectionColor: RustColors.editor_highlight
text: FileSystem.readFile(root.filePath)
textFormat: Qt.AutoText
wrapMode: TextArea.Wrap
onLinkActivated: function (link) {
Qt.openUrlExternally(link);
}
}
FontMetrics {
id: fontMetrics
font: textArea.font
}
}
}
}

View File

@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import clide.module 1.0
Rectangle {
id: root
readonly property color currentColor: {
if (pressed) {
return RustColors.pressed;
} else if (hovered) {
return RustColors.hovered;
} else {
return "transparent";
}
}
required property bool hovered
required property bool pressed
border.color: currentColor
color: currentColor
implicitHeight: 8
implicitWidth: 8
radius: 2.5
opacity: root.hovered ? 1.0 : 0.0
// Execute these behaviors when the color is changed.
Behavior on color {
ColorAnimation {
duration: 500
}
}
Behavior on opacity {
OpacityAnimator {
duration: 500
}
}
}

View File

@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import clide.module 1.0
import Logger 1.0
Rectangle {
color: RustColors.terminal_background
radius: 10
ListModel {
id: model
}
ListView {
id: listView
function getLogColor(level) {
switch (level) {
case "INFO":
return RustColors.info_log;
case "DEBUG":
return RustColors.debug_log;
case "WARN":
return RustColors.warn_log;
case "ERROR":
return RustColors.error_log;
case "TRACE":
return RustColors.trace_log;
default:
return RustColors.info_log;
}
}
anchors.fill: parent
model: model
delegate: Text {
color: listView.getLogColor(level)
font.family: "monospace"
text: `[${level}] ${message}`
}
onCountChanged: Qt.callLater(positionViewAtEnd)
}
Connections {
function onLogged(level, message) {
model.append({
level,
message
});
}
target: Logger
}
}

View File

@@ -0,0 +1,18 @@
// 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
import clide.module 1.0
Menu {
background: Rectangle {
border.color: RustColors.hovered
border.width: 10
color: RustColors.menubar
implicitWidth: 100
radius: 5
}
}

View File

@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import clide.module 1.0
MenuBar {
// Background for this MenuBar.
background: Rectangle {
color: RustColors.menubar
}
//
// File Menu
ClideMenu {
title: qsTr("&File")
ClideMenuItem {
action: Action {
id: actionNewProject
text: qsTr("&New Project...")
}
}
ClideMenuItem {
action: Action {
id: actionOpen
text: qsTr("&Open...")
}
onTriggered: FileSystem.setDirectory(FileSystem.filePath)
}
ClideMenuItem {
action: Action {
id: actionSave
text: qsTr("&Save")
}
}
MenuSeparator {
background: Rectangle {
border.color: color
color: Qt.darker(RustColors.menubar, 1)
implicitHeight: 3
implicitWidth: 200
}
}
ClideMenuItem {
action: Action {
id: actionExit
text: qsTr("&Exit")
onTriggered: Qt.quit()
}
}
}
//
// Edit Menu
ClideMenu {
title: qsTr("&Edit")
ClideMenuItem {
action: Action {
id: actionUndo
text: qsTr("&Undo")
}
}
ClideMenuItem {
action: Action {
id: actionRedo
text: qsTr("&Redo")
}
}
ClideMenuItem {
action: Action {
id: actionCut
text: qsTr("&Cut")
}
}
ClideMenuItem {
action: Action {
id: actionCopy
text: qsTr("&Copy")
}
}
ClideMenuItem {
action: Action {
id: actionPaste
text: qsTr("&Paste")
}
}
}
//
// View Menu
ClideMenu {
title: qsTr("&View")
ClideMenuItem {
action: Action {
id: actionAppearance
text: qsTr("&Appearance")
}
}
ClideMenuItem {
action: Action {
id: actionToolWindows
text: qsTr("&Tool Windows")
}
}
}
//
// Help Menu
ClideAboutWindow {
id: clideAbout
}
ClideMenu {
title: qsTr("&Help")
ClideMenuItem {
action: Action {
id: actionDocumentation
text: qsTr("&Documentation")
}
}
ClideMenuItem {
action: Action {
id: actionAbout
text: qsTr("&About")
// Toggle the about window with the menu item is clicked.
onTriggered: clideAbout.visible = !clideAbout.visible
}
}
}
}

View File

@@ -0,0 +1,22 @@
// 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
import clide.module 1.0
MenuItem {
id: root
background: Rectangle {
color: root.hovered ? RustColors.hovered : RustColors.unhovered
radius: 1.0
}
contentItem: IconLabel {
color: "black"
font.family: "Helvetica"
text: root.text
}
}

View File

@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import clide.module 1.0
ScrollBar {
id: scrollBar
// Height, opacitiy, width
property int h: scrollBar.interactive ? sizeModifier * 2 : sizeModifier
property int o: scrollBar.active && scrollBar.size < 1.0 ? 1.0 : 0.0
property int sizeModifier: 4
property int w: scrollBar.interactive ? sizeModifier * 2 : sizeModifier
// Scroll bar gutter
background: Rectangle {
id: gutter
color: RustColors.scrollbar_gutter
implicitHeight: scrollBar.h
implicitWidth: scrollBar.w
// Fade the scrollbar gutter when inactive.
opacity: scrollBar.o
radius: 20
Behavior on opacity {
OpacityAnimator {
duration: 500
}
}
}
// Scroll bar
contentItem: Rectangle {
readonly property color barColor: {
if (scrollBar.size < 1.0) {
// If the scrollbar is required change it's color based on activity.
if (scrollBar.active) {
return RustColors.scrollbar_active;
} else {
return RustColors.scrollbar;
}
} else {
// If we don't need a scrollbar, fallback to the gutter color.
return gutter.color;
}
}
color: barColor
implicitHeight: scrollBar.h
implicitWidth: scrollBar.w
// Fade the scrollbar when inactive.
opacity: scrollBar.o
radius: 20
// Smooth transition between color changes based on the state above.
Behavior on color {
ColorAnimation {
duration: 1000
}
}
Behavior on opacity {
OpacityAnimator {
duration: 1000
}
}
}
}

9
qml/Components/qmldir Normal file
View File

@@ -0,0 +1,9 @@
ClideScrollBar ClideScrollBar.qml
ClideHandle ClideHandle.qml
ClideMenu ClideMenu.qml
ClideMenuItem ClideMenuItem.qml
ClideBreadCrumbs ClideBreadCrumbs.qml
ClideAboutWindow ClideAboutWindow.qml
ClideEditor ClideEditor.qml
ClideLogger ClideLogger.qml
ClideMenuBar ClideMenuBar.qml

35
qml/Logger/Logger.qml Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
pragma Singleton
import QtQuick
QtObject {
signal logged(string level, string message)
function debug(msg) {
console.log(msg);
logged("DEBUG", msg);
}
function error(msg) {
console.error(msg);
logged("ERROR", msg);
}
function info(msg) {
console.log(msg);
logged("INFO", msg);
}
function log(msg) {
console.log(msg);
logged("INFO", msg);
}
function trace(msg) {
console.log(msg);
logged("TRACE", msg);
}
function warn(msg) {
console.warn(msg);
logged("WARN", msg);
}
}

1
qml/Logger/qmldir Normal file
View File

@@ -0,0 +1 @@
singleton Logger 1.0 Logger.qml

View File

@@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
@@ -8,28 +12,32 @@ import clide.module 1.0
ApplicationWindow {
id: appWindow
required property string appContextPath
height: 800
title: "CLIDE"
title: "Clide"
visible: true
width: 1200
required property string appContextPath
menuBar: ClideMenuBar {
}
Rectangle {
anchors.fill: parent
color: RustColors.gutter
}
color: RustColors.menubar
width: appView.implicitWidth
height: appView.implicitHeight
MessageDialog {
id: errorDialog
ClideApplicationView {
id: appView
projectDir: appWindow.appContextPath
implicitHeight: appWindow.height
implicitWidth: appWindow.width
title: qsTr("Error")
}
ClideProjectView {
projectDir: appWindow.appContextPath
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 20
anchors.topMargin: 10
}
}
}

10
resources.qrc Normal file
View File

@@ -0,0 +1,10 @@
<RCC>
<qresource prefix="/images">
<file alias="kilroy.png">resources/images/kilroy-256.png</file>
</qresource>
<qresource prefix="/fonts">
<file alias="saucecodepro.ttf">resources/SauceCodeProNerdFont-Black.ttf</file>
<file alias="saucecodepro-light.ttf">resources/SauceCodeProNerdFont-Light.ttf</file>
<file alias="saucecodepro-xlight.ttf">resources/SauceCodeProNerdFont-ExtraLight.ttf</file>
</qresource>
</RCC>

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/gui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,13 +1,16 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use crate::AppContext;
use anyhow::Result;
use cxx_qt_lib::{QMapPair, QMapPair_QString_QVariant, QString, QVariant};
use log::trace;
pub mod colors;
pub mod filesystem;
pub fn run(app_context: AppContext) -> Result<()> {
trace!(target:"gui::run()", "Starting the GUI editor at {:?}", app_context.path);
libclide::trace!(target:"gui::run()", "Starting the GUI editor at {:?}", app_context.path);
use cxx_qt_lib::{QGuiApplication, QQmlApplicationEngine, QUrl};

View File

@@ -1,3 +1,10 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// 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++" {
@@ -31,12 +38,16 @@ pub mod qobject {
#[qproperty(QColor, explorer_background)]
#[qproperty(QColor, explorer_folder)]
#[qproperty(QColor, explorer_folder_open)]
#[qproperty(QColor, terminal_background)]
#[qproperty(QColor, info_log)]
#[qproperty(QColor, debug_log)]
#[qproperty(QColor, warn_log)]
#[qproperty(QColor, error_log)]
#[qproperty(QColor, trace_log)]
type RustColors = super::RustColorsImpl;
}
}
use cxx_qt_lib::QColor;
pub struct RustColorsImpl {
hovered: QColor,
unhovered: QColor,
@@ -60,33 +71,45 @@ pub struct RustColorsImpl {
explorer_background: QColor,
explorer_folder: QColor,
explorer_folder_open: QColor,
terminal_background: QColor,
info_log: QColor,
debug_log: QColor,
warn_log: QColor,
error_log: QColor,
trace_log: QColor,
}
impl Default for RustColorsImpl {
fn default() -> Self {
Self {
hovered: QColor::try_from("#303234").unwrap(),
unhovered: QColor::try_from("#3c3f41").unwrap(),
pressed: QColor::try_from("#4b4f51").unwrap(),
menubar: QColor::try_from("#3c3f41").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("#2b2b2b").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("#3b3b3b").unwrap(),
explorer_text_selected: QColor::try_from("#8b8b8b").unwrap(),
explorer_background: QColor::try_from("#676c70").unwrap(),
explorer_folder: QColor::try_from("#54585b").unwrap(),
explorer_folder_open: QColor::try_from("#FFF").unwrap(),
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(),
}
}
}

View File

@@ -1,3 +1,17 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use cxx_qt_lib::{QModelIndex, QString};
use dirs;
use std::fs;
use std::path::Path;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::html::{IncludeBackground, append_highlighted_html_for_styled_line};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
#[cxx_qt::bridge]
pub mod qobject {
unsafe extern "C++" {
@@ -17,7 +31,6 @@ pub mod qobject {
#[qml_element]
#[qml_singleton]
#[qproperty(QString, file_path, cxx_name = "filePath")]
#[qproperty(QModelIndex, root_index, cxx_name = "rootIndex")]
type FileSystem = super::FileSystemImpl;
#[inherit]
@@ -36,25 +49,14 @@ pub mod qobject {
#[qinvokable]
#[cxx_name = "setDirectory"]
fn set_directory(self: Pin<&mut FileSystem>, path: &QString) -> QModelIndex;
#[qinvokable]
fn icon(self: Pin<&mut FileSystem>, path: &QString) -> QString;
}
}
use cxx_qt_lib::{QModelIndex, QString};
use dirs;
use log::warn;
use std::fs;
use std::io::BufRead;
use syntect::easy::HighlightFile;
use syntect::highlighting::ThemeSet;
use syntect::html::{
IncludeBackground, append_highlighted_html_for_styled_line, start_highlighted_html_snippet,
};
use syntect::parsing::SyntaxSet;
// TODO: Impleent a provider for QFileSystemModel::setIconProvider for icons.
pub struct FileSystemImpl {
file_path: QString,
root_index: QModelIndex,
}
// Default is explicit to make the editor open this source file initially.
@@ -62,7 +64,6 @@ impl Default for FileSystemImpl {
fn default() -> Self {
Self {
file_path: QString::from(file!()),
root_index: Default::default(),
}
}
}
@@ -72,42 +73,47 @@ impl qobject::FileSystem {
if path.is_empty() {
return QString::default();
}
if !fs::metadata(path.to_string())
.expect(format!("Failed to get file metadata {path:?}").as_str())
.is_file()
{
warn!(target:"FileSystem", "Attempted to open file {path:?} that is not a valid file");
let meta = fs::metadata(path.to_string())
.unwrap_or_else(|_| panic!("Failed to get file metadata {path:?}"));
if !meta.is_file() {
libclide::warn!(target:"FileSystem", "Attempted to open file {path:?} that is not a valid file");
return QString::default();
}
let ss = SyntaxSet::load_defaults_nonewlines();
let ts = ThemeSet::load_defaults();
let theme = &ts.themes["base16-ocean.dark"];
let path_str = path.to_string();
if let Ok(lines) = fs::read_to_string(path_str.as_str()) {
let ss = SyntaxSet::load_defaults_nonewlines();
let ts = ThemeSet::load_defaults();
let theme = &ts.themes["base16-ocean.dark"];
let lang = ss
.find_syntax_by_extension(
Path::new(path_str.as_str())
.extension()
.map(|s| s.to_str())
.unwrap_or_else(|| Some("md"))
.expect("Failed to get file extension"),
)
.unwrap_or_else(|| ss.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(lang, theme);
// If you care about the background, see `start_highlighted_html_snippet(theme);`.
let mut output = String::from("<pre>\n");
for line in LinesWithEndings::from(lines.as_str()) {
let regions = highlighter
.highlight_line(line, &ss)
.expect("Failed to highlight");
let mut highlighter =
HighlightFile::new(path.to_string(), &ss, theme).expect("Failed to create highlighter");
let (mut output, _bg) = start_highlighted_html_snippet(theme);
let mut line = String::new();
while highlighter
.reader
.read_line(&mut line)
.expect("Failed to read file.")
> 0
{
let regions = highlighter
.highlight_lines
.highlight_line(&line, &ss)
.expect("Failed to highlight");
append_highlighted_html_for_styled_line(
&regions[..],
IncludeBackground::No,
&mut output,
)
.expect("Failed to insert highlighted html");
}
append_highlighted_html_for_styled_line(
&regions[..],
IncludeBackground::Yes,
&mut output,
)
.expect("Failed to insert highlighted html");
line.clear();
output.push_str("</pre>\n");
QString::from(output)
} else {
QString::default()
}
output.push_str("</pre>\n");
QString::from(output)
}
// There will never be more than one column.
@@ -118,20 +124,23 @@ 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())
.unwrap_or_else(|_| panic!("Failed to get metadata for path {path:?}"))
.is_dir()
{
self.set_root_path(path)
} else {
// If the initial directory can't be opened, attempt to find the home directory.
self.set_root_path(&QString::from(
dirs::home_dir()
.expect("Failed to get home directory")
.as_path()
.to_str()
.unwrap()
.to_string(),
))
let homedir = dirs::home_dir()
.expect("Failed to get home directory")
.as_path()
.to_str()
.unwrap()
.to_string();
self.set_root_path(&QString::from(homedir))
}
}
fn icon(self: std::pin::Pin<&mut Self>, path: &QString) -> QString {
QString::from(libclide::fs::icon(path.to_string().as_str()).to_string())
}
}

View File

@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use anyhow::{Context, Result, anyhow};
use clap::Parser;
use log::{info, trace};
use std::process::{Command, Stdio};
pub mod gui;
@@ -52,7 +55,7 @@ impl AppContext {
// If no path was provided, use the current directory.
None => std::env::current_dir().context("Failed to obtain current directory")?,
};
info!(target:"main()", "Root path detected: {path:?}");
libclide::info!(target:"main()", "Root path detected: {path:?}");
Ok(Self {
path,
@@ -76,9 +79,9 @@ fn main() -> Result<()> {
RunMode::GuiAttached => gui::run(app_context),
RunMode::Tui => tui::run(app_context),
RunMode::Gui => {
trace!(target:"main()", "Starting GUI in a new process");
libclide::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

@@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
mod about;
mod app;
mod component;
@@ -9,7 +13,8 @@ mod menu_bar;
use crate::AppContext;
use anyhow::{Context, Result};
use log::{LevelFilter, debug, info, trace};
use libclide_macros::Loggable;
use log::LevelFilter;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
@@ -24,24 +29,22 @@ use tui_logger::{
TuiLoggerFile, TuiLoggerLevelOutput, init_logger, set_default_level, set_log_file,
};
#[derive(Loggable)]
struct Tui {
terminal: Terminal<CrosstermBackend<Stdout>>,
root_path: std::path::PathBuf,
}
pub fn run(app_context: AppContext) -> Result<()> {
trace!(target:Tui::ID, "Starting TUI");
libclide::trace!(target: "clide::tui::run", "Starting TUI");
Tui::new(app_context)?.start()
}
impl Tui {
pub const ID: &str = "Tui";
fn new(app_context: AppContext) -> Result<Self> {
trace!(target:Self::ID, "Building {}", Self::ID);
init_logger(LevelFilter::Trace)?;
set_default_level(LevelFilter::Trace);
debug!(target:Self::ID, "Logging initialized");
libclide::debug!("Logging initialized");
let mut dir = env::temp_dir();
dir.push("clide.log");
@@ -53,7 +56,7 @@ impl Tui {
.output_file(false)
.output_separator(':');
set_log_file(file_options);
debug!(target:Self::ID, "Logging to file: {dir:?}");
libclide::debug!("Logging to file: {dir:?}");
Ok(Self {
terminal: Terminal::new(CrosstermBackend::new(stdout()))?,
@@ -62,7 +65,7 @@ impl Tui {
}
fn start(self) -> Result<()> {
info!(target:Self::ID, "Starting the TUI editor at {:?}", self.root_path);
libclide::info!("Starting the TUI editor at {:?}", self.root_path);
ratatui::crossterm::execute!(
stdout(),
EnterAlternateScreen,
@@ -79,7 +82,7 @@ impl Tui {
}
fn stop() -> Result<()> {
info!(target:Self::ID, "Stopping the TUI editor");
libclide::info!("Stopping the TUI editor");
disable_raw_mode()?;
ratatui::crossterm::execute!(
stdout(),

View File

@@ -1,16 +1,19 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use libclide_macros::Loggable;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};
#[derive(Loggable)]
pub struct About {}
impl About {
#[allow(unused)]
pub const ID: &str = "About";
pub fn new() -> Self {
// trace!(target:Self::id(), "Building {}", Self::id());
// libclide::trace!("Building {}", Self::id());
Self {}
}
}
@@ -64,8 +67,8 @@ impl Widget for About {
.map(|l| Line::from(Span::raw(*l)))
.collect();
Clear::default().render(kilroy_rect, buf);
Clear::default().render(chunks[1], buf);
Clear.render(kilroy_rect, buf);
Clear.render(chunks[1], buf);
Paragraph::new(about_lines)
.block(
Block::default()

View File

@@ -1,13 +1,16 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// 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 libclide::log::Loggable;
use libclide_macros::Loggable;
use ratatui::DefaultTerminal;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event;
@@ -22,12 +25,13 @@ use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppComponent {
AppEditor,
AppExplorer,
AppLogger,
AppMenuBar,
Editor,
Explorer,
Logger,
MenuBar,
}
#[derive(Loggable)]
pub struct App<'a> {
editor_tab: EditorTab,
explorer: Explorer<'a>,
@@ -38,16 +42,14 @@ pub struct App<'a> {
}
impl<'a> App<'a> {
pub const ID: &'static str = "App";
pub fn new(root_path: PathBuf) -> Result<Self> {
trace!(target:Self::ID, "Building {}", Self::ID);
libclide::trace!("Building {}", <Self as Loggable>::ID);
let app = Self {
editor_tab: EditorTab::new(),
explorer: Explorer::new(&root_path)?,
logger: Logger::new(),
menu_bar: MenuBar::new(),
last_active: AppEditor,
last_active: AppComponent::Editor,
about: false,
};
Ok(app)
@@ -55,13 +57,13 @@ impl<'a> App<'a> {
/// Logic that should be executed once on application startup.
pub fn start(&mut self) -> Result<()> {
trace!(target:Self::ID, "Starting App");
libclide::trace!("Starting App");
Ok(())
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
self.start()?;
trace!(target:Self::ID, "Entering App run loop");
libclide::trace!("Entering App run loop");
loop {
terminal.draw(|f| {
f.render_widget(&mut self, f.area());
@@ -83,18 +85,18 @@ 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 {
AppEditor => match self.editor_tab.current_editor() {
AppComponent::Editor => match self.editor_tab.current_editor() {
Some(editor) => editor.component_state.help_text.clone(),
None => {
if !self.editor_tab.is_empty() {
error!(target:Self::ID, "Failed to get Editor while drawing bottom status bar");
libclide::error!("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(),
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(),
};
Paragraph::new(
concat!(
@@ -111,32 +113,32 @@ impl<'a> App<'a> {
}
fn clear_focus(&mut self) {
info!(target:Self::ID, "Clearing all widget focus");
libclide::info!("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_tab.current_editor_mut() {
None => {
error!(target:Self::ID, "Failed to get current Editor while clearing focus")
libclide::error!("Failed to get current Editor while clearing focus")
}
Some(editor) => editor.component_state.set_focus(Focus::Inactive),
}
}
fn change_focus(&mut self, focus: AppComponent) {
info!(target:Self::ID, "Changing widget focus to {:?}", focus);
libclide::info!("Changing widget focus to {:?}", focus);
self.clear_focus();
match focus {
AppEditor => match self.editor_tab.current_editor_mut() {
AppComponent::Editor => match self.editor_tab.current_editor_mut() {
None => {
error!(target:Self::ID, "Failed to get current Editor while changing focus")
libclide::error!("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),
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),
}
self.last_active = focus;
}
@@ -251,21 +253,21 @@ impl<'a> Component for App<'a> {
}
// Handle events for all components.
let action = match self.last_active {
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())?,
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())?,
};
// 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_tab.current_editor_mut() {
editor.handle_mouse_events(mouse)?;
}
self.explorer.handle_mouse_events(mouse)?;
self.logger.handle_mouse_events(mouse)?;
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)?;
}
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.
@@ -273,13 +275,15 @@ impl<'a> Component for App<'a> {
Action::Quit | Action::Handled => Ok(action),
Action::Save => match self.editor_tab.current_editor_mut() {
None => {
error!(target:Self::ID, "Failed to get current editor while handling App Action::Save");
libclide::error!(
"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}");
libclide::error!("Failed to save editor contents: {e}");
Ok(Action::Noop)
}
},
@@ -298,14 +302,16 @@ impl<'a> Component for App<'a> {
Err(_) => Ok(Action::Noop),
},
Action::ReloadFile => {
trace!(target:Self::ID, "Reloading file for current editor");
libclide::trace!("Reloading file for current editor");
if let Some(editor) = self.editor_tab.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");
libclide::error!(
"Failed to get current editor while handling App Action::ReloadFile"
);
Ok(Action::Noop)
}
}
@@ -345,7 +351,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppExplorer);
self.change_focus(AppComponent::Explorer);
Ok(Action::Handled)
}
KeyEvent {
@@ -354,7 +360,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppEditor);
self.change_focus(AppComponent::Editor);
Ok(Action::Handled)
}
KeyEvent {
@@ -363,7 +369,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppLogger);
self.change_focus(AppComponent::Logger);
Ok(Action::Handled)
}
KeyEvent {
@@ -372,7 +378,7 @@ impl<'a> Component for App<'a> {
kind: KeyEventKind::Press,
state: _state,
} => {
self.change_focus(AppMenuBar);
self.change_focus(AppComponent::MenuBar);
Ok(Action::Handled)
}
KeyEvent {

View File

@@ -1,9 +1,14 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
#![allow(dead_code, unused_variables)]
use crate::tui::component::Focus::Inactive;
use Focus::Active;
use anyhow::Result;
use log::trace;
use libclide::theme::colors::Colors;
use libclide_macros::Loggable;
use ratatui::crossterm::event::{Event, KeyEvent, MouseEvent};
use ratatui::style::Color;
@@ -57,7 +62,7 @@ pub trait Component {
}
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, Loggable)]
pub struct ComponentState {
pub(crate) focus: Focus,
pub(crate) vis: Visibility,
@@ -70,7 +75,7 @@ impl ComponentState {
}
fn new() -> Self {
trace!(target:Self::id(), "Building {}", Self::id());
libclide::trace!(target:Self::id(), "Building {}", Self::id());
Self {
focus: Active,
vis: Visibility::Visible,
@@ -94,8 +99,8 @@ pub enum Focus {
impl Focus {
pub(crate) fn get_active_color(&self) -> Color {
match self {
Active => Color::LightYellow,
Inactive => Color::White,
Active => Color::from_u32(Colors::css_to_u32(Colors::ACTIVE)),
Inactive => Color::from_u32(Colors::css_to_u32(Colors::INACTIVE)),
}
}
}

View File

@@ -1,9 +1,14 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
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 libclide::log::Loggable;
use libclide_macros::Loggable;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Rect};
@@ -12,19 +17,18 @@ use ratatui::widgets::{Block, Borders, Padding, Widget};
use std::path::PathBuf;
use syntect::parsing::SyntaxSet;
#[derive(Loggable)]
pub struct Editor {
pub state: EditorState,
pub event_handler: EditorEventHandler,
pub file_path: Option<std::path::PathBuf>,
pub file_path: Option<PathBuf>,
syntax_set: SyntaxSet,
pub(crate) component_state: ComponentState,
}
impl Editor {
pub const ID: &str = "Editor";
pub fn new(path: &std::path::Path) -> Self {
trace!(target:Self::ID, "Building {}", Self::ID);
libclide::trace!("Building {}", <Self as Loggable>::ID);
Editor {
state: EditorState::default(),
event_handler: EditorEventHandler::default(),
@@ -38,10 +42,10 @@ impl Editor {
}
pub fn reload_contents(&mut self) -> Result<()> {
trace!(target:Self::ID, "Reloading editor file contents {:?}", self.file_path);
libclide::trace!("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");
libclide::error!("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),
@@ -49,7 +53,7 @@ impl Editor {
}
pub fn set_contents(&mut self, path: &std::path::Path) -> Result<()> {
trace!(target:Self::ID, "Setting Editor contents from path {:?}", path);
libclide::trace!("Setting Editor contents from path {:?}", path);
if let Ok(contents) = std::fs::read_to_string(path) {
let lines: Vec<_> = contents
.lines()
@@ -65,10 +69,10 @@ impl Editor {
pub fn save(&self) -> Result<()> {
if let Some(path) = &self.file_path {
trace!(target:Self::ID, "Saving Editor contents {:?}", path);
libclide::trace!("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");
libclide::error!("Failed saving Editor contents; file_path was None");
bail!("File not saved. No file path set.")
}
}
@@ -111,9 +115,8 @@ 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.
match self.handle_key_events(key_event)? {
Action::Handled => return Ok(Action::Handled),
_ => {}
if let Action::Handled = self.handle_key_events(key_event)? {
return Ok(Action::Handled);
}
}
self.event_handler.on_event(event, &mut self.state);

View File

@@ -1,7 +1,12 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
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 libclide::log::Loggable;
use libclide_macros::Loggable;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
@@ -12,6 +17,7 @@ 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
#[derive(Loggable)]
pub struct EditorTab {
pub(crate) editors: HashMap<String, Editor>,
tab_order: Vec<String>,
@@ -19,10 +25,8 @@ pub struct EditorTab {
}
impl EditorTab {
pub const ID: &str = "EditorTab";
pub fn new() -> Self {
trace!(target:Self::ID, "Building {}", Self::ID);
libclide::trace!("Building {}", <Self as Loggable>::ID);
Self {
editors: HashMap::new(),
tab_order: Vec::new(),
@@ -32,7 +36,11 @@ impl EditorTab {
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);
libclide::trace!(
"Moving from {} to next editor tab at {}",
self.current_editor,
next
);
self.set_tab_focus(Focus::Active, next);
self.current_editor = next;
}
@@ -42,7 +50,11 @@ impl EditorTab {
.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);
libclide::trace!(
"Moving from {} to previous editor tab at {}",
self.current_editor,
prev
);
self.set_tab_focus(Focus::Active, prev);
self.current_editor = prev;
}
@@ -51,7 +63,7 @@ impl EditorTab {
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}");
libclide::error!("Failed to get editor tab key with invalid index {index}");
}
None
}
@@ -69,16 +81,19 @@ impl EditorTab {
}
pub fn set_current_tab_focus(&mut self, focus: Focus) {
trace!(target:Self::ID, "Setting current tab {} focus to {:?}", self.current_editor, focus);
libclide::trace!(
"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);
libclide::trace!("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,
libclide::trace!(
"New tab {} focus set to Active; Setting current tab {} to Inactive",
index,
self.current_editor
@@ -87,12 +102,11 @@ impl EditorTab {
}
match self.get_editor_key(index) {
None => {
error!(target:Self::ID, "Failed setting tab focus for invalid key {index}");
libclide::error!("Failed setting tab focus for invalid key {index}");
}
Some(key) => match self.editors.get_mut(&key) {
None => {
error!(
target:Self::ID,
libclide::error!(
"Failed to update tab focus at index {} with invalid key: {}",
self.current_editor,
self.tab_order[self.current_editor]
@@ -104,12 +118,12 @@ impl EditorTab {
}
pub fn open_tab(&mut self, path: &std::path::Path) -> Result<()> {
trace!(target:Self::ID, "Opening new EditorTab with path {:?}", path);
libclide::trace!("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");
libclide::warn!("EditorTab already opened with this file");
return Ok(());
}
@@ -134,12 +148,12 @@ impl EditorTab {
.to_owned();
match self.editors.remove(&key) {
None => {
error!(target:Self::ID, "Failed to remove editor tab {key} with invalid index {index}")
libclide::error!("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}")
libclide::info!("Closed editor tab {key} at index {index}")
}
}
Ok(())

View File

@@ -1,33 +1,36 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use crate::tui::component::{Action, Component, ComponentState, Focus, FocusState};
use anyhow::{Context, Result, bail};
use log::trace;
use libclide::fs::entry_meta::EntryMeta;
use libclide::log::Loggable;
use libclide_macros::Loggable;
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, Widget};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use tui_tree_widget::{Tree, TreeItem, TreeState};
#[derive(Debug)]
#[derive(Debug, Loggable)]
pub struct Explorer<'a> {
pub(crate) root_path: PathBuf,
root_path: EntryMeta,
tree_items: TreeItem<'a, String>,
tree_state: TreeState<String>,
pub(crate) component_state: ComponentState,
}
impl<'a> Explorer<'a> {
pub const ID: &'static str = "Explorer";
pub fn new(path: &PathBuf) -> Result<Self> {
trace!(target:Self::ID, "Building {}", Self::ID);
libclide::trace!("Building {}", <Self as Loggable>::ID);
let explorer = Explorer {
root_path: path.to_owned(),
tree_items: Self::build_tree_from_path(path.to_owned())?,
root_path: EntryMeta::new(path)?,
tree_items: Self::build_tree_from_path(path)?,
tree_state: TreeState::default(),
component_state: ComponentState::default().with_help_text(concat!(
"(↑/k)/(↓/j): Select item | ←/h: Close folder | →/l: Open folder |",
@@ -37,46 +40,46 @@ impl<'a> Explorer<'a> {
Ok(explorer)
}
fn build_tree_from_path(path: PathBuf) -> Result<TreeItem<'static, String>> {
/// 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();
let mut children = vec![];
let clean_path = fs::canonicalize(path)?;
if let Ok(entries) = fs::read_dir(&clean_path) {
let path_meta = EntryMeta::new(path)?;
if let Ok(entries) = fs::read_dir(&path_meta.abs_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: {:?}",
clean_path
&path_meta.abs_path
))?;
paths.sort();
for path in paths {
if path.is_dir() {
children.push(Self::build_tree_from_path(path)?);
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)?);
} else {
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(),
));
}
children.push(TreeItem::new_leaf(
entry_meta.abs_path.clone(),
format!("{} {}", entry_meta.icon.icon, entry_meta.file_name.as_str()),
));
}
}
}
// 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(
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(),
path_meta.abs_path.clone(),
format!("{} {}", path_meta.icon.icon, path_meta.file_name.as_str()),
children,
)
.context(format!("Failed to build tree from path: {clean_path:?}"))
.context(format!(
"Failed to build tree from path: {:?}",
path_meta.abs_path
))
}
pub fn selected(&self) -> Result<String> {
@@ -92,16 +95,12 @@ 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()) {
let file_name = self
.root_path
.file_name()
.unwrap_or_else(|| OsStr::new("Unknown"));
if let Ok(tree) = Tree::new(self.tree_items.children()) {
StatefulWidget::render(
tree.block(
Block::default()
.borders(Borders::ALL)
.title(file_name.to_string_lossy())
.title(self.root_path.file_name.clone())
.border_style(Style::default().fg(self.component_state.get_active_color()))
.title_style(Style::default().fg(Color::Green))
.title_alignment(Alignment::Center),
@@ -130,23 +129,21 @@ impl<'a> Component for Explorer<'a> {
_ => {}
}
}
if let Some(mouse_event) = event.as_mouse_event() {
match self.handle_mouse_events(mouse_event)? {
Action::Handled => return Ok(Action::Handled),
_ => {}
}
if let Some(mouse_event) = event.as_mouse_event()
&& let Action::Handled = self.handle_mouse_events(mouse_event)?
{
return Ok(Action::Handled);
}
Ok(Action::Pass)
}
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Action> {
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.
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);
}
let changed = match key.code {

View File

@@ -1,5 +1,11 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use crate::tui::component::{Action, Component, ComponentState, Focus, FocusState};
use log::{LevelFilter, trace};
use libclide::log::Loggable;
use libclide_macros::Loggable;
use log::LevelFilter;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::layout::Rect;
@@ -9,16 +15,15 @@ use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget, TuiWidgetEvent, Tui
/// 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().
#[derive(Loggable)]
pub struct Logger {
state: TuiWidgetState,
pub(crate) component_state: ComponentState,
}
impl Logger {
pub const ID: &str = "Logger";
pub fn new() -> Self {
trace!(target:Self::ID, "Building {}", Self::ID);
libclide::trace!("Building {}", <Self as Loggable>::ID);
let state = TuiWidgetState::new();
state.transition(TuiWidgetEvent::HideKey);
Self {

View File

@@ -1,9 +1,13 @@
// SPDX-FileCopyrightText: 2026, Shaun Reed <shaunrd0@gmail.com>
//
// SPDX-License-Identifier: GNU General Public License v3.0 or later
use crate::tui::component::{Action, Component, ComponentState, FocusState};
use crate::tui::menu_bar::MenuBarItemOption::{
About, CloseTab, Exit, Reload, Save, ShowHideExplorer, ShowHideLogger,
};
use anyhow::Context;
use log::trace;
use libclide_macros::Loggable;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
@@ -76,6 +80,7 @@ impl MenuBarItem {
}
}
#[derive(Debug, Loggable)]
pub struct MenuBar {
selected: MenuBarItem,
opened: Option<MenuBarItem>,
@@ -84,11 +89,9 @@ pub struct MenuBar {
}
impl MenuBar {
pub const ID: &str = "MenuBar";
const DEFAULT_HELP: &str = "(←/h)/(→/l): Select option | Enter: Choose selection";
pub fn new() -> Self {
trace!(target:Self::ID, "Building {}", Self::ID);
libclide::trace!("Building");
Self {
selected: MenuBarItem::File,
opened: None,
@@ -127,7 +130,7 @@ impl MenuBar {
opened: MenuBarItem,
) {
let popup_area = Self::rect_under_option(title_bar_anchor, area, 27, 10);
Clear::default().render(popup_area, buf);
Clear.render(popup_area, buf);
let options = opened.options().iter().map(|i| ListItem::new(i.id()));
StatefulWidget::render(
List::new(options)
@@ -146,15 +149,14 @@ impl MenuBar {
}
fn rect_under_option(anchor: Rect, area: Rect, width: u16, height: u16) -> Rect {
let rect = 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
// trace!("Building Rect under MenuBar popup {}", rect);
}
}