24 Commits

Author SHA1 Message Date
zxq5 7b051208f3 probably broken tbh
Continuous integration / build (push) Failing after 3m15s
2025-08-20 22:57:16 +01:00
zxq5 c891a8be58 ui improvements and feature flags for AI integration
Continuous integration / build (push) Failing after 4m9s
2025-08-18 01:06:30 +01:00
zxq5 b6d0e10a7d UI fix
Continuous integration / build (push) Has been cancelled
2025-08-11 01:34:27 +01:00
zxq5 8cf54f3346 moved ai assistant to editor side panel
Continuous integration / build (push) Successful in 1m32s
2025-08-10 00:55:02 +01:00
zxq5 874d7ff377 updated .gitignore
Continuous integration / build (push) Successful in 1m32s
2025-08-09 23:44:21 +01:00
zxq5 3c47ae1305 using a gitea compatible upload-artifact action
Continuous integration / build (push) Successful in 1m41s
2025-08-09 23:20:47 +01:00
zxq5 67d8902eaf using a gitea compatible upload-artifact action
Continuous integration / build (push) Failing after 13s
2025-08-09 23:19:49 +01:00
zxq5 2a7ec348c5 updated repo name in actiosn
Continuous integration / build (push) Failing after 6m19s
2025-08-09 23:03:15 +01:00
zxq5 0653427557 updated repo name in actiosn
Continuous integration / build (push) Successful in 6m17s
2025-08-09 22:56:26 +01:00
zxq5 6b6f65713d commented out windows target
Continuous integration / build (push) Successful in 1m32s
2025-08-09 22:47:43 +01:00
zxq5 c21819e786 fixed cargo warns
Continuous integration / build (push) Failing after 1m48s
2025-08-09 22:42:42 +01:00
zxq5 cc7eb3e7fb updated gitignore and setup PKGBUILD for archlinux packaging
Continuous integration / build (push) Failing after 7m6s
2025-08-09 22:35:05 +01:00
zxq5 745e03a74f idk what i changed
Continuous integration / build (push) Has been cancelled
2025-07-30 22:47:02 +01:00
zxq5 e5a485d3a7 reworked settings, general interface improvements, more AI configuration, bugfixes and QOL.
Continuous integration / build (push) Has been cancelled
2025-07-30 02:48:49 +01:00
zxq5 6c40f34122 added streaming for AI content generation
Continuous integration / build (push) Failing after 6m27s
2025-07-28 01:27:03 +01:00
zxq5 5294feb5ff updated roadmap
Continuous integration / build (push) Failing after 6m28s
2025-07-28 00:05:29 +01:00
zxq5 6832f1c5bc test
Continuous integration / build (push) Has been cancelled
2025-07-28 00:03:24 +01:00
zxq5 65213d3a9c test
Continuous integration / build (push) Has been cancelled
2025-07-28 00:03:04 +01:00
zxq5 73d5654e25 removed unnecessary
Continuous integration / build (push) Has been cancelled
2025-07-28 00:02:00 +01:00
zxq5 71f8f76f99 added font to repo
Continuous integration / build (push) Has been cancelled
2025-07-28 00:01:16 +01:00
zxq5 4d0e0c90a7 minor changes
Continuous integration / build (push) Failing after 6m40s
2025-07-27 23:23:47 +01:00
zxq5 bc71a30bfa fixed ai integration
Continuous integration / build (push) Failing after 6m27s
2025-07-27 19:15:30 +01:00
zxq5 6a25c6c05c idk
Continuous integration / build (push) Failing after 6m47s
2025-07-24 00:18:41 +01:00
zxq5 c3ae04ab75 idk 2025-07-24 00:18:09 +01:00
44 changed files with 1905 additions and 1613 deletions
+9 -16
View File
@@ -3,30 +3,23 @@ on: [push, pull_request]
name: Continuous integration
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- run: cargo check
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
# with:
# target: x86_64-pc-windows-gnu
- run: rustup target add x86_64-pc-windows-gnu
- run: cargo build --release
- run: cargo build --release --target x86_64-pc-windows-gnu
- uses: actions/upload-artifact@v4
# - run: cargo build --release --target x86_64-pc-windows-gnu
- uses: christopherhx/gitea-upload-artifact@v4
with:
name: linux-release
path: target/release/doc_writing_tool
path: target/release/worldcoder
- uses: actions/upload-artifact@v4
with:
name: windows-release
path: target/x86_64-pc-windows-gnu/release/doc_writing_tool.exe
# - uses: actions/upload-artifact@v4
# with:
# name: windows-release
# path: target/x86_64-pc-windows-gnu/release/doc_writing_tool.exe
+4
View File
@@ -1,3 +1,7 @@
/target
*/target
/project
Cargo.lock
*.pkg.tar.zst
/pkg
/.config
+7 -2
View File
@@ -1,9 +1,14 @@
{
"rust-analyzer.check.command": "clippy",
"editor.formatOnSave": true,
"rust-analyzer.cargo.features": "all",
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true
"files.trimTrailingWhitespace": true,
"rust-analyzer.cargo.features": [
"llm",
"native"
],
"rust-analyzer.cargo.noDefaultFeatures": true,
"rust-analyzer.cargo.allFeatures": false
}
Generated
+188 -199
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -1,12 +1,11 @@
[package]
name = "doc_writing_tool"
name = "worldcoder"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.32.0"
egui = { version = "0.32.0", features = ["serde"] }
editor = { path = "./editor" }
egui_extras = { version = "0.32.0", features = [
"chrono",
"datepicker",
@@ -16,15 +15,17 @@ egui_extras = { version = "0.32.0", features = [
egui_file = "0.23.0"
image = { version = "0.25.6", features = ["jpeg", "png"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_json = "1.0.141"
chrono = { version = "0.4.41", features = ["serde"] }
thiserror = "2.0.12"
thiserror = "2.0.14"
egui_commonmark = { version = "0.21.1", features = ["embedded_image"] }
walkdir = "2.5.0"
uuid = { version = "1.17.0", features = ["v4"] }
reqwest = { version = "0.12.22", features = ["blocking", "json"] }
reqwest = { version = "0.12.23", features = ["blocking", "json"] }
tempfile = "3.20.0"
itertools = "0.14.0"
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-gcc-ar"
[features]
default = ["native", "llm"]
web = []
native = []
llm = []
+24
View File
@@ -0,0 +1,24 @@
pkgname=worldcoder
pkgver=0.1.1
pkgrel=3
makedepends=('rust' 'cargo')
arch=('i686' 'x86_64' 'armv6h' 'armv7h')
prepare() {
cargo fetch --locked --target "$CARCH-unknown-linux-gnu"
}
build() {
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --frozen --release --all-features
}
check() {
export RUSTUP_TOOLCHAIN=stable
cargo test --frozen --all-features
}
package() {
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/$pkgname"
}
-354
View File
@@ -1,354 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ab_glyph"
version = "0.2.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0f4f6fbdc5ee39f2ede9f5f3ec79477271a6d6a2baff22310d51736bda6cea"
dependencies = [
"ab_glyph_rasterizer",
"owned_ttf_parser",
]
[[package]]
name = "ab_glyph_rasterizer"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2187590a23ab1e3df8681afdf0987c48504d80291f002fcdb651f0ef5e25169"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "cfg-if"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "ecolor"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a631732d995184114016fab22fc7e3faf73d6841c2d7650395fe251fbcd9285"
dependencies = [
"emath",
]
[[package]]
name = "editor"
version = "0.1.0"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "egui"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8470210c95a42cc985d9ffebfd5067eea55bdb1c3f7611484907db9639675e28"
dependencies = [
"ahash",
"bitflags",
"emath",
"epaint",
"nohash-hasher",
"profiling",
"smallvec",
"unicode-segmentation",
]
[[package]]
name = "emath"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45f057b141e7e46340c321400be74b793543b1b213036f0f989c35d35957c32e"
[[package]]
name = "epaint"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94cca02195f0552c17cabdc02f39aa9ab6fbd815dac60ab1cd3d5b0aa6f9551c"
dependencies = [
"ab_glyph",
"ahash",
"ecolor",
"emath",
"epaint_default_fonts",
"nohash-hasher",
"parking_lot",
"profiling",
]
[[package]]
name = "epaint_default_fonts"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8495e11ed527dff39663b8c36b6c2b2799d7e4287fb90556e455d72eca0b4d3"
[[package]]
name = "libc"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "lock_api"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "owned_ttf_parser"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4"
dependencies = [
"ttf-parser",
]
[[package]]
name = "parking_lot"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "profiling"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
dependencies = [
"bitflags",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "editor"
version = "0.1.0"
edition = "2024"
description = "a basic text editor widget with line numbers"
[dependencies]
egui = "0.32.0"
serde = "1"
[lib]
name = "editor"
path = "src/lib.rs"
-214
View File
@@ -1,214 +0,0 @@
use egui::TextBuffer;
use egui::widgets::text_edit::TextEditOutput;
use egui::{Color32, text::LayoutJob};
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug, PartialEq)]
/// CodeEditor struct which stores settings for highlighting.
pub struct CodeEditor {
id: String,
numlines: bool,
numlines_shift: isize,
numlines_only_natural: bool,
fontsize: f32,
stick_to_bottom: bool,
desired_width: f32,
}
impl Hash for CodeEditor {
fn hash<H: Hasher>(&self, state: &mut H) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
(self.fontsize as u32).hash(state);
}
}
impl Default for CodeEditor {
fn default() -> CodeEditor {
CodeEditor {
id: String::from("Code Editor"),
numlines: true,
numlines_shift: 0,
numlines_only_natural: false,
fontsize: 10.0,
stick_to_bottom: false,
desired_width: f32::INFINITY,
}
}
}
impl CodeEditor {
pub fn id_source(self, id_source: impl Into<String>) -> Self {
CodeEditor {
id: id_source.into(),
..self
}
}
/// Use custom font size
///
/// **Default: 10.0**
pub fn with_fontsize(self, fontsize: f32) -> Self {
CodeEditor { fontsize, ..self }
}
/// Use UI font size
pub fn with_ui_fontsize(self, ui: &mut egui::Ui) -> Self {
CodeEditor {
fontsize: egui::TextStyle::Monospace.resolve(ui.style()).size,
..self
}
}
/// Show or hide lines numbering
///
/// **Default: true**
pub fn with_numlines(self, numlines: bool) -> Self {
CodeEditor { numlines, ..self }
}
/// Shift lines numbering by this value
///
/// **Default: 0**
pub fn with_numlines_shift(self, numlines_shift: isize) -> Self {
CodeEditor {
numlines_shift,
..self
}
}
/// Show lines numbering only above zero, useful for enabling numbering since nth row
///
/// **Default: false**
pub fn with_numlines_only_natural(self, numlines_only_natural: bool) -> Self {
CodeEditor {
numlines_only_natural,
..self
}
}
/// Should the containing area shrink if the content is small?
///
/// **Default: false**
pub fn auto_shrink(self, shrink: bool) -> Self {
CodeEditor {
desired_width: if shrink { 0.0 } else { self.desired_width },
..self
}
}
/// Sets the desired width of the code editor
///
/// **Default: `f32::INFINITY`**
pub fn desired_width(self, width: f32) -> Self {
CodeEditor {
desired_width: width,
..self
}
}
/// Stick to bottom
/// The scroll handle will stick to the bottom position even while the content size
/// changes dynamically. This can be useful to simulate terminal UIs or log/info scrollers.
/// The scroll handle remains stuck until user manually changes position. Once "unstuck"
/// it will remain focused on whatever content viewport the user left it on. If the scroll
/// handle is dragged to the bottom it will again become stuck and remain there until manually
/// pulled from the end position.
///
/// **Default: false**
pub fn stick_to_bottom(self, stick_to_bottom: bool) -> Self {
CodeEditor {
stick_to_bottom,
..self
}
}
pub fn format(&self, string: &str) -> egui::text::TextFormat {
let font_id = egui::FontId::monospace(self.fontsize);
let color = Color32::WHITE;
egui::text::TextFormat::simple(font_id, color)
}
fn numlines_show(&self, ui: &mut egui::Ui, text: &str) {
let total = if text.ends_with('\n') || text.is_empty() {
text.lines().count() + 1
} else {
text.lines().count()
} as isize;
let max_indent = total
.to_string()
.len()
.max(!self.numlines_only_natural as usize * self.numlines_shift.to_string().len());
let mut counter = (1..=total)
.map(|i| {
let num = i + self.numlines_shift;
if num <= 0 && self.numlines_only_natural {
String::new()
} else {
let label = num.to_string();
format!(
"{}{label}",
" ".repeat(max_indent.saturating_sub(label.len()))
)
}
})
.collect::<Vec<String>>()
.join("\n");
#[allow(clippy::cast_precision_loss)]
let width = max_indent as f32
* self.fontsize
* 0.5
* !(total + self.numlines_shift <= 0 && self.numlines_only_natural) as u8 as f32;
let mut layouter = |ui: &egui::Ui, string: &dyn TextBuffer, _wrap_width: f32| {
let layout_job = egui::text::LayoutJob::single_section(
string.as_str().to_string(), // Convert TextBuffer to String
egui::TextFormat::simple(egui::FontId::monospace(self.fontsize), Color32::WHITE),
);
ui.fonts(|f| f.layout_job(layout_job))
};
ui.add(
egui::TextEdit::multiline(&mut counter)
.id_source(format!("{}_numlines", self.id))
.font(egui::TextStyle::Monospace)
.interactive(false)
.frame(false)
.desired_width(width)
.layouter(&mut layouter),
);
}
/// Show Code Editor
pub fn show(&mut self, ui: &mut egui::Ui, text: &mut dyn egui::TextBuffer) -> TextEditOutput {
let mut text_edit_output: Option<TextEditOutput> = None;
let code_editor = |ui: &mut egui::Ui| {
ui.horizontal_top(|h| {
if self.numlines {
self.numlines_show(h, text.as_str());
}
egui::ScrollArea::horizontal()
.hscroll(true)
.id_salt(format!("{}_inner_scroll", self.id))
.show(h, |ui| {
let output = egui::TextEdit::multiline(text)
.id_source(&self.id)
.lock_focus(true)
.frame(false)
.desired_width(self.desired_width)
.show(ui);
text_edit_output = Some(output);
});
});
};
egui::ScrollArea::vertical()
.id_salt(format!("{}_outer_scroll", self.id))
.stick_to_bottom(self.stick_to_bottom)
.show(ui, code_editor);
text_edit_output.expect("TextEditOutput should exist at this point")
}
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

-6
View File
@@ -1,6 +0,0 @@
{
"date": "2025-07-17",
"project_name": "New Project",
"project_author": "Your Name",
"project_description": "Description of your project"
}
@@ -1,8 +0,0 @@
{
"title": "test",
"id": "83592caa-f97d-427e-9d6a-50a586c30e6e",
"description": "ee",
"tags": [],
"content": "# Test project\n\n- this project is a test to ensure that this tool can be integrated with AI models correctly\n- Im testing various prompts and parameters to evaluate its capabilities. The initial focus is on simple tasks like list generation, text summarization, and question answering. More complex scenarios involving code generation and creative writing will follow in subsequent phases. A key aspect of this test project involves documenting all interactions both the prompts used and the AIs responses for later analysis. This allows us to identify patterns, biases, and areas where the tool can be improved. ",
"parent": null
}
@@ -1,23 +0,0 @@
{
"id": "161227ef-ba29-41a7-b40a-ed4ac550a8ea",
"template_id": "69cf7e1d-96a1-4d2a-9f08-bd3386d4bc69",
"name": "nucleus",
"fields": {
"age": {
"Number": 0.0
},
"parent": {
"Link": ""
},
"dob": {
"Date": "1970-01-01"
},
"pfp": {
"Image": "characters/nucleus.png"
},
"description": {
"MultiLine": "an AI"
}
},
"tags": []
}
@@ -1,23 +0,0 @@
{
"id": "3ce0e977-9f65-4f4c-a036-67f3d5c25fdc",
"template_id": "69cf7e1d-96a1-4d2a-9f08-bd3386d4bc69",
"name": "ZXQ5",
"fields": {
"dob": {
"Date": "1970-01-01"
},
"description": {
"MultiLine": "yes"
},
"age": {
"Number": 19.1
},
"parent": {
"Link": ""
},
"pfp": {
"Image": "characters/zxq5.png"
}
},
"tags": []
}
@@ -1,23 +0,0 @@
{
"id": "57429207-5fc1-4bab-a524-c550773c3d45",
"template_id": "69cf7e1d-96a1-4d2a-9f08-bd3386d4bc69",
"name": "Tayles",
"fields": {
"pfp": {
"Image": "characters/tayles.png"
},
"parent": {
"Link": ""
},
"description": {
"MultiLine": "trainspotter"
},
"age": {
"Number": 17.5
},
"dob": {
"Date": "1970-01-01"
}
},
"tags": []
}
@@ -1,25 +0,0 @@
{
"id": "be24e58f-3f79-4c5a-9224-9037eea5f51f",
"template_id": "69cf7e1d-96a1-4d2a-9f08-bd3386d4bc69",
"name": "The Order",
"fields": {
"pfp": {
"Image": "characters/the order.png"
},
"description": {
"MultiLine": "yes"
},
"dob": {
"Date": "1970-01-29"
},
"parent": {
"Link": ""
},
"age": {
"Number": 20.6
}
},
"tags": [
"bbeddabd-914c-4648-8262-bf14bfcf8fff"
]
}
@@ -1,25 +0,0 @@
{
"id": "deeaee92-bdec-4eb3-bb3b-ee760fc83d45",
"template_id": "69cf7e1d-96a1-4d2a-9f08-bd3386d4bc69",
"name": "The Chancellor",
"fields": {
"age": {
"Number": 37.0
},
"parent": {
"Link": ""
},
"dob": {
"Date": "1970-01-01"
},
"description": {
"MultiLine": "a tall ahh american"
},
"pfp": {
"Image": "characters/the chancellor.jpg"
}
},
"tags": [
"bbeddabd-914c-4648-8262-bf14bfcf8fff"
]
}
@@ -1,11 +0,0 @@
{
"id": "bbeddabd-914c-4648-8262-bf14bfcf8fff",
"name": "American",
"description": "an american smh",
"color": [
0,
32,
207,
255
]
}
@@ -1,32 +0,0 @@
{
"name": "Species",
"id": "353649f9-e1f3-46d9-b723-8e56b510b2cc",
"description": "A classification system for living or digital entities.",
"fields": [
{
"name": "Diverged from",
"field_type": {
"Link": {
"template_id": null
}
},
"required": false,
"on_preview": false,
"description": "did this diverge from another documented species?"
},
{
"name": "Appearance / Features",
"field_type": "MultiLine",
"required": true,
"on_preview": true,
"description": "anatomy etc."
},
{
"name": "behaviour",
"field_type": "MultiLine",
"required": true,
"on_preview": true,
"description": "aggressive, collaborative, etc.."
}
]
}
@@ -1,46 +0,0 @@
{
"name": "Character",
"id": "69cf7e1d-96a1-4d2a-9f08-bd3386d4bc69",
"description": "a character",
"fields": [
{
"name": "description",
"field_type": "MultiLine",
"required": true,
"on_preview": true,
"description": "yes"
},
{
"name": "age",
"field_type": "Number",
"required": true,
"on_preview": false,
"description": "yes"
},
{
"name": "dob",
"field_type": "Date",
"required": true,
"on_preview": false,
"description": "yes"
},
{
"name": "parent",
"field_type": {
"Link": {
"template_id": null
}
},
"required": true,
"on_preview": false,
"description": "yes"
},
{
"name": "pfp",
"field_type": "Image",
"required": true,
"on_preview": true,
"description": "yes"
}
]
}
+6 -6
View File
@@ -24,9 +24,9 @@
### v0.1.1 - Editor
- [ ] Basic editor (markdown formatting)
- [x] Basic editor (markdown formatting)
- [x] Basic text editing
- [ ] Load & Save text file
- [x] Load & Save text file
- [x] Editor preview
- [x] Preview text in markdown
@@ -34,13 +34,13 @@
### v0.2.0 - links & context building
- [ ] Links between objects
- [x] Links between objects
- [ ] Links in templates
### v0.2.1 - writing projects & organisation
- [ ] Project management
- [ ] Chapters/organisation
- [x] Project management
- [x] Chapters/organisation
## v0.3 - Workflows & AI integration
@@ -70,7 +70,7 @@
- [ ] Content generation AI
- [ ] Collect context from across a story or project
- [ ] Generate content for stories
- [x] Generate content for stories (Done to a very basic level)
- [ ] Create original content in the form of objects using templates
- [ ] Create a new template
+2 -2
View File
@@ -1,6 +1,6 @@
use egui::{TextEdit, vec2};
use crate::{PROJECT_FOLDER, util};
use crate::{PROJECT_FOLDER, filesystem::Id, util};
#[derive(Debug, Clone)]
pub struct Asset {
@@ -48,7 +48,7 @@ impl Asset {
pub fn ui(&mut self, ui: &mut egui::Ui) {
ui.vertical(|ui| {
util::saved_status(ui, self.saved, &self.name, &self.new_name);
util::saved_status(ui, self.saved, &Id::new(), &self.new_name);
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl)
|| ui.button("Save").clicked()
+215 -175
View File
@@ -1,24 +1,43 @@
use egui::{TextEdit, text};
use egui::TextEdit;
use egui_commonmark::{CommonMarkCache, CommonMarkViewer};
use serde::{self, Deserialize, Serialize};
use crate::{PROJECT_FOLDER, editors::tags::Tag, llm_integration::content_llm::ai_enabled, util};
use crate::{
FILESYSTEM, PROJECT_FOLDER,
editors::{settings_editor::ProjectSettings, tags::Tag},
filesystem::{FileSystem, Id},
util,
};
#[cfg(feature = "llm")]
use crate::llm_integration::content_llm::{ContentAI, ReadyState};
pub struct MainEditor {
pub content: ContentSection,
pub show_editor: bool,
pub editor_separate_window: bool,
pub show_preview: bool,
preview_cache: CommonMarkCache,
#[cfg(feature = "llm")]
dialog: ContentAI,
#[cfg(feature = "llm")]
pub show_ai: bool,
}
impl Clone for MainEditor {
fn clone(&self) -> Self {
Self {
content: self.content.clone(),
show_editor: self.show_editor,
editor_separate_window: self.editor_separate_window,
show_preview: self.show_preview,
preview_cache: CommonMarkCache::default(),
#[cfg(feature = "llm")]
dialog: self.dialog.clone(),
#[cfg(feature = "llm")]
show_ai: self.show_ai,
}
}
}
@@ -28,21 +47,20 @@ pub struct ContentSection {
#[serde(default)]
pub title: String,
#[serde(default)]
pub id: String,
pub id: Id,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
pub tags: Vec<Id>,
#[serde(default)]
pub content: String,
// parent id
#[serde(default)]
pub parent: Option<String>,
pub parent: Option<Id>,
#[serde(skip)]
pub saved: bool,
@@ -52,7 +70,7 @@ impl ContentSection {
pub fn new() -> Self {
Self {
title: String::new(),
id: uuid::Uuid::new_v4().to_string(),
id: Id::new(),
description: String::new(),
tags: Vec::new(),
content: String::new(),
@@ -61,24 +79,29 @@ impl ContentSection {
}
}
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER
.join("documents")
.join(format!("{}.json", &self.id));
pub fn save<F: FileSystem>(
&mut self,
filesystem: &F,
) -> Result<(), Box<dyn std::error::Error>> {
let documents_dir = PROJECT_FOLDER.join("documents");
if filesystem.exists(&self.id) {
filesystem.write(&self.id, self.clone())?;
} else {
let _new_id = filesystem.create(&documents_dir, self.clone())?;
// Note: The filesystem creates its own ID, but we keep our existing ID for consistency
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)?;
self.saved = true;
Ok(())
}
pub fn load(id: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER.join("documents").join(format!("{id}.json"));
let content = std::fs::read_to_string(&path)?;
let mut section: Self = serde_json::from_str(&content)?;
pub fn load<F: FileSystem>(
filesystem: &F,
id: &Id,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut section: Self = filesystem.read(id)?;
section.saved = true;
section.id = id.to_string();
section.id = id.clone();
Ok(section)
}
@@ -96,7 +119,13 @@ impl MainEditor {
content: ContentSection::new(),
show_editor: false, // Start with editor hidden
show_preview: false,
editor_separate_window: false,
preview_cache: CommonMarkCache::default(),
#[cfg(feature = "llm")]
show_ai: false,
#[cfg(feature = "llm")]
dialog: ContentAI::new(String::new()),
}
}
@@ -105,125 +134,169 @@ impl MainEditor {
content,
show_editor: true,
show_preview: false,
editor_separate_window: false,
preview_cache: CommonMarkCache::default(),
#[cfg(feature = "llm")]
show_ai: false,
#[cfg(feature = "llm")]
dialog: ContentAI::new(String::new()),
}
}
pub fn ui(&mut self, ctx: &egui::Context) {
pub fn render_ui<F: FileSystem>(
&mut self,
project: &mut ProjectSettings,
filesystem: &F,
ui: &mut egui::Ui,
) {
ui.vertical(|ui| {
// check for Ctrl+S to save
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) {
if let Err(e) = self.content.save(filesystem) {
eprintln!("Failed to save: {e}");
}
}
// display save state
util::saved_status(
ui,
self.content.saved,
&self.content.id,
&self.content.title,
);
// Save/Cancel buttons
ui.horizontal(|ui| {
// save button
if ui.button("Save").clicked() {
if let Err(e) = self.content.save(filesystem) {
eprintln!("Failed to save: {e}");
}
}
// create copy button
if ui.button("Create Copy").clicked() {
let mut copy = self.clone();
copy.content.id = Id::new();
copy.content.title = format!("{} (Copy)", self.content.title);
FILESYSTEM.clone(&self.content.id, &copy.content.id);
// TODO: Fix save call to pass filesystem
// copy.content.save().unwrap();
}
// delete button
if ui.button("Delete").clicked() {
filesystem.delete(&self.content.id).unwrap();
*self = Self::new();
}
// revert changes button
if ui.button("Revert changes").clicked() {
self.content = ContentSection::load(filesystem, &self.content.id).unwrap();
}
// preview toggle
ui.checkbox(&mut self.show_preview, "Preview");
// assistant toggle
#[cfg(feature = "llm")]
ui.checkbox(&mut self.show_ai, "AI Assistant");
// editor toggle
ui.checkbox(&mut self.editor_separate_window, "Pop out editor");
});
});
ui.separator();
// Name and description grid
egui::Grid::new("top_grid")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.strong("Name");
if ui
.add(
TextEdit::singleline(&mut self.content.title)
.desired_width(f32::INFINITY)
.frame(false),
)
.changed()
{
self.content.saved = false;
}
ui.end_row();
ui.strong("Description");
if ui
.add(
TextEdit::singleline(&mut self.content.description)
.desired_width(f32::INFINITY)
.frame(false),
)
.changed()
{
self.content.saved = false;
}
ui.end_row();
ui.strong("Tags");
Tag::selector_ui(&mut self.content.tags, ui, Some(&mut self.content.saved));
ui.end_row();
});
ui.separator();
#[cfg(feature = "llm")]
if self.show_ai {
let dialog = &mut self.dialog;
dialog.content = self.content.content.clone();
dialog.ui(ui, project);
if *dialog.ready.lock().unwrap() == ReadyState::Ready {
self.content
.content
.push_str(&dialog.result.lock().unwrap());
self.content.saved = false;
*dialog.ready.lock().unwrap() = ReadyState::Idle;
} else if *dialog.ready.lock().unwrap() == ReadyState::Halted {
*dialog.ready.lock().unwrap() = ReadyState::Idle;
}
}
if self.show_preview {
self.preview_ui(ui);
}
self.editor_ui(ui, project);
}
pub fn ui<F: FileSystem>(
&mut self,
ctx: &egui::Context,
project: &mut ProjectSettings,
filesystem: &F,
) {
// Show the editor window if enabled
let mut show = self.show_editor;
if show {
egui::Window::new("Markdown Editor")
.resizable(true)
.default_width(1000.0)
.default_height(800.0)
.open(&mut show)
.show(ctx, |ui| {
ui.vertical(|ui| {
// check for Ctrl+S to save
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) {
if let Err(e) = self.content.save() {
eprintln!("Failed to save: {e}");
}
}
// display save state
util::saved_status(
ui,
self.content.saved,
&self.content.id,
&self.content.title,
);
// Save/Cancel buttons
ui.horizontal(|ui| {
// save button
if ui.button("Save").clicked() {
if let Err(e) = self.content.save() {
eprintln!("Failed to save: {e}");
}
}
// create copy button
if ui.button("Create Copy").clicked() {
let mut copy = self.clone();
copy.content.id = uuid::Uuid::new_v4().to_string();
copy.content.title = format!("{} (Copy)", self.content.title);
copy.content.save().unwrap();
}
// delete button
if ui.button("Delete").clicked() {
std::fs::remove_file(
PROJECT_FOLDER
.join("documents")
.join(format!("{}.json", self.content.id)),
)
.unwrap();
*self = Self::new();
}
// revert changes button
if ui.button("Revert changes").clicked() {
self.content = ContentSection::load(&self.content.id).unwrap();
}
// preview toggle
ui.checkbox(&mut self.show_preview, "Preview");
});
if self.editor_separate_window {
egui::Window::new("Editor")
.resizable(true)
.default_width(1000.0)
.default_height(800.0)
.open(&mut show)
.show(ctx, |ui| {
self.render_ui(project, filesystem, ui);
});
ui.separator();
// Name and description grid
egui::Grid::new("top_grid")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.strong("Name");
if ui
.add(
TextEdit::singleline(&mut self.content.title)
.desired_width(f32::INFINITY)
.frame(false),
)
.changed()
{
self.content.saved = false;
}
ui.end_row();
ui.strong("Description");
if ui
.add(
TextEdit::singleline(&mut self.content.description)
.desired_width(f32::INFINITY)
.frame(false),
)
.changed()
{
self.content.saved = false;
}
ui.end_row();
ui.strong("Tags");
Tag::selector_ui(
&mut self.content.tags,
ui,
Some(&mut self.content.saved),
);
ui.end_row();
});
ui.separator();
if self.show_preview {
self.preview_ui(ui);
}
self.editor_ui(ui);
} else {
egui::CentralPanel::default().show(ctx, |ui| {
self.render_ui(project, filesystem, ui);
});
}
}
self.show_editor = show;
@@ -263,8 +336,8 @@ impl MainEditor {
});
}
fn editor_ui(&mut self, ui: &mut egui::Ui) {
let response = egui::ScrollArea::both()
fn editor_ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) {
let _response = egui::ScrollArea::both()
.auto_shrink([false, false])
.id_salt("editor_scroll")
.show(ui, |ui| {
@@ -281,49 +354,16 @@ impl MainEditor {
ui.set_min_width(max_width as f32);
let text_edit = TextEdit::multiline(&mut self.content.content)
.id_source("MainEditor_editor")
.font(egui::TextStyle::Monospace)
.interactive(true)
.frame(false)
.lock_focus(true)
.hint_text("Type here...")
.desired_width(max_width as f32);
let mut ctx_menu = false;
let response = ui
.add_sized(
egui::vec2(max_width as f32 - 30.0, ui.available_height()),
text_edit,
)
.on_hover_text("Right click to open context menu")
.context_menu(|ui| {
ctx_menu = true;
ui.menu_button("AI Actions", |ui| {
ui.add_enabled_ui(ai_enabled(), |ui| {
if ui.button("Summarise").clicked() {
println!("Summarise");
}
if ui.button("Continue").clicked() {
let content = self.content.content.clone();
let response =
crate::llm_integration::content_llm::continue_content(
&content, "", 1024,
)
.unwrap();
self.content.content.push_str(&response);
}
});
});
});
if let Some(response) = response {
if response.response.changed() || ctx_menu {
self.content.saved = false;
}
}
ui.add(
TextEdit::multiline(&mut self.content.content)
.id_source("MainEditor_editor")
.font(egui::TextStyle::Monospace)
.interactive(true)
.frame(false)
.lock_focus(true)
.hint_text("Type here...")
.desired_width(max_width as f32),
);
});
});
});
-78
View File
@@ -1,78 +0,0 @@
use std::io::Read;
use chrono::NaiveDate;
use egui_extras::DatePickerButton;
use serde::{Deserialize, Serialize};
use crate::PROJECT_FOLDER;
#[derive(Serialize, Deserialize)]
pub struct ProjectContext {
date: NaiveDate,
project_name: String,
project_author: String,
project_description: String,
}
impl ProjectContext {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Self {
let path = PROJECT_FOLDER.join("context.json");
if let Ok(mut file) = std::fs::File::open(path) {
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
if let Ok(proj) = serde_json::from_str(&contents) {
return proj;
}
}
Self::default()
}
pub fn save(&self) {
let path = PROJECT_FOLDER.join("context.json");
let content = serde_json::to_string_pretty(self).unwrap();
std::fs::write(path, content).unwrap();
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
// table
egui::Grid::new("context_editor")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.label("Project Name");
ui.text_edit_singleline(&mut self.project_name);
ui.end_row();
ui.label("Project Author");
ui.text_edit_singleline(&mut self.project_author);
ui.end_row();
ui.label("Project Description");
ui.text_edit_singleline(&mut self.project_description);
ui.end_row();
ui.label("Date");
ui.add(DatePickerButton::new(&mut self.date));
ui.end_row();
});
}
}
impl Default for ProjectContext {
fn default() -> Self {
Self {
date: chrono::Local::now().naive_local().into(),
project_name: "New Project".to_string(),
project_author: "Your Name".to_string(),
project_description: "Description of your project".to_string(),
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod asset_editor;
pub mod content_editor;
pub mod context_editor;
pub mod note_editor;
pub mod object_editor;
pub mod settings_editor;
pub mod tags;
pub mod template_editor;
+17 -14
View File
@@ -1,9 +1,14 @@
use std::fs;
use egui::TextEdit;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use crate::{PROJECT_FOLDER, editors::tags::Tag, util};
use crate::{
FILESYSTEM, PROJECT_FOLDER,
editors::tags::Tag,
filesystem::{FileSystem, Id},
util,
};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Note {
@@ -14,10 +19,9 @@ pub struct Note {
pub subject: String,
#[serde(default)]
pub tags: Vec<String>,
pub tags: Vec<Id>,
#[serde(skip)]
pub id: String,
pub id: Id,
#[serde(skip)]
pub saved: bool,
@@ -26,7 +30,7 @@ pub struct Note {
impl Default for Note {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
id: Id::new(),
name: "New Note".to_string(),
subject: "".to_string(),
content: "".to_string(),
@@ -39,7 +43,7 @@ impl Default for Note {
impl Note {
pub fn new() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
id: Id::new(),
name: "New Note".to_string(),
subject: "".to_string(),
content: "".to_string(),
@@ -50,17 +54,16 @@ impl Note {
pub fn save(&mut self) -> std::io::Result<()> {
let id = &self.id;
let path = PROJECT_FOLDER.join("notes").join(format!("{id}.json"));
fs::write(path, serde_json::to_string(&self)?)?;
let data = serde_json::to_string(&self)?;
FILESYSTEM.write(id, data).unwrap();
self.saved = true;
Ok(())
}
pub fn load(id: &str) -> std::io::Result<Self> {
let path = PROJECT_FOLDER.join("notes").join(format!("{id}.json"));
let content = fs::read_to_string(path)?;
let mut note: Note = serde_json::from_str(&content)?;
note.id = id.to_string();
pub fn load(id: &Id) -> std::io::Result<Self> {
let mut note: Note = FILESYSTEM.read(id).unwrap();
note.id = id.clone();
note.saved = true;
Ok(note)
}
+26 -43
View File
@@ -1,30 +1,30 @@
use core::f32;
use egui::{CollapsingHeader, RichText, Sense, TextEdit, Ui, UiBuilder, vec2};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::{
PROJECT_FOLDER, RightPanelContent,
FILESYSTEM, PROJECT_FOLDER, RightPanelContent,
editors::{
tags::Tag,
template_editor::{FieldValue, Template},
},
filesystem::{FileSystem, Id},
util,
};
pub type ObjectId = String;
#[derive(Debug, Serialize, Deserialize)]
pub struct ObjectInstance {
// template info
pub id: ObjectId,
pub template_id: String,
pub id: Id,
pub template_id: Id,
// instance info
pub name: String,
pub fields: std::collections::HashMap<String, FieldValue>,
#[serde(default)]
pub tags: Vec<String>,
pub tags: Vec<Id>,
#[serde(skip)]
pub saved: bool,
@@ -50,8 +50,8 @@ impl Clone for ObjectInstance {
impl Default for ObjectInstance {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
template_id: "new_template_instance".to_string(),
id: Id::new(),
template_id: Id::new(),
name: "new_object".to_string(),
fields: std::collections::HashMap::new(),
tags: Vec::new(),
@@ -69,33 +69,25 @@ impl ObjectInstance {
fields.insert(field.name.clone(), FieldValue::from_type(&field.field_type));
}
Self {
id: uuid::Uuid::new_v4().to_string(),
template_id: template.id.clone(),
name: "new_object".to_string(),
let instance = Self {
fields,
tags: Vec::new(),
saved: false,
dialog: None,
}
template_id: template.id.clone(),
..Default::default()
};
let _ = FILESYSTEM.create(Path::new("./objects"), instance.clone());
instance
}
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER
.join("objects")
.join(format!("{}.json", &self.id));
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
FILESYSTEM.write(&self.id, self.clone())?;
self.saved = true;
Ok(())
}
pub fn load(id: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER.join("objects").join(format!("{id}.json"));
let content = std::fs::read_to_string(&path)?;
let mut instance: ObjectInstance = serde_json::from_str(&content)?;
pub fn load(id: &Id) -> Result<Self, Box<dyn std::error::Error>> {
let mut instance: ObjectInstance = FILESYSTEM.read(id)?;
instance.saved = true;
Ok(instance)
}
@@ -127,23 +119,14 @@ impl ObjectInstance {
}
if ui.button("Create Copy").clicked() {
let mut copy = self.clone();
copy.id = uuid::Uuid::new_v4().to_string();
copy.dialog = None;
copy.name = format!("{} (Copy)", self.name);
copy.save().unwrap();
let new_id = Id::new();
FILESYSTEM.clone(&self.id, &new_id).unwrap();
let copy = Self::load(&new_id).unwrap();
*right_panel = Some(RightPanelContent::Object(Box::new(copy)));
}
if ui.button("Delete").clicked() {
std::fs::remove_file(
PROJECT_FOLDER
.join("objects")
.join(format!("{}.json", self.id)),
)
.unwrap();
FILESYSTEM.delete(&self.id).unwrap();
*right_panel = Some(RightPanelContent::None);
}
});
@@ -299,12 +282,12 @@ impl ObjectInstance {
}
fn selector_ui(
selected: &mut ObjectId,
selected: &mut Id,
objects: &mut [ObjectInstance],
ui: &mut egui::Ui,
saved: &mut bool,
) {
if !selected.is_empty() {
if !selected.to_string().is_empty() {
if let Ok(object) = ObjectInstance::load(selected) {
ui.strong(&object.name);
}
@@ -340,7 +323,7 @@ impl ObjectInstance {
}
if ui.button("Remove").clicked() {
*selected = String::new();
*selected = Id::default();
*saved = false;
}
});
+327
View File
@@ -0,0 +1,327 @@
use chrono::NaiveDate;
use egui::TextEdit;
use egui_extras::DatePickerButton;
use serde::{Deserialize, Serialize};
use std::{io::Read, path::PathBuf, sync::LazyLock};
use crate::{PROJECT_FOLDER, filesystem::Id, util::saved_status};
#[derive(Serialize, Deserialize, Clone)]
pub struct ProjectSettings {
date: NaiveDate,
project_name: String,
project_author: String,
project_description: String,
// AI settings
pub ai_context: String,
// settings
#[serde(skip)]
pub global_settings: EditorSettings,
#[serde(skip)]
pub local_overrides: EditorSettings,
#[serde(skip)]
pub open: bool,
#[serde(skip)]
pub saved: bool,
}
static GLOBAL_SETTINGS_PATH: LazyLock<String> =
LazyLock::new(|| match std::env::var("XDG_CONFIG_HOME") {
Ok(path) => path + "/worldcoder/settings.json",
Err(_) => {
eprintln!(
"XDG_CONFIG_HOME not set, using default path of ~/.config/worldcoder/settings.json"
);
"~/.config/worldcoder/settings.json".to_string()
}
});
impl ProjectSettings {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Self {
let project_path = PROJECT_FOLDER.join("project.json");
let mut file = if let Ok(file) = std::fs::File::open(project_path) {
file
} else {
return Self::default();
};
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
if let Ok(mut proj) = serde_json::from_str::<Self>(&contents) {
proj.saved = true;
// load global settings
proj.global_settings = EditorSettings::load_global();
// load local overrides
proj.local_overrides = EditorSettings::load();
proj
} else {
Self::default()
}
}
pub fn save(&mut self) {
let project_path = PROJECT_FOLDER.join("project.json");
let content = serde_json::to_string_pretty(self).unwrap();
std::fs::write(project_path, content).unwrap();
self.global_settings.save();
self.local_overrides.save();
self.saved = true;
}
#[allow(dead_code)]
pub fn ui(&mut self, ui: &mut egui::Ui) {
// save state
saved_status(ui, self.saved, &Id::default(), "Project Settings");
if ui.button("Save").clicked() {
self.save();
}
ui.separator();
// project settings
ui.heading("Project Settings");
egui::Grid::new("project settings")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.label("Project Name");
ui.text_edit_singleline(&mut self.project_name);
ui.end_row();
ui.label("Project Author");
ui.text_edit_singleline(&mut self.project_author);
ui.end_row();
ui.label("Project Description");
ui.text_edit_singleline(&mut self.project_description);
ui.end_row();
ui.label("Date");
ui.add(DatePickerButton::new(&mut self.date));
ui.end_row();
ui.label("AI Context Prompt");
ui.add(TextEdit::multiline(&mut self.ai_context)
.font(egui::TextStyle::Monospace)
.interactive(true)
.frame(false)
.lock_focus(true)
.hint_text("What is this project about? what should the LLM know when generating content for this project?"));
ui.end_row();
});
ui.separator();
// local settings overrides for editor
ui.heading("Local Overrides");
egui::Grid::new("local overrides")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.label("Enable AI");
if let Some(ai_enabled) = &mut self.local_overrides.ai_enabled {
ui.checkbox(ai_enabled, "Enable AI");
if ui.button("Remove Override").clicked() {
self.local_overrides.ai_enabled = None;
}
} else if ui.button("Override").clicked() {
self.local_overrides.ai_enabled = Some(true);
}
ui.end_row();
ui.label("LLM API URI");
if let Some(llm_api_uri) = &mut self.local_overrides.llm_api_uri {
ui.text_edit_singleline(llm_api_uri);
if ui.button("Remove Override").clicked() {
self.local_overrides.llm_api_uri = None;
}
} else if ui.button("Override").clicked() {
self.local_overrides.llm_api_uri = Some("http://localhost:1234".to_string());
}
ui.end_row();
ui.label("LLM API Key");
if let Some(llm_api_key) = &mut self.local_overrides.llm_api_key {
ui.text_edit_singleline(llm_api_key);
if ui.button("Remove Override").clicked() {
self.local_overrides.llm_api_key = None;
}
} else if ui.button("Override").clicked() {
self.local_overrides.llm_api_key = Some("1234".to_string());
}
ui.end_row();
});
ui.separator();
// global editor settings
ui.heading("Global Editor Settings");
egui::Grid::new("global settings")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.label("Enable AI");
ui.checkbox(&mut self.global_settings.ai_enabled.unwrap(), "Enable AI");
ui.end_row();
ui.label("LLM API URI");
ui.text_edit_singleline(self.global_settings.llm_api_uri.as_mut().unwrap());
ui.end_row();
ui.label("LLM API Key");
ui.text_edit_singleline(self.global_settings.llm_api_key.as_mut().unwrap());
ui.end_row();
});
}
pub fn ai_enabled(&mut self) -> bool {
let client = reqwest::blocking::Client::new();
if self.global_settings.ai_enabled.unwrap() {
return true;
}
if client
.get(self.global_settings.llm_api_uri.clone().unwrap() + "/v1/models")
.send()
.is_ok()
{
self.global_settings.ai_enabled = Some(true);
return true;
}
false
}
pub fn open(&mut self) {
self.open = true;
}
pub fn _close(&mut self) {
self.open = false;
}
}
impl Default for ProjectSettings {
fn default() -> Self {
Self {
date: chrono::Local::now().naive_local().into(),
project_name: "New Project".to_string(),
project_author: "Your Name".to_string(),
project_description: "Description of your project".to_string(),
ai_context: "".to_string(),
global_settings: EditorSettings::new(),
local_overrides: EditorSettings::new(),
// window state
open: false,
saved: false,
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct EditorSettings {
pub llm_api_uri: Option<String>,
pub llm_api_key: Option<String>,
pub ai_enabled: Option<bool>,
pub dark_theme: Option<bool>,
#[serde(skip)]
is_global: bool,
}
impl Default for EditorSettings {
fn default() -> Self {
Self {
llm_api_uri: Some("http://localhost:1234".to_string()),
llm_api_key: Some("".to_string()),
ai_enabled: Some(true),
dark_theme: Some(true),
// window state
is_global: true,
}
}
}
impl EditorSettings {
pub fn new() -> Self {
Self {
llm_api_uri: None,
llm_api_key: None,
ai_enabled: None,
dark_theme: None,
is_global: false,
}
}
pub fn load() -> Self {
let path = PROJECT_FOLDER.join("settings.json");
let mut file = if let Ok(file) = std::fs::File::open(path) {
file
} else {
return Self::default();
};
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
serde_json::from_str(&contents).unwrap()
}
pub fn save(&self) {
let content = serde_json::to_string_pretty(self).unwrap();
let path = if self.is_global {
PathBuf::from(GLOBAL_SETTINGS_PATH.clone())
} else {
PROJECT_FOLDER.join("settings.json")
};
std::fs::write(path, content).unwrap();
}
pub fn load_global() -> Self {
let path = PathBuf::from(GLOBAL_SETTINGS_PATH.clone());
if !path.exists() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let content = serde_json::to_string_pretty(&Self::default()).unwrap();
std::fs::write(&path, content).unwrap();
// return a default config
return Self::default();
}
let content = std::fs::read_to_string(path).unwrap();
serde_json::from_str(&content).unwrap()
}
}
+14 -8
View File
@@ -1,11 +1,15 @@
use egui::{Response, RichText, TextEdit, UiBuilder};
use egui::{Response, RichText, TextEdit};
use serde::{Deserialize, Serialize};
use crate::{PROJECT_FOLDER, util};
use crate::{
FILESYSTEM, PROJECT_FOLDER,
filesystem::{FileSystem, Id},
util,
};
#[derive(Serialize, Deserialize)]
pub struct Tag {
pub id: String,
pub id: Id,
pub name: String,
pub description: String,
pub color: egui::Color32,
@@ -20,7 +24,7 @@ pub struct Tag {
impl Default for Tag {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
id: Id::new(),
name: String::new(),
description: String::new(),
color: egui::Color32::from_rgb(20, 20, 20),
@@ -128,7 +132,7 @@ impl Tag {
});
}
pub fn selector_ui(tag_ids: &mut Vec<String>, ui: &mut egui::Ui, saved: Option<&mut bool>) {
pub fn selector_ui(tag_ids: &mut Vec<Id>, ui: &mut egui::Ui, saved: Option<&mut bool>) {
// remove duplicate tag ids
tag_ids.sort();
tag_ids.dedup();
@@ -202,9 +206,11 @@ impl Tag {
});
}
pub fn load(id: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER.join("tags").join(format!("{id}.json"));
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
pub fn load(id: &Id) -> Result<Self, Box<dyn std::error::Error>> {
let mut tag: Self = FILESYSTEM.read(id)?;
tag.saved = true;
tag.id = id.clone();
Ok(tag)
}
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
+14 -21
View File
@@ -1,12 +1,12 @@
use core::fmt;
use chrono::NaiveDate;
use core::fmt;
use egui::ScrollArea;
use serde::{Deserialize, Serialize};
use crate::{
PROJECT_FOLDER, RightPanelContent,
FILESYSTEM, PROJECT_FOLDER, RightPanelContent,
editors::object_editor::ObjectInstance,
filesystem::{self, FileSystem, Id},
util::{self, Error},
};
@@ -17,7 +17,7 @@ pub enum FieldType {
MultiLine,
Date,
Number,
Link { template_id: Option<String> },
Link { template_id: Option<Id> },
Links,
}
@@ -48,8 +48,8 @@ pub enum FieldValue {
MultiLine(String),
Date(NaiveDate),
Number(f64),
Link(String),
Links(Vec<String>),
Link(Id),
Links(Vec<Id>),
}
impl FieldValue {
@@ -60,7 +60,7 @@ impl FieldValue {
FieldType::MultiLine => Self::MultiLine(String::new()),
FieldType::Date => Self::Date(NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()),
FieldType::Number => Self::Number(0.0),
FieldType::Link { template_id: None } => Self::Link(String::new()),
FieldType::Link { template_id: None } => Self::Link(Id::default()),
FieldType::Link {
template_id: Some(template_id),
} => Self::Link(template_id.clone()),
@@ -89,7 +89,7 @@ pub struct FieldDefinition {
#[derive(Serialize, Deserialize)]
pub struct Template {
pub name: String,
pub id: String,
pub id: Id,
pub description: Option<String>,
pub fields: Vec<FieldDefinition>,
@@ -155,7 +155,7 @@ impl Default for Template {
fn default() -> Self {
Self {
name: "New Template".to_string(),
id: uuid::Uuid::new_v4().to_string(),
id: Id::new(),
description: Some(String::from("Placeholder description")),
fields: Vec::new(),
saved: false,
@@ -171,22 +171,15 @@ impl Default for Template {
}
impl Template {
pub fn load(id: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER.join("templates").join(format!("{id}.json"));
let content = std::fs::read_to_string(&path)?;
let mut template: Self = serde_json::from_str(&content)?;
pub fn load(id: &Id) -> Result<Self, Box<dyn std::error::Error>> {
let mut template: Self = FILESYSTEM.read(id)?;
template.saved = true;
template.id = id.clone();
Ok(template)
}
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let path = PROJECT_FOLDER
.join("templates")
.join(format!("{}.json", &self.id));
let content = serde_json::to_string_pretty(self)?;
std::fs::write(path, content)?;
FILESYSTEM.write(&self.id, self.clone())?;
self.saved = true;
Ok(())
}
@@ -226,7 +219,7 @@ impl Template {
if ui.button("Create Copy").clicked() {
let mut copy = self.clone();
copy.id = uuid::Uuid::new_v4().to_string();
copy.id = Id::new();
copy.name = format!("{} (Copy)", self.name);
copy.save().unwrap();
}
+37 -36
View File
@@ -1,4 +1,7 @@
use walkdir::{DirEntry, WalkDir};
use itertools::Itertools;
use std::fs::{self, DirEntry};
// use walkdir::{DirEntry, WalkDir};
use crate::{
PROJECT_FOLDER, RightPanelContent,
@@ -7,6 +10,7 @@ use crate::{
asset_editor::Asset, content_editor::ContentSection, object_editor::ObjectInstance,
tags::Tag, template_editor::Template,
},
filesystem::Id,
note_editor::Note,
};
@@ -179,7 +183,7 @@ impl Explorer {
// Filter documents that have the current parent (or no parent if this is the root)
let child_docs: Vec<&ContentSection> = documents
.iter()
.filter(|doc| doc.parent.as_deref() == parent_id)
.filter(|doc| doc.parent.as_ref().map(|id| id.as_str()) == parent_id)
.collect();
for doc in child_docs {
@@ -204,7 +208,7 @@ impl Explorer {
})
.body(|ui| {
// recursive call to render the next level of documents
Self::render_doc_branch(ui, documents, Some(&doc.id), load_doc);
Self::render_doc_branch(ui, documents, Some(doc.id.as_str()), load_doc);
});
}
}
@@ -249,50 +253,40 @@ impl Explorer {
});
})
.body(|ui| {
let mut entries: Vec<_> = WalkDir::new(PROJECT_FOLDER.join("assets"))
.min_depth(1)
.max_depth(1) // Only immediate children
.sort_by(|a, b| {
let entries = fs::read_dir(PROJECT_FOLDER.join("assets"))
.unwrap()
.filter_map(Result::ok)
.sorted_by(|a, b| {
// Directories first, then files
let a_is_dir = a.file_type().is_dir();
let b_is_dir = b.file_type().is_dir();
let a_is_dir = a.file_type().unwrap().is_dir();
let b_is_dir = b.file_type().unwrap().is_dir();
if a_is_dir == b_is_dir {
a.file_name().cmp(b.file_name())
a.file_name().cmp(&b.file_name())
} else if a_is_dir {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
}
})
.into_iter()
.filter_map(Result::ok)
.collect();
.collect::<Vec<_>>();
for entry in entries {
self.render_entry(ui, to_load, &entry);
Self::render_entry(ui, to_load, &entry);
}
});
}
fn render_entry(
&mut self,
ui: &mut egui::Ui,
to_load: &mut Option<RightPanelContent>,
entry: &DirEntry,
) {
let file_type = entry.file_type();
fn render_entry(ui: &mut egui::Ui, to_load: &mut Option<RightPanelContent>, entry: &DirEntry) {
let file_type = entry.file_type().unwrap();
let is_dir = file_type.is_dir();
let file_name = entry.file_name().to_string_lossy();
let file_name = entry.file_name().to_str().unwrap().to_string();
let path = entry.path();
if is_dir {
let entries: Vec<_> = WalkDir::new(path)
.min_depth(1)
.max_depth(1)
.sort_by(|a, b| a.file_name().cmp(b.file_name()))
.into_iter()
let entries = fs::read_dir(path)
.unwrap()
.filter_map(Result::ok)
.collect();
.collect::<Vec<_>>();
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
@@ -302,13 +296,13 @@ impl Explorer {
.show_header(ui, |ui| {
ui.horizontal(|ui| {
ui.label(file_name);
let clicked = ui.button("+").on_hover_text("Add new item").clicked();
let _clicked = ui.button("+").on_hover_text("Add new item").clicked();
});
})
.body(|ui| {
// recursive call to render the next level of documents
for entry in entries {
self.render_entry(ui, to_load, &entry);
Self::render_entry(ui, to_load, &entry);
}
});
} else {
@@ -334,7 +328,7 @@ impl Explorer {
let mut templates = Vec::new();
for entry in std::fs::read_dir(&templates_folder).unwrap() {
let path = entry.unwrap().path();
match Template::load(path.file_stem().unwrap().to_str().unwrap()) {
match Template::load(&Id::from_path(&path)) {
Ok(t) => templates.push(t),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
}
@@ -353,7 +347,7 @@ impl Explorer {
let mut objects = Vec::new();
for entry in std::fs::read_dir(&objects_folder).unwrap() {
let path = entry.unwrap().path();
match ObjectInstance::load(path.file_stem().unwrap().to_str().unwrap()) {
match ObjectInstance::load(&Id::from_path(&path)) {
Ok(o) => objects.push(o),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
}
@@ -373,7 +367,7 @@ impl Explorer {
for entry in std::fs::read_dir(&notes_folder).unwrap() {
let path = entry.unwrap().path();
match Note::load(path.file_stem().unwrap().to_str().unwrap()) {
match Note::load(&Id::from_path(&path)) {
Ok(note) => notes.push(note),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
}
@@ -394,9 +388,16 @@ impl Explorer {
for entry in std::fs::read_dir(&documents_folder).unwrap() {
let path = entry.unwrap().path();
match ContentSection::load(path.file_stem().unwrap().to_str().unwrap()) {
Ok(document) => documents.push(MainEditor::open(document)),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
// TODO: Update to use FileSystem API
// For now, read files directly until we refactor the loading system
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(document) =
serde_json::from_str::<crate::editors::content_editor::ContentSection>(&content)
{
documents.push(MainEditor::open(document));
} else {
eprintln!("Could not parse file {path:?}");
}
}
}
+137
View File
@@ -0,0 +1,137 @@
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{
fmt,
ops::{Deref, DerefMut},
path::{Path, PathBuf},
};
use crate::FILESYSTEM;
#[cfg(feature = "native")]
pub mod native;
#[cfg(feature = "web")]
pub mod web;
#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash, Serialize, Deserialize)]
pub struct Id(String);
impl Id {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
}
impl AsRef<str> for Id {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Id {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_path(path: &Path) -> Self {
Self(path.file_name().unwrap().to_str().unwrap().to_string())
}
}
impl fmt::Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Default for Id {
fn default() -> Self {
Id(String::new())
}
}
#[derive(Debug)]
pub enum FileSystemError {
FileNotFound(Id, String),
DirectoryNotFound(PathBuf, String),
}
impl fmt::Display for FileSystemError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FileSystemError::FileNotFound(id, message) => {
write!(f, "File not found: {} - {}", id, message)
}
FileSystemError::DirectoryNotFound(id, message) => {
write!(f, "Directory not found: {} - {}", id.display(), message)
}
}
}
}
impl std::error::Error for FileSystemError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTree {
pub name: String,
pub path: PathBuf,
pub is_directory: bool,
pub id: Option<Id>,
pub children: Vec<FileTree>,
}
pub trait FileSystem {
fn new(root: impl AsRef<Path>) -> Self;
fn create(&self, directory: &Path, data: impl Serialize) -> Result<Id, FileSystemError>;
fn read<T: DeserializeOwned>(&self, id: &Id) -> Result<T, FileSystemError>;
fn write(&self, id: &Id, data: impl Serialize) -> Result<(), FileSystemError>;
fn borrow<T: DeserializeOwned + Serialize>(
&self,
id: &Id,
) -> Result<FileBorrow<T>, FileSystemError> {
let file = self.read(id)?;
Ok(FileBorrow {
id: id.clone(),
file,
})
}
fn delete(&self, id: &Id) -> Result<(), FileSystemError>;
fn clone(&self, id: &Id, new_id: &Id) -> Result<(), FileSystemError>;
fn exists(&self, id: &Id) -> bool;
fn lsdir(&self, id: &Id) -> Result<Vec<String>, FileSystemError>;
fn file_tree(&self, root_path: &Path) -> Result<FileTree, FileSystemError>;
}
pub struct FileBorrow<T: DeserializeOwned + Serialize> {
pub id: Id,
pub file: T,
}
impl<T: DeserializeOwned + Serialize> Deref for FileBorrow<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.file
}
}
impl<T: DeserializeOwned + Serialize> DerefMut for FileBorrow<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.file
}
}
impl<T: DeserializeOwned + Serialize> Drop for FileBorrow<T> {
fn drop(&mut self) {
FILESYSTEM.write(&self.id, &self.file).unwrap();
}
}
+327
View File
@@ -0,0 +1,327 @@
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use image::EncodableLayout;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::to_string;
use crate::filesystem::FileSystemError;
use super::FileSystem;
pub struct NativeFileSystem {
root: PathBuf,
index: Arc<RwLock<HashMap<super::Id, PathBuf>>>,
}
impl NativeFileSystem {
/// Rebuild the entire index by scanning the filesystem
fn rebuild_index(&self) -> Result<(), std::io::Error> {
let mut index = HashMap::new();
Self::scan_directory(&self.root, &mut index)?;
if let Ok(mut idx) = self.index.write() {
*idx = index;
}
Ok(())
}
/// Recursively scan a directory and populate the index
fn scan_directory(
dir: &Path,
index: &mut HashMap<super::Id, PathBuf>,
) -> Result<(), std::io::Error> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
// Try to parse the filename as a UUID (our Id format)
if let Ok(uuid) = uuid::Uuid::parse_str(name) {
let id = super::Id(uuid.to_string());
index.insert(id, path.clone());
}
}
if path.is_dir() {
Self::scan_directory(&path, index)?;
}
}
Ok(())
}
/// Add an entry to the index
fn add_to_index(&self, id: super::Id, path: PathBuf) {
if let Ok(mut index) = self.index.write() {
index.insert(id, path);
}
}
/// Remove an entry from the index
fn remove_from_index(&self, id: &super::Id) {
if let Ok(mut index) = self.index.write() {
index.remove(id);
}
}
/// Get path from index, with fallback to filesystem scan if not found
fn find_path_by_id(&self, id: &super::Id) -> Option<PathBuf> {
// First try the index
if let Ok(index) = self.index.read() {
if let Some(path) = index.get(id) {
// Verify the file still exists
if path.exists() {
return Some(path.clone());
}
}
}
// Fallback: scan filesystem and update index
let target_name = id.to_string();
let mut stack = vec![self.root.clone()];
while let Some(dir) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == target_name {
// Update index with found path
self.add_to_index(id.clone(), path.clone());
return Some(path);
}
}
if path.is_dir() {
stack.push(path);
}
}
}
None
}
/// Build a file tree recursively from a given path
fn build_file_tree(&self, path: &Path) -> Result<super::FileTree, FileSystemError> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let is_directory = path.is_dir();
// Check if this path corresponds to an ID in our index
let id = if let Ok(index) = self.index.read() {
index
.iter()
.find(|(_, indexed_path)| indexed_path.as_path() == path)
.map(|(id, _)| id.clone())
} else {
None
};
let mut children = Vec::new();
if is_directory {
for entry in fs::read_dir(path).map_err(|e| {
FileSystemError::DirectoryNotFound(path.to_path_buf(), e.to_string())
})? {
let entry = entry.map_err(|e| {
FileSystemError::DirectoryNotFound(path.to_path_buf(), e.to_string())
})?;
let child_path = entry.path();
match self.build_file_tree(&child_path) {
Ok(child_tree) => children.push(child_tree),
Err(e) => eprintln!(
"Warning: Failed to build tree for {}: {}",
child_path.display(),
e
),
}
}
}
Ok(super::FileTree {
name,
path: path.to_path_buf(),
is_directory,
id,
children,
})
}
}
impl FileSystem for NativeFileSystem {
fn new(root: impl AsRef<Path>) -> Self {
let fs = Self {
root: root.as_ref().to_path_buf(),
index: Arc::new(RwLock::new(HashMap::new())),
};
fs.rebuild_index().unwrap_or_else(|e| {
eprintln!("Warning: Failed to build initial index: {e}");
});
fs
}
fn create(&self, directory: &Path, data: impl Serialize) -> Result<super::Id, FileSystemError> {
let dir = if directory.is_absolute() {
directory.to_path_buf()
} else {
self.root.join(directory)
};
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| {
FileSystemError::DirectoryNotFound(dir.to_path_buf(), e.to_string())
})?;
}
if !dir.is_dir() {
return Err(FileSystemError::DirectoryNotFound(
dir.to_path_buf(),
"".to_string(),
));
}
let id = super::Id::new();
let file_path = dir.join(id.to_string());
let mut file = File::create(&file_path)
.map_err(|e| FileSystemError::DirectoryNotFound(dir.to_path_buf(), e.to_string()))?;
file.write_all(serde_json::to_string(&data).unwrap().as_bytes())
.map_err(|e| FileSystemError::DirectoryNotFound(dir.to_path_buf(), e.to_string()))?;
// Add to index
self.add_to_index(id.clone(), file_path);
Ok(id)
}
fn read<T: DeserializeOwned>(&self, id: &super::Id) -> Result<T, FileSystemError> {
let path = self.find_path_by_id(id).ok_or_else(|| {
FileSystemError::FileNotFound(id.clone(), "No path found!".to_string())
})?;
let val = serde_json::from_reader(
File::open(path)
.map_err(|_| FileSystemError::FileNotFound(id.clone(), "".to_string()))?,
)
.map_err(|_| FileSystemError::FileNotFound(id.clone(), "".to_string()))?;
Ok(val)
}
fn write(&self, id: &super::Id, data: impl Serialize) -> Result<(), FileSystemError> {
let path = self.find_path_by_id(id).ok_or_else(|| {
FileSystemError::FileNotFound(id.clone(), "No path found!".to_string())
})?;
let mut file = File::create(path)
.map_err(|_| FileSystemError::FileNotFound(id.clone(), "".to_string()))?;
file.write_all(serde_json::to_string(&data).unwrap().as_bytes())
.map_err(|_| FileSystemError::FileNotFound(id.clone(), "".to_string()))?;
Ok(())
}
fn delete(&self, id: &super::Id) -> Result<(), FileSystemError> {
let path = self.find_path_by_id(id).ok_or_else(|| {
FileSystemError::FileNotFound(id.clone(), "No path found!".to_string())
})?;
let result = if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
// Remove from index if deletion was successful
if result.is_ok() {
self.remove_from_index(id);
}
result.map_err(|e| FileSystemError::FileNotFound(id.clone(), e.to_string()))
}
fn clone(&self, id: &super::Id, new_id: &super::Id) -> Result<(), FileSystemError> {
let src = self.find_path_by_id(id).ok_or_else(|| {
FileSystemError::FileNotFound(id.clone(), "No path found!".to_string())
})?;
let parent = src.parent().map(Path::to_path_buf).ok_or_else(|| {
FileSystemError::FileNotFound(id.clone(), "No parent found!".to_string())
})?;
let dst = parent.join(new_id.to_string());
let result = if src.is_dir() {
// Simple recursive dir copy
fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let sp = entry.path();
let dp = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir(&sp, &dp)?;
} else if ty.is_file() {
fs::copy(&sp, &dp)?;
}
}
Ok(())
}
copy_dir(&src, &dst)
} else {
fs::copy(&src, &dst).map(|_| ())
};
// Add cloned file/directory to index if copy was successful
if result.is_ok() {
self.add_to_index(new_id.clone(), dst);
}
result.map_err(|e| FileSystemError::FileNotFound(id.clone(), e.to_string()))
}
fn exists(&self, id: &super::Id) -> bool {
self.find_path_by_id(id).is_some()
}
fn lsdir(&self, id: &super::Id) -> Result<Vec<String>, FileSystemError> {
let path = match self.find_path_by_id(id) {
Some(p) if p.is_dir() => p,
Some(p) => p.parent().map(Path::to_path_buf).ok_or_else(|| {
FileSystemError::DirectoryNotFound(PathBuf::from(id.to_string()), "".to_string())
})?,
None => {
return Err(FileSystemError::DirectoryNotFound(
PathBuf::from(id.to_string()),
id.to_string(),
));
}
};
let mut entries = Vec::new();
for entry in fs::read_dir(&path)
.map_err(|e| FileSystemError::DirectoryNotFound(path.clone(), e.to_string()))?
{
let entry = entry
.map_err(|e| FileSystemError::DirectoryNotFound(path.clone(), e.to_string()))?;
if let Some(name) = entry.file_name().to_str() {
entries.push(name.to_string());
}
}
Ok(entries)
}
fn file_tree(&self, root_path: &Path) -> Result<super::FileTree, FileSystemError> {
self.build_file_tree(root_path)
}
}
View File
+489 -33
View File
@@ -1,58 +1,479 @@
use std::{
io::{BufRead, BufReader},
sync::{Arc, Mutex},
};
use serde::{Deserialize, Serialize};
use crate::editors::settings_editor::ProjectSettings;
#[derive(Clone)]
pub struct ContentAI {
pub open: bool,
// model input
pub content: String,
pub instruction: String,
pub context_override: String,
pub system_prompt: String,
// model settings
pub max_tokens: usize,
pub temperature: f32,
pub reasoning_effort: ReasoningEffort,
pub model_override: String,
// model output
pub reasoning: Arc<Mutex<String>>,
pub result: Arc<Mutex<String>>,
pub ready: Arc<Mutex<ReadyState>>,
}
impl ContentAI {
pub fn new(content: String) -> Self {
Self {
// model input
content,
instruction: String::new(),
context_override: String::new(),
system_prompt: String::new(),
// model settings
max_tokens: 2048,
reasoning_effort: ReasoningEffort::default(),
temperature: 0.7,
model_override: String::new(),
reasoning: Arc::new(Mutex::new(String::new())),
// output
result: Arc::new(Mutex::new(String::new())),
ready: Arc::new(Mutex::new(ReadyState::Idle)),
// ui
open: true,
}
}
pub fn ui(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) {
let is_open = self.open;
if is_open {
egui::SidePanel::right("ai_assistant").show_inside(ui, |ui| {
Self::ui_main(self, ui, project);
});
}
self.open = is_open;
}
fn ui_output_box(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) {
let mut ready_lock = self.ready.lock().unwrap();
ui.horizontal(|ui| {
if *ready_lock == ReadyState::Generating {
if ui.button("Cancel").clicked() {
*ready_lock = ReadyState::Halted;
}
if ui.button("Stop").clicked() {
*ready_lock = ReadyState::Idle;
}
ui.spinner();
ui.label("Generating...");
}
if *ready_lock == ReadyState::Idle {
let continue_content = || {
let content = self.content.clone();
let project = project.clone();
let result = self.result.clone();
let reasoning = self.reasoning.clone();
let ready = self.ready.clone();
let options = AIOptions {
max_completion_tokens: self.max_tokens,
reasoning_effort: self.reasoning_effort,
temperature: self.temperature,
model_override: if !self.model_override.is_empty() {
Some(self.model_override.clone())
} else {
None
},
};
let ai_input = AIInput {
system_prompt: self.system_prompt.clone(),
user_prompt: format!(
"{}\n\n{} {}",
self.instruction.clone(),
project.ai_context.clone(),
self.context_override.clone()
),
previous_content: content.clone(),
structure: None,
};
result.lock().unwrap().clear();
std::thread::spawn(move || {
let result = crate::llm_integration::content_llm::continue_content(
ai_input,
options,
project,
result,
reasoning,
ready.clone(),
);
if let Err(e) = result {
eprintln!("Error in content generation: {e}");
}
});
};
if ui.button("Generate ").clicked() {
continue_content();
}
ui.label("Idle");
}
// show regardless of state
if ui.button("Insert").clicked() {
*ready_lock = ReadyState::Ready;
}
if ui.button("Clear").clicked() {
self.result.lock().unwrap().clear();
self.reasoning.lock().unwrap().clear();
}
});
ui.spacing();
ui.vertical(|ui| {
egui::TopBottomPanel::top("reasoning_output")
.resizable(true)
.show_inside(ui, |ui| {
egui::ScrollArea::both()
.auto_shrink([false, true])
.id_salt("reasoning_output")
.max_width(ui.available_width())
// .max_height(ui.available_height() / 3.0)
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut *self.reasoning.lock().unwrap())
.font(egui::TextStyle::Monospace)
.interactive(false)
.desired_rows(5)
.frame(false)
.desired_width(ui.available_width())
.lock_focus(true)
.hint_text("Reasoning will appear here..."),
);
});
});
egui::ScrollArea::both()
.auto_shrink([false, false])
.id_salt("llm_output")
.max_width(ui.available_width())
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut *self.result.lock().unwrap())
.font(egui::TextStyle::Monospace)
.interactive(false)
.desired_rows(0)
.frame(false)
.desired_width(ui.available_width())
.lock_focus(true)
.hint_text("Content will appear here..."),
);
});
});
}
fn ui_main(&mut self, ui: &mut egui::Ui, project: &mut ProjectSettings) {
{
ui.weak("(The model will see current file content)");
egui::CollapsingHeader::new("Settings")
.default_open(true)
.show(ui, |ui| {
egui::Grid::new("continue_grid")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
ui.label("Max Tokens");
ui.add(
egui::DragValue::new(&mut self.max_tokens)
.range(128..=u32::MAX)
.speed(128),
);
ui.end_row();
ui.label("Temperature");
ui.add(
egui::DragValue::new(&mut self.temperature)
.range(0.0..=2.0)
.speed(0.1),
);
ui.label("Reasoning effort");
egui::ComboBox::from_id_salt("reasoning_effort")
.selected_text(self.reasoning_effort.to_string())
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.reasoning_effort,
ReasoningEffort::Minimal,
"Minimal",
);
ui.selectable_value(
&mut self.reasoning_effort,
ReasoningEffort::Low,
"Low",
);
ui.selectable_value(
&mut self.reasoning_effort,
ReasoningEffort::Medium,
"Medium",
);
ui.selectable_value(
&mut self.reasoning_effort,
ReasoningEffort::High,
"High",
);
});
ui.end_row();
ui.label("Model override");
ui.add(egui::TextEdit::singleline(&mut self.model_override));
ui.end_row();
});
});
egui::TopBottomPanel::top("continue_instruction")
.resizable(true)
.show_inside(ui, |ui| {
egui::CollapsingHeader::new("Instructions")
.default_open(true)
.show(ui, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height())
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.instruction)
.frame(false)
.desired_width(ui.available_width())
.hint_text("Writing Instructions"),
);
});
});
});
egui::TopBottomPanel::top("continue_context")
.resizable(true)
.show_inside(ui, |ui| {
egui::CollapsingHeader::new("Context")
.default_open(true)
.show(ui, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height())
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.context_override)
.frame(false)
.desired_width(ui.available_width())
.hint_text("Any additional context?"),
);
});
});
});
egui::TopBottomPanel::top("continue_system_prompt")
.resizable(true)
.show_inside(ui, |ui| {
egui::CollapsingHeader::new("System prompt")
.default_open(true)
.show(ui, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height())
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.system_prompt)
.frame(false)
.desired_width(ui.available_width())
.hint_text("System prompt"),
);
});
});
});
self.ui_output_box(ui, project);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn continue_content(
context: &str,
instruction: &str,
max_tokens: usize,
) -> Result<String, Box<dyn std::error::Error>> {
ai_input: AIInput,
// context: String,
// previous_content: String,
// instruction: String,
options: AIOptions,
project: ProjectSettings,
result: Arc<Mutex<String>>,
reasoning: Arc<Mutex<String>>,
ready: Arc<Mutex<ReadyState>>,
) -> Result<(), Box<dyn std::error::Error>> {
*ready.lock().unwrap() = ReadyState::Generating;
let client = reqwest::blocking::Client::new();
let messages = vec![
Message {
role: "system".to_string(),
content: "
Please generate content that is a direct continuation of the given text.
Your response should be a logical next step in the content and should not repeat any of the text from the instruction or the content.
Do not generate any text that is not a direct continuation of the content.
if extra instructions are provided, follow them exactly, otherwise continue the text in a logical way.
".to_string(),
content: ai_input.system_prompt,
},
Message {
role: "user".to_string(),
content: context.to_string(),
},
Message {
role: "user".to_string(),
content: format!("Instructions: {instruction}"),
content: format!(
"<Instructions> {}\n\n<Previous content> {}\n\n",
ai_input.user_prompt, ai_input.previous_content
),
},
];
let request = ChatRequest {
messages,
temperature: 0.7,
temperature: options.temperature,
max_tokens: options.max_completion_tokens,
model: options.model_override,
reasoning_effort: options.reasoning_effort,
stream: true,
};
let response = client
.post("http://localhost:1234/v1/chat/completions")
.json(&request)
.send()?;
if !response.status().is_success() {
return Err(format!("Request failed: {}", response.text()?).into());
}
let response: ChatResponse = response.json()?;
if let Some(choice) = response.choices.into_iter().next() {
Ok(choice.message.content)
let llm_api_uri = if let Some(uri) = project.local_overrides.llm_api_uri {
uri
} else {
Err("No response from model".into())
project.global_settings.llm_api_uri.unwrap()
};
let api_key = if let Some(key) = project.local_overrides.llm_api_key {
if key.is_empty() { None } else { Some(key) }
} else if let Some(key) = project.global_settings.llm_api_key {
if key.is_empty() { None } else { Some(key) }
} else {
return Err("No API key found".into());
};
let response = if let Some(k) = api_key {
client
.post(llm_api_uri + "/api/v0/chat/completions")
.json(&request)
.bearer_auth(k)
.send()?
} else {
client
.post(llm_api_uri + "/api/v0/chat/completions")
.json(&request)
.send()?
};
println!("success!");
// println!("response: {}", response.text().unwrap());
let reader = BufReader::new(response);
for line in reader.lines() {
// initial loop to check if the user has terminated the generation
{
let mut ready = ready.lock().unwrap();
if *ready == ReadyState::Halted {
result.lock().unwrap().clear();
reasoning.lock().unwrap().clear();
}
if *ready != ReadyState::Generating {
*ready = ReadyState::Idle;
break;
}
}
let line = line?;
if line == "data: [DONE]" {
break;
}
if let Some(json) = line.strip_prefix("data: ") {
if let Ok(chunk) = serde_json::from_str::<StreamingChatResponse>(json) {
println!("chunk: {chunk:?}");
if let Some(content) = chunk.choices[0].delta.content.as_ref() {
println!("content: {content}");
result.lock().unwrap().push_str(content);
}
if let Some(reasoning_content) = chunk.choices[0].delta.reasoning_content.as_ref() {
println!("reasoning_content: {reasoning_content}");
reasoning.lock().unwrap().push_str(reasoning_content);
}
}
}
}
*ready.lock().unwrap() = ReadyState::Idle;
Ok(())
}
pub fn ai_enabled() -> bool {
let client = reqwest::blocking::Client::new();
client.get("http://localhost:1234/v1/models").send().is_ok()
pub struct AIOptions {
pub max_completion_tokens: usize,
pub temperature: f32,
pub reasoning_effort: ReasoningEffort,
pub model_override: Option<String>,
}
pub struct AIInput {
pub system_prompt: String,
pub user_prompt: String,
pub previous_content: String,
pub structure: Option<String>,
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum ReadyState {
Idle,
Generating,
Ready,
Halted,
}
#[derive(Serialize, Copy, Clone, PartialEq, Default)]
pub enum ReasoningEffort {
#[serde(rename = "minimal")]
Minimal,
#[default]
#[serde(rename = "low")]
Low,
#[serde(rename = "medium")]
Medium,
#[serde(rename = "high")]
High,
}
impl std::fmt::Display for ReasoningEffort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReasoningEffort::Minimal => write!(f, "Minimal"),
ReasoningEffort::Low => write!(f, "Low"),
ReasoningEffort::Medium => write!(f, "Medium"),
ReasoningEffort::High => write!(f, "High"),
}
}
}
// Simple request structure
@@ -60,6 +481,39 @@ pub fn ai_enabled() -> bool {
struct ChatRequest {
messages: Vec<Message>,
temperature: f32,
max_tokens: usize,
stream: bool,
reasoning_effort: ReasoningEffort,
// if we give the API model:null it returns 500
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
// Streaming response structures
#[derive(Deserialize, Debug)]
struct StreamingChatResponse {
choices: Vec<StreamingChoice>,
}
#[derive(Deserialize, Debug)]
struct StreamingChoice {
delta: Delta,
#[serde(default)]
#[allow(unused)]
finish_reason: Option<String>,
}
#[derive(Deserialize, Debug)]
struct Delta {
#[serde(default)]
#[allow(unused)]
role: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -70,10 +524,12 @@ struct Message {
#[derive(Deserialize, Debug)]
struct ChatResponse {
#[allow(unused)]
choices: Vec<Choice>,
}
#[derive(Deserialize, Debug)]
struct Choice {
#[allow(unused)]
message: Message,
}
+41 -18
View File
@@ -1,20 +1,26 @@
#![windows_subsystem = "windows"]
use std::{path::PathBuf, sync::LazyLock};
use egui::ScrollArea;
mod editors;
mod explorer;
#[cfg(feature = "llm")]
mod llm_integration;
mod scene;
mod filesystem;
mod util;
use crate::{
editors::{
asset_editor::Asset, content_editor, context_editor::ProjectContext, note_editor,
object_editor::ObjectInstance, tags::Tag, template_editor::Template,
asset_editor::Asset, content_editor, note_editor, object_editor::ObjectInstance,
settings_editor::ProjectSettings, tags::Tag, template_editor::Template,
},
explorer::Explorer,
filesystem::{FileSystem, native::NativeFileSystem},
};
static VERSION: &str = "0.1.0";
@@ -24,6 +30,9 @@ static PROJECT_FOLDER: LazyLock<PathBuf> = LazyLock::new(|| {
path
});
pub static FILESYSTEM: LazyLock<NativeFileSystem> =
LazyLock::new(|| NativeFileSystem::new(&*PROJECT_FOLDER));
fn main() {
let app = Interface::new();
let options = eframe::NativeOptions {
@@ -31,16 +40,19 @@ fn main() {
..Default::default()
};
let _ = eframe::run_native("Code Editor", options, Box::new(|_cc| Ok(Box::new(app))));
if let Err(e) = eframe::run_native("World Coder", options, Box::new(|_cc| Ok(Box::new(app)))) {
eprintln!("Failed to run app: {e}");
}
eprintln!("App closed");
}
pub struct Interface {
// dialog: Option<egui_file::FileDialog>,
right_panel_content: RightPanelContent,
editor: content_editor::MainEditor,
scene: scene::EditorScene,
explorer: Explorer,
project: ProjectContext,
project: ProjectSettings,
filesystem: NativeFileSystem,
}
impl eframe::App for Interface {
@@ -84,13 +96,13 @@ impl Interface {
Self {
right_panel_content: RightPanelContent::None,
editor: content_editor::MainEditor::new(),
scene: scene::EditorScene::new(),
explorer: Explorer::new(),
project: ProjectContext::load(),
project: ProjectSettings::load(),
filesystem: NativeFileSystem::new(&*PROJECT_FOLDER),
}
}
fn render_top_panel(&self, ctx: &egui::Context) {
fn render_top_panel(&mut self, ctx: &egui::Context) {
// Top bar with actions
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| {
@@ -99,9 +111,24 @@ impl Interface {
ui.separator();
// version
ui.label(VERSION)
ui.label(VERSION);
// Settings
if ui.button("Settings").clicked() {
self.project.open();
}
});
});
if self.project.open {
let mut open = self.project.open;
egui::Window::new("Settings")
.open(&mut open)
.show(ctx, |ui| {
self.project.ui(ui);
});
self.project.open = open;
}
}
fn render_left_panel(&mut self, ctx: &egui::Context) {
@@ -110,8 +137,7 @@ impl Interface {
.resizable(true)
.default_width(250.0)
.show(ctx, |ui| {
ui.heading("Project Files");
ui.separator();
ui.heading("Explorer");
let mut to_load: Option<RightPanelContent> = None;
let mut load_doc: Option<content_editor::MainEditor> = None;
@@ -126,8 +152,6 @@ impl Interface {
if let Some(load_doc) = load_doc {
self.editor = load_doc;
self.editor.show_editor = true;
self.editor.show_preview = true;
}
});
}
@@ -188,8 +212,7 @@ impl Interface {
// render main content area
fn render_main_content(&mut self, ctx: &egui::Context) {
self.editor.ui(ctx);
self.scene.ui(ctx, &mut self.explorer.objects());
self.editor.ui(ctx, &mut self.project, &self.filesystem);
}
// configure appearance of UI elements
@@ -214,7 +237,7 @@ impl Interface {
fonts.font_data.insert(
"JetBrains Mono Nerd Font".to_string(),
std::sync::Arc::new(egui::FontData::from_static(include_bytes!(
"/usr/local/share/fonts/j/JetBrainsMonoNerdFont_Regular.ttf",
"../font/JetBrainsMonoNerdFontMono_Regular.ttf",
))),
);
-147
View File
@@ -1,147 +0,0 @@
use egui::{RichText, vec2};
use crate::{
PROJECT_FOLDER,
editors::{
object_editor::ObjectInstance,
template_editor::{FieldType, FieldValue, Template},
},
};
pub struct EditorScene {
rect: egui::Rect,
}
impl EditorScene {
pub fn new() -> Self {
Self {
rect: egui::Rect::ZERO,
}
}
pub fn ui(&mut self, ctx: &egui::Context, objects: &mut [ObjectInstance]) {
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
egui::Scene::default()
.zoom_range(0.1..=10.0)
.show(ui, &mut self.rect, |ui| {
ui.horizontal_wrapped(|ui| {
ui.set_max_width(5000.0);
// Group objects by their template_id
use std::collections::HashMap;
let mut objects_by_template: HashMap<String, Vec<&ObjectInstance>> =
HashMap::new();
for obj in objects {
objects_by_template
.entry(obj.template_id.clone())
.or_default()
.push(obj);
}
// For each template with objects, create cards
for (template_id, template_objects) in objects_by_template {
// Try to load the template to get field definitions
if let Ok(mut template) = Template::load(&template_id) {
for obj in template_objects {
// Create a card for each object
egui::Frame::group(ui.style())
.fill(egui::Color32::from_rgba_premultiplied(
30, 30, 30, 200,
))
.corner_radius(4.0)
.show(ui, |ui| {
ui.vertical(|ui| {
ui.set_max_width(512.0);
ui.set_min_width(512.0);
// Object name as header
ui.heading(RichText::new(&obj.name).strong());
// Show fields with on_preview = true
template.fields.sort_by_key(|field| field.field_type != FieldType::Image);
for field_def in &template.fields {
if field_def.on_preview {
if let Some(field_value) =
obj.fields.get(&field_def.name)
{
ui.separator();
match field_value {
FieldValue::SingleLine(
text,
) => {
ui.strong(&field_def.name);
ui.label(text);
}
FieldValue::MultiLine(
text,
) => {
ui.strong(&field_def.name);
ui.label(text);
}
FieldValue::Number(n) => {
ui.strong(&field_def.name);
ui.label(n.to_string());
}
FieldValue::Date(date) => {
ui.strong(&field_def.name);
ui.label(
date.format(
"%Y-%m-%d",
)
.to_string(),
);
}
FieldValue::Image(value) => {
if !value.is_empty() {
let path = PROJECT_FOLDER.join("assets").join(value);
if let Ok(bytes) = std::fs::read(&path) {
let image_source = egui::ImageSource::Bytes {
uri: std::borrow::Cow::Owned(path.to_str().unwrap().to_string()),
bytes: bytes.into(),
};
ui.add(
egui::Image::new(image_source).fit_to_exact_size(vec2(512.0, 512.0)),
);
}
}
}
FieldValue::Link(
target_id,
) => {
ui.strong(&field_def.name);
ui.label(format!(
"{target_id}"
));
}
FieldValue::Links(
links,
) => {
ui.strong(&field_def.name);
ui.label(format!(
"{} links",
links.len()
));
}
}
}
}
}
});
});
// Add some spacing between cards
ui.add_space(8.0);
}
}
}
});
});
});
}
}
+3 -1
View File
@@ -3,6 +3,8 @@ use egui::{
scroll_area::{ScrollBarVisibility, ScrollSource},
};
use crate::filesystem::Id;
pub struct Error {
message: String,
visible: bool,
@@ -26,7 +28,7 @@ impl Error {
}
}
pub fn saved_status(ui: &mut egui::Ui, saved: bool, id: &str, name: &str) {
pub fn saved_status(ui: &mut egui::Ui, saved: bool, id: &Id, name: &str) {
ui.group(|ui| {
ui.set_max_width(ui.available_width());
+6
View File
@@ -0,0 +1,6 @@
{
"llm_api_uri": "http://localhost:1234",
"llm_api_key": "",
"ai_enabled": true,
"dark_theme": true
}