diff --git a/.cargo/config.toml b/.cargo/config.toml index a12f1c0..6a03545 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,6 @@ - [build] rustflags = [ "-C", "link-arg=-fuse-ld=lld", ] + +[env] +QMAKE="/opt/Qt/6.7.3/gcc_64/bin/qmake6" +LD_LIBRARY_PATH="/opt/Qt/6.7.3/gcc_64/lib" diff --git a/.qmllint.ini b/.qmllint.ini new file mode 100644 index 0000000..07b81e9 --- /dev/null +++ b/.qmllint.ini @@ -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 diff --git a/Cargo.lock b/Cargo.lock index c2b50d7..55a5491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,7 @@ dependencies = [ "cxx-qt", "cxx-qt-build", "cxx-qt-lib", + "devicons", "dirs", "edtui", "log", @@ -667,6 +668,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "devicons" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e47e2f330cf4fdd5a958dcef921b9523ffc21ab6713aa5e77ba2cce03904b" +dependencies = [ + "lazy_static", +] + [[package]] name = "digest" version = "0.10.7" diff --git a/Cargo.toml b/Cargo.toml index fe5a0fc..237e11d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ tui-logger = "0.18.1" edtui = "0.11.1" strum = "0.27.2" uuid = { version = "1.19.0", features = ["v4"] } +devicons = "0.6.12" [build-dependencies] # The link_qt_object_files feature is required for statically linking Qt 6. diff --git a/README.md b/README.md index 8a2fff5..2bb4dfc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,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 +107,8 @@ cargo run clide ``` +![image](./resources/gui.png) + ## Development It's recommended to use RustRover or Qt Creator for development. @@ -126,6 +153,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 diff --git a/build.rs b/build.rs index 1d20876..fce6777 100644 --- a/build.rs +++ b/build.rs @@ -2,12 +2,21 @@ use cxx_qt_build::{CxxQtBuilder, QmlModule}; fn main() { CxxQtBuilder::new_qml_module(QmlModule::new("clide.module").qml_files(&[ - "qml/main.qml", - "qml/ClideAboutWindow.qml", + "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(); } diff --git a/qml/ClideApplicationView.qml b/qml/ClideApplicationView.qml new file mode 100644 index 0000000..dd88e48 --- /dev/null +++ b/qml/ClideApplicationView.qml @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } +} diff --git a/qml/ClideEditor.qml b/qml/ClideEditor.qml deleted file mode 100644 index 512b470..0000000 --- a/qml/ClideEditor.qml +++ /dev/null @@ -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 - } - } - } - } -} diff --git a/qml/ClideEditorView.qml b/qml/ClideEditorView.qml new file mode 100644 index 0000000..788e46a --- /dev/null +++ b/qml/ClideEditorView.qml @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 { + } + } +} diff --git a/qml/ClideExplorerView.qml b/qml/ClideExplorerView.qml new file mode 100644 index 0000000..4a17d6b --- /dev/null +++ b/qml/ClideExplorerView.qml @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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; + } + } + } +} diff --git a/qml/ClideMenuBar.qml b/qml/ClideMenuBar.qml deleted file mode 100644 index 73a6655..0000000 --- a/qml/ClideMenuBar.qml +++ /dev/null @@ -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 - } - } -} diff --git a/qml/ClideProjectView.qml b/qml/ClideProjectView.qml deleted file mode 100644 index 05d55ef..0000000 --- a/qml/ClideProjectView.qml +++ /dev/null @@ -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 - } -} diff --git a/qml/ClideTreeView.qml b/qml/ClideTreeView.qml index 76ea446..5bdbb54 100644 --- a/qml/ClideTreeView.qml +++ b/qml/ClideTreeView.qml @@ -1,153 +1,166 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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,"; - let folderClosed = "data:image/svg+xml;utf8,"; - let file = "data:image/svg+xml;utf8,"; - // 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" + } } diff --git a/qml/ClideAboutWindow.qml b/qml/Components/ClideAboutWindow.qml similarity index 59% rename from qml/ClideAboutWindow.qml rename to qml/Components/ClideAboutWindow.qml index 1b7c8a8..81e6585 100644 --- a/qml/ClideAboutWindow.qml +++ b/qml/Components/ClideAboutWindow.qml @@ -1,21 +1,27 @@ -// TODO: Header +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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("

About CLIDE

" - + "

A simple text editor written in Rust and QML using CXX-Qt.

" - + "

Personal website shaunreed.com

" - + "

Project notes knoats.com

" - + "

This project is developed at git.shaunreed.com

" - + "

KDAB CXX-Qt repository

" - + "

Copyright (C) 2026 Shaun Reed, all rights reserved.

") - 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("

About CLIDE

" + "

A simple text editor written in Rust and QML using CXX-Qt.

" + "

Personal website shaunreed.com

" + "

Project notes knoats.com

" + "

This project is developed at git.shaunreed.com

" + "

KDAB CXX-Qt repository

" + "

Copyright (C) 2026 Shaun Reed, all rights reserved.

") + textFormat: Text.RichText + wrapMode: Text.WordWrap onLinkActivated: function (link) { - Qt.openUrlExternally(link) + Qt.openUrlExternally(link); } } } diff --git a/qml/Components/ClideBreadCrumbs.qml b/qml/Components/ClideBreadCrumbs.qml new file mode 100644 index 0000000..7b02e93 --- /dev/null +++ b/qml/Components/ClideBreadCrumbs.qml @@ -0,0 +1,107 @@ +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(); + } + } + } + } +} diff --git a/qml/Components/ClideEditor.qml b/qml/Components/ClideEditor.qml new file mode 100644 index 0000000..a443b64 --- /dev/null +++ b/qml/Components/ClideEditor.qml @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } + } + } +} diff --git a/qml/Components/ClideHandle.qml b/qml/Components/ClideHandle.qml new file mode 100644 index 0000000..17924fe --- /dev/null +++ b/qml/Components/ClideHandle.qml @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } + } +} diff --git a/qml/Components/ClideLogger.qml b/qml/Components/ClideLogger.qml new file mode 100644 index 0000000..367ece8 --- /dev/null +++ b/qml/Components/ClideLogger.qml @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } +} diff --git a/qml/Components/ClideMenu.qml b/qml/Components/ClideMenu.qml new file mode 100644 index 0000000..6fd076e --- /dev/null +++ b/qml/Components/ClideMenu.qml @@ -0,0 +1,14 @@ +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 + } +} diff --git a/qml/Components/ClideMenuBar.qml b/qml/Components/ClideMenuBar.qml new file mode 100644 index 0000000..cfd9518 --- /dev/null +++ b/qml/Components/ClideMenuBar.qml @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } + } + } +} diff --git a/qml/Components/ClideMenuItem.qml b/qml/Components/ClideMenuItem.qml new file mode 100644 index 0000000..4b859be --- /dev/null +++ b/qml/Components/ClideMenuItem.qml @@ -0,0 +1,18 @@ +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 + } +} diff --git a/qml/Components/ClideScrollBar.qml b/qml/Components/ClideScrollBar.qml new file mode 100644 index 0000000..d06601c --- /dev/null +++ b/qml/Components/ClideScrollBar.qml @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } + } + } +} diff --git a/qml/Components/qmldir b/qml/Components/qmldir new file mode 100644 index 0000000..97b6732 --- /dev/null +++ b/qml/Components/qmldir @@ -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 diff --git a/qml/Logger/Logger.qml b/qml/Logger/Logger.qml new file mode 100644 index 0000000..7e60c04 --- /dev/null +++ b/qml/Logger/Logger.qml @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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); + } +} diff --git a/qml/Logger/qmldir b/qml/Logger/qmldir new file mode 100644 index 0000000..019474e --- /dev/null +++ b/qml/Logger/qmldir @@ -0,0 +1 @@ +singleton Logger 1.0 Logger.qml diff --git a/qml/main.qml b/qml/main.qml index fdbbf11..e0a07e2 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 + } } } diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..ac4c2f2 --- /dev/null +++ b/resources.qrc @@ -0,0 +1,10 @@ + + + resources/images/kilroy-256.png + + + resources/SauceCodeProNerdFont-Black.ttf + resources/SauceCodeProNerdFont-Light.ttf + resources/SauceCodeProNerdFont-ExtraLight.ttf + + \ No newline at end of file diff --git a/resources/SauceCodeProNerdFont-Black.ttf b/resources/SauceCodeProNerdFont-Black.ttf new file mode 100644 index 0000000..bbfc2b5 Binary files /dev/null and b/resources/SauceCodeProNerdFont-Black.ttf differ diff --git a/resources/SauceCodeProNerdFont-ExtraLight.ttf b/resources/SauceCodeProNerdFont-ExtraLight.ttf new file mode 100644 index 0000000..0df0cdc Binary files /dev/null and b/resources/SauceCodeProNerdFont-ExtraLight.ttf differ diff --git a/resources/SauceCodeProNerdFont-Light.ttf b/resources/SauceCodeProNerdFont-Light.ttf new file mode 100644 index 0000000..431684e Binary files /dev/null and b/resources/SauceCodeProNerdFont-Light.ttf differ diff --git a/resources/gui.png b/resources/gui.png new file mode 100644 index 0000000..d02163d Binary files /dev/null and b/resources/gui.png differ diff --git a/icons/kilroy-256.png b/resources/images/kilroy-256.png similarity index 100% rename from icons/kilroy-256.png rename to resources/images/kilroy-256.png diff --git a/src/gui.rs b/src/gui.rs index add9993..48b1e0b 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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}; diff --git a/src/gui/colors.rs b/src/gui/colors.rs index d113109..9fb5ab3 100644 --- a/src/gui/colors.rs +++ b/src/gui/colors.rs @@ -1,4 +1,9 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// SPDX-License-Identifier: GNU General Public License v3.0 or later + #[cxx_qt::bridge] + pub mod qobject { unsafe extern "C++" { include!("cxx-qt-lib/qcolor.h"); @@ -31,6 +36,12 @@ 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; } } @@ -60,6 +71,12 @@ 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 { @@ -68,7 +85,7 @@ impl Default for RustColorsImpl { 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: QColor::try_from("#262626").unwrap(), menubar_border: QColor::try_from("#575757").unwrap(), scrollbar: QColor::try_from("#4b4f51").unwrap(), scrollbar_active: QColor::try_from("#4b4f51").unwrap(), @@ -76,17 +93,23 @@ impl Default for RustColorsImpl { 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_background: QColor::try_from("#1E1F22").unwrap(), editor_text: QColor::try_from("#acaea3").unwrap(), editor_highlighted_text: QColor::try_from("#ccced3").unwrap(), editor_highlight: QColor::try_from("#ccced3").unwrap(), gutter: QColor::try_from("#1e1f22").unwrap(), explorer_hovered: QColor::try_from("#4c5053").unwrap(), - explorer_text: QColor::try_from("#3b3b3b").unwrap(), - explorer_text_selected: QColor::try_from("#8b8b8b").unwrap(), - explorer_background: QColor::try_from("#676c70").unwrap(), + explorer_text: QColor::try_from("#FFF").unwrap(), + explorer_text_selected: QColor::try_from("#262626").unwrap(), + explorer_background: QColor::try_from("#1E1F22").unwrap(), explorer_folder: QColor::try_from("#54585b").unwrap(), - explorer_folder_open: QColor::try_from("#FFF").unwrap(), + explorer_folder_open: QColor::try_from("#393B40").unwrap(), + terminal_background: QColor::try_from("#111111").unwrap(), + info_log: QColor::try_from("#C4FFFF").unwrap(), + debug_log: QColor::try_from("#9148AF").unwrap(), + warn_log: QColor::try_from("#C4A958").unwrap(), + error_log: QColor::try_from("#ff5555").unwrap(), + trace_log: QColor::try_from("#ffaa00").unwrap(), } } } diff --git a/src/gui/filesystem.rs b/src/gui/filesystem.rs index 557203b..3528cfe 100644 --- a/src/gui/filesystem.rs +++ b/src/gui/filesystem.rs @@ -1,3 +1,19 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// SPDX-License-Identifier: GNU General Public License v3.0 or later + +use cxx_qt_lib::{QModelIndex, QString}; +use devicons::FileIcon; +use dirs; +use log::warn; +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 +33,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 +51,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 +66,6 @@ impl Default for FileSystemImpl { fn default() -> Self { Self { file_path: QString::from(file!()), - root_index: Default::default(), } } } @@ -72,42 +75,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() - { + let meta = fs::metadata(path.to_string()) + .expect(format!("Failed to get file metadata {path:?}").as_str()); + if !meta.is_file() { warn!(target:"FileSystem", "Attempted to open file {path:?} that is not a valid file"); return QString::default(); } - 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("
\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(
+                    ®ions[..],
+                    IncludeBackground::No,
+                    &mut output,
+                )
+                .expect("Failed to insert highlighted html");
+            }
 
-            append_highlighted_html_for_styled_line(
-                ®ions[..],
-                IncludeBackground::Yes,
-                &mut output,
-            )
-            .expect("Failed to insert highlighted html");
-            line.clear();
+            output.push_str("
\n"); + QString::from(output) + } else { + return QString::default(); } - output.push_str("\n"); - QString::from(output) } // There will never be more than one column. @@ -124,14 +132,24 @@ impl qobject::FileSystem { 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 { + let str = path.to_string(); + if Path::new(&str).is_dir() { + // Ensures directories are given a folder icon and not mistakenly resolved to a language. + // For example, a directory named `cpp` would otherwise return a C++ icon. + return QString::from(FileIcon::from("dir/").to_string()) + } + let icon = FileIcon::from(str); + QString::from(icon.to_string()) + } } diff --git a/src/main.rs b/src/main.rs index 917b90b..c975f90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// SPDX-License-Identifier: GNU General Public License v3.0 or later + use anyhow::{Context, Result, anyhow}; use clap::Parser; use log::{info, trace}; diff --git a/src/tui.rs b/src/tui.rs index d0569c5..2652ca7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// SPDX-License-Identifier: GNU General Public License v3.0 or later + mod about; mod app; mod component; diff --git a/src/tui/about.rs b/src/tui/about.rs index aa68961..7b53b7b 100644 --- a/src/tui/about.rs +++ b/src/tui/about.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// SPDX-License-Identifier: GNU General Public License v3.0 or later + use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; diff --git a/src/tui/app.rs b/src/tui/app.rs index d925d2c..d5569eb 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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}; diff --git a/src/tui/component.rs b/src/tui/component.rs index 2b73ae2..27b3999 100644 --- a/src/tui/component.rs +++ b/src/tui/component.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// SPDX-License-Identifier: GNU General Public License v3.0 or later + #![allow(dead_code, unused_variables)] use crate::tui::component::Focus::Inactive; diff --git a/src/tui/editor.rs b/src/tui/editor.rs index a1f0081..0c20d3e 100644 --- a/src/tui/editor.rs +++ b/src/tui/editor.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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::{ diff --git a/src/tui/editor_tab.rs b/src/tui/editor_tab.rs index 76c2de4..21d8112 100644 --- a/src/tui/editor_tab.rs +++ b/src/tui/editor_tab.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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}; diff --git a/src/tui/explorer.rs b/src/tui/explorer.rs index fbd2063..cf295bb 100644 --- a/src/tui/explorer.rs +++ b/src/tui/explorer.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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; diff --git a/src/tui/logger.rs b/src/tui/logger.rs index c68e441..d88cf68 100644 --- a/src/tui/logger.rs +++ b/src/tui/logger.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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 ratatui::buffer::Buffer; diff --git a/src/tui/menu_bar.rs b/src/tui/menu_bar.rs index 1de6b76..129d83a 100644 --- a/src/tui/menu_bar.rs +++ b/src/tui/menu_bar.rs @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2026, Shaun Reed +// +// 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,