initial commit - v0.1.0

This commit is contained in:
2025-07-11 01:49:19 +01:00
commit 1b0b53a2d2
15 changed files with 6587 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
[build]
rustc-wrapper = "sccache"
+2
View File
@@ -0,0 +1,2 @@
/target
*/target
+9
View File
@@ -0,0 +1,9 @@
{
"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
}
Generated
+4817
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "somewhatusefultool"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.31.1"
egui = "0.31.1"
editor = { path = "./editor" }
egui_extras = { version = "0.31.1", features = [
"chrono",
"datepicker",
"file",
"image",
] }
egui_file = "0.22.1"
image = { version = "0.25.6", features = ["jpeg", "png"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
chrono = { version = "0.4.41", features = ["serde"] }
thiserror = "2.0.12"
egui_commonmark = "0.20.0"
+354
View File
@@ -0,0 +1,354 @@
# 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
@@ -0,0 +1,13 @@
[package]
name = "editor"
version = "0.1.0"
edition = "2024"
description = "a basic text editor widget with line numbers"
[dependencies]
egui = "0.31.1"
serde = "1"
[lib]
name = "editor"
path = "src/lib.rs"
+222
View File
@@ -0,0 +1,222 @@
use egui::{text::LayoutJob, Color32};
use egui::widgets::text_edit::TextEditOutput;
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: &str, _wrap_width: f32| {
let layout_job = egui::text::LayoutJob::single_section(
string.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")
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"template_name": "Character",
"template_path": "templates/character.json",
"name": "zxq5",
"fields": {
"Age": {
"value": "19"
},
"Birth Date": {
"value": "2025-07-11"
},
"Name": {
"value": "zxq5"
},
"Backstory": {
"value": "zxq5"
},
"Profile Picture": {
"value": "/home/zxq5/Pictures/logos and pfps/YT profile picture.png"
},
"Personality": {
"value": "zxq5"
},
"Appearance": {
"value": "zxq5"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "Character",
"description": "character template!",
"fields": [
{
"name": "Profile Picture",
"field_type": "Image",
"required": false,
"description": "Path to character's profile picture"
},
{
"name": "Name",
"field_type": "SingleLine",
"required": true,
"description": "The full name of the character"
},
{
"name": "Age",
"field_type": "Number",
"required": false,
"description": "Character's age in years"
},
{
"name": "Birth Date",
"field_type": "Date",
"required": false,
"description": "Date of birth (YYYY-MM-DD)"
},
{
"name": "Appearance",
"field_type": "MultiLine",
"required": false,
"description": "Physical description of the character"
},
{
"name": "Personality",
"field_type": "MultiLine",
"required": false,
"description": "Personality traits and behavior"
},
{
"name": "Backstory",
"field_type": "MultiLine",
"required": false,
"description": "Character's history and background"
}
]
}
+80
View File
@@ -0,0 +1,80 @@
# Project Roadmap
## v0.1 - Minimum Viable Product
### v0.1.0 - basic structure
- [x] Templates
- [x] Create template
- [x] View template
- [x] Edit template
- [x] Save template
- [x] Load template
- [ ] Delete template
- [x] Objects
- [x] Create object from template
- [x] Save object
- [x] Load object
- [x] View & Edit Object
- [ ] Delete object
- [x] Projects
- [x] Single project folder (hardcoded at current directory)
### v0.1.1 - Editor
- [ ] Basic editor (markdown formatting)
- [x] Basic text editing
- [ ] Load & Save text file
- [x] Editor preview
- [x] Preview text in markdown
## v0.2 - Projects, Organisation & Linking
### v0.2.0 - links & context building
- [ ] Links between objects
- [ ] Links in templates
### v0.2.1 - writing projects & organisation
- [ ] Project management
- [ ] Chapters/organisation
## v0.3 - Workflows & AI integration
### v0.3.0 - Build project
- [ ] Create a project build tool
- [ ] be able to store and process project metadata
- [ ] projects need a clear and defined structure
- [ ] Turn story of several chapters arranged as json files of metadata and content into a final story presented as markdown
### v0.3.1 - Basic AI tooling
- [ ] Creating objects from templates
- [ ] Integrate an AI model that can take data in any format and extract any relevant data
- [ ] Store this data as a new object
### v0.3.2 - Content generation AI
- [ ] Written Content
- [ ] Initial Context Gathering
- [ ] Tokenise the request
- [ ] Fuzzy search for relevant objects and load them into context
- [ ] Content Summarisation
- [ ] Summarise entire written work with a low level of detail and extract key details as well as the overall theme and style
- [ ] Summarise current section with a high level of detail as well as the written style etc.
- [ ] Content generation AI
- [ ] Collect context from across a story or project
- [ ] Generate content for stories
- [ ] Create original content in the form of objects using templates
- [ ] Create a new template
### v0.3.3 - AI flows & deep integration
- [ ] AI flows & deep integration
- [ ] System for the user to review and approve actions before they are run
- [ ] Tooling to perform workflows that may take many steps
- [ ] Figure out what actions it needs to do, such as creating new objects and templates in real time
+323
View File
@@ -0,0 +1,323 @@
use std::{fs, path::PathBuf, sync::LazyLock};
use egui::{RichText, ScrollArea};
mod main_editor;
mod object;
mod template;
use egui_file::DialogType;
use object::ObjectInstance;
use template::{FieldType, Template};
static PROJECT_FOLDER: LazyLock<PathBuf> = LazyLock::new(|| {
let mut path = std::env::current_dir().unwrap();
path.push("project");
path
});
fn main() {
let app = Interface::new();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
..Default::default()
};
let _ = eframe::run_native("Code Editor", options, Box::new(|_cc| Ok(Box::new(app))));
}
pub struct Interface {
text: String,
dialog: Option<egui_file::FileDialog>,
right_panel_content: RightPanelContent,
editor: main_editor::MainEditor,
}
impl Interface {
#[must_use]
pub fn new() -> Self {
Self {
text: "".to_string(),
dialog: None,
right_panel_content: RightPanelContent::None,
editor: main_editor::MainEditor::new(),
}
}
fn show_directory(
&mut self,
ui: &mut egui::Ui,
path: &PathBuf,
depth: usize,
) -> std::io::Result<()> {
if !path.exists() {
return Ok(());
}
let indent = " ".repeat(depth);
let entries = fs::read_dir(path)?;
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if let Some(ext) = path.extension() {
if ext == "json" {
// Only show JSON files
files.push(path);
}
}
}
// Sort directories and files alphabetically
dirs.sort();
files.sort();
// Show directories first
for dir in dirs {
let dir_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<invalid>")
.to_owned()
+ "/";
if egui::CollapsingHeader::new(dir_name.clone())
.default_open(depth < 1)
.show(ui, |ui| self.show_directory(ui, &dir, depth + 1))
.body_returned
.is_none()
{
ui.label(RichText::new(format!(
"{indent}❌ Error reading {dir_name}"
)));
}
}
// Then show files
for file in files {
if let Some(file_name) = file.file_name().and_then(|n| n.to_str()) {
let response = ui.horizontal(|ui| {
ui.label(" ".repeat(depth));
ui.selectable_label(false, file_name)
});
if response.inner.clicked() {
if let Ok(instance) = ObjectInstance::load(file.clone()) {
self.right_panel_content =
RightPanelContent::instance(Some(file.clone()), Some(instance));
} else if let Ok(template) = Template::load(file.clone()) {
self.right_panel_content =
RightPanelContent::template(Some(file.clone()), Some(template));
}
}
}
}
Ok(())
}
}
impl Default for Interface {
fn default() -> Self {
Self::new()
}
}
// /home/zxq5/Pictures/logos and pfps/YT profile picture background.png
impl eframe::App for Interface {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui_extras::install_image_loaders(ctx);
{
let mut visuals = egui::Visuals::dark();
visuals.window_fill = egui::Color32::from_rgb(20, 20, 20);
visuals.panel_fill = egui::Color32::from_rgb(20, 20, 20);
ctx.set_visuals(visuals);
}
if let Some(dialog) = &mut self.dialog {
if dialog.show(ctx).selected() {
if let Some(path) = dialog.path() {
if dialog.dialog_type() == DialogType::OpenFile {
// Handle file dialog for loading templates/instances
if let Ok(instance) = ObjectInstance::load(path.to_path_buf()) {
// Instance
self.right_panel_content = RightPanelContent::instance(
Some(path.to_path_buf()),
Some(instance),
);
self.dialog = None;
} else if let Ok(template) = Template::load(path.to_path_buf()) {
// Template
self.right_panel_content = RightPanelContent::template(
Some(path.to_path_buf()),
Some(template),
);
self.dialog = None;
}
} else if dialog.dialog_type() == DialogType::SaveFile {
// Handle file dialog for saving templates/instances
if let RightPanelContent::Template { template, .. } =
&mut self.right_panel_content
{
// set the save location and save
template.path = Some(path.to_path_buf());
if template.save().is_err() {
eprintln!("Failed to save template");
} else {
self.dialog = None;
}
}
}
}
}
}
// Top bar with actions
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.horizontal(|ui| {
// create new template
if ui.button("New Template").clicked() {
self.right_panel_content = RightPanelContent::Template {
template: Box::new(Template::default()),
new_field_name: String::new(),
new_field_type: FieldType::SingleLine,
new_field_required: false,
new_field_description: String::new(),
};
}
// load instance or template from file
if ui.button("Load Template/Instance").clicked() {
self.dialog = Some(egui_file::FileDialog::open_file(Some(
PROJECT_FOLDER.clone(),
)));
self.dialog.as_mut().unwrap().open();
}
});
});
// Left panel - File browser
egui::SidePanel::left("file_browser")
.resizable(true)
.default_width(250.0)
.show(ctx, |ui| {
ui.heading("Project Files");
ui.separator();
ScrollArea::vertical().show(ui, |ui| {
if let Err(e) = self.show_directory(ui, &PROJECT_FOLDER, 0) {
ui.label(
RichText::new(format!("Error reading directory: {e}"))
.color(egui::Color32::RED),
);
}
});
});
// Main content area
egui::SidePanel::right("templates").show(ctx, |ui| {
let mut new_instance: RightPanelContent = RightPanelContent::None;
match &mut self.right_panel_content {
// an instance of a template
RightPanelContent::Instance { instance, path: _ } => {
// load template from path
let mut temp_path = instance.template_path.clone();
// check if path is relative
if !temp_path.is_absolute() {
temp_path = PROJECT_FOLDER.join(temp_path);
}
let template = Template::load(temp_path).unwrap();
ScrollArea::vertical().show(ui, |ui| {
instance.ui(ui, &template);
});
}
// an editable template
RightPanelContent::Template {
template,
new_field_name,
new_field_type,
new_field_required,
new_field_description,
} => template.ui(
ui,
&mut new_instance,
new_field_name,
new_field_type,
new_field_required,
new_field_description,
),
RightPanelContent::None => {
ui.centered_and_justified(|ui| {
ui.label("No template loaded to edit.");
if ui.button("Back").clicked() {
self.right_panel_content = RightPanelContent::None;
}
});
}
};
if let RightPanelContent::None = new_instance {
} else {
self.right_panel_content = new_instance;
}
});
self.editor.ui(ctx);
}
}
pub enum RightPanelContent {
Template {
template: Box<Template>,
// fields to edit
new_field_name: String,
new_field_type: FieldType,
new_field_required: bool,
new_field_description: String,
},
Instance {
instance: Box<ObjectInstance>,
path: Option<PathBuf>,
},
None,
}
impl RightPanelContent {
fn template(path: Option<PathBuf>, template: Option<Template>) -> Self {
Self::Template {
template: Box::new(template.unwrap_or_default()),
new_field_name: String::new(),
new_field_type: FieldType::SingleLine,
new_field_required: false,
new_field_description: String::new(),
}
}
fn instance(path: Option<PathBuf>, instance: Option<ObjectInstance>) -> Self {
Self::Instance {
instance: Box::new(instance.unwrap_or_default()),
path,
}
}
fn is_saved(&self) -> bool {
match self {
RightPanelContent::Instance { instance, path: _ } => instance.saved,
RightPanelContent::Template { template, .. } => template.path.is_some(),
RightPanelContent::None => false,
}
}
}
+50
View File
@@ -0,0 +1,50 @@
use egui_commonmark::{CommonMarkCache, CommonMarkViewer};
pub struct MainEditor {
preview: bool,
text: String,
}
impl MainEditor {
pub fn new() -> Self {
Self {
preview: true,
text: String::new(),
}
}
pub fn ui(&mut self, ctx: &egui::Context) {
if self.preview {
egui::TopBottomPanel::bottom("bottom_panel")
.resizable(true)
.default_height(250.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.auto_shrink(false)
.show(ui, |ui| {
let mut cache = CommonMarkCache::default();
CommonMarkViewer::new().show(ui, &mut cache, &self.text);
});
});
}
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical()
.enable_scrolling(true)
.auto_shrink(false)
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.text)
.id_source("MainEditor_numlines")
.font(egui::TextStyle::Monospace)
.interactive(true)
.frame(false)
.lock_focus(true)
.hint_text("Type here...")
.desired_width(256.0),
);
});
});
}
}
+278
View File
@@ -0,0 +1,278 @@
use core::f32;
use std::path::PathBuf;
use chrono::NaiveDate;
use egui::{CollapsingHeader, RichText, TextEdit, Ui, vec2};
use egui_file::DialogType;
use serde::{Deserialize, Serialize};
use crate::{
PROJECT_FOLDER,
template::{FieldType, FieldValue, Template},
};
#[derive(Debug, Serialize, Deserialize)]
pub struct ObjectInstance {
// template info
pub template_name: String,
pub template_path: PathBuf,
// instance info
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub fields: std::collections::HashMap<String, FieldValue>,
// state (ignore)
#[serde(skip)]
pub saved: bool,
#[serde(skip)]
pub path: Option<PathBuf>,
#[serde(skip)]
pub dialog: Option<egui_file::FileDialog>,
}
impl Default for ObjectInstance {
fn default() -> Self {
Self {
template_name: "New Template Instance".to_string(),
name: None,
template_path: PathBuf::new(),
fields: std::collections::HashMap::new(),
saved: false,
path: None,
dialog: None,
}
}
}
impl ObjectInstance {
pub fn new(template: &Template) -> Self {
let mut fields = std::collections::HashMap::new();
for field in &template.fields {
fields.insert(field.name.clone(), FieldValue::default());
}
Self {
template_name: template.name.clone(),
name: None,
fields,
template_path: template
.path
.clone()
.expect("expected the template to have a path!"),
saved: false,
path: None,
dialog: None,
}
}
pub fn save(&self, path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
pub fn load(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(&path)?;
let mut instance: Self = serde_json::from_str(&content)?;
// Set the name from the filename (without extension)
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
instance.name = Some(stem.to_string());
}
instance.path = Some(path);
instance.saved = true;
Ok(instance)
}
fn handle_dialogs(&mut self, ui: &mut Ui) {
let path = if let Some(dialog) = &mut self.dialog {
match dialog.dialog_type() {
DialogType::SaveFile => {
if dialog.show(ui.ctx()).selected() {
dialog.path().map(|path| (path.to_path_buf(), true))
} else {
None
}
}
// Handle other dialog types...
_ => None,
}
} else {
None
};
if let Some((path, should_save)) = path {
if should_save {
if let Err(err) = self.save(path.clone()) {
println!("Save error: {err}");
} else {
// Update the name from the filename when saving
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
self.name = Some(stem.to_string());
}
self.path = Some(path);
self.saved = true;
self.dialog = None;
}
}
}
}
pub fn ui(&mut self, ui: &mut Ui, template: &Template) {
ui.vertical(|ui| {
self.handle_dialogs(ui);
// Show save status and button
ui.horizontal(|ui| {
if self.saved {
ui.label(RichText::new("✓ Saved").color(egui::Color32::GREEN));
} else {
ui.label(RichText::new("* Unsaved").color(egui::Color32::YELLOW));
}
// Show current save path or "Not saved yet"
let path_display = self
.path
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("Not saved yet");
ui.label(path_display);
// File picker button
if ui.button("Save As").clicked() && self.dialog.is_none() {
self.dialog = Some(egui_file::FileDialog::save_file(Some(
PROJECT_FOLDER.clone(),
)));
self.dialog.as_mut().unwrap().open();
}
if ui.button("Save").clicked() {
if let Some(path) = &self.path {
if let Err(e) = self.save(path.clone()) {
eprintln!("Failed to save: {e}");
} else {
self.saved = true;
}
} else {
// If no path is set, request one
if self.dialog.is_none() {
self.dialog = Some(egui_file::FileDialog::save_file(Some(
PROJECT_FOLDER.clone(),
)));
self.dialog.as_mut().unwrap().open();
}
}
}
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
// Render each field
for field_def in &template.fields {
if let Some(field_value) = self.fields.get_mut(&field_def.name) {
CollapsingHeader::new(&field_def.name)
.default_open(true)
.show(ui, |ui| {
ui.heading(RichText::new(&field_def.name).size(14.0).strong());
if let Some(desc) = &field_def.description {
ui.label(RichText::new(desc).italics().weak());
}
ui.separator();
let response = match field_def.field_type {
FieldType::SingleLine => {
TextEdit::singleline(&mut field_value.value)
.desired_width(f32::INFINITY)
.frame(false)
.show(ui)
.response
}
FieldType::MultiLine => {
TextEdit::multiline(&mut field_value.value)
.desired_width(f32::INFINITY)
.desired_rows(5)
.frame(false)
.show(ui)
.response
}
FieldType::Date => {
let date_str = &field_value.value;
let mut date =
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.unwrap_or_else(|_| {
chrono::Local::now().date_naive()
});
let response =
ui.add(egui_extras::DatePickerButton::new(&mut date));
if response.changed() {
field_value.value = date.format("%Y-%m-%d").to_string();
field_value.modified = true;
self.saved = false;
}
response
}
FieldType::Number => {
let mut num =
field_value.value.parse::<f64>().unwrap_or(0.0);
let response =
ui.add(egui::DragValue::new(&mut num).speed(0.1));
if response.changed() {
field_value.value = num.to_string();
field_value.modified = true;
self.saved = false;
}
response
}
FieldType::Image => {
// Simple path input for now
let response = TextEdit::singleline(&mut field_value.value)
.hint_text("Path to image")
.desired_width(f32::INFINITY)
.frame(false)
.show(ui)
.response;
// If we have a valid path, try to display a preview
if !field_value.value.is_empty() {
if let Ok(bytes) = std::fs::read(&field_value.value) {
let image_source = egui::ImageSource::Bytes {
uri: std::borrow::Cow::Owned(
field_value.value.clone(),
),
bytes: bytes.into(),
};
ui.add(
egui::Image::new(image_source)
.max_size(vec2(256.0, f32::INFINITY)),
);
}
}
response
}
};
if response.changed() {
field_value.modified = true;
self.saved = false;
}
ui.separator();
});
}
}
});
});
}
}
+339
View File
@@ -0,0 +1,339 @@
use egui::{RichText, ScrollArea};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::{PROJECT_FOLDER, RightPanelContent, object::ObjectInstance};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum FieldType {
Image,
SingleLine,
MultiLine,
Date,
Number,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldDefinition {
pub name: String,
pub field_type: FieldType,
pub required: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub enum EditorMode {
#[default]
View,
EditTemplate,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Template {
pub name: String,
pub description: Option<String>,
pub fields: Vec<FieldDefinition>,
#[serde(skip)]
pub path: Option<PathBuf>,
#[serde(skip)]
pub saved: bool,
#[serde(skip)]
pub editor_mode: EditorMode,
#[serde(skip)]
pub dialog: Option<egui_file::FileDialog>,
}
impl Default for Template {
fn default() -> Self {
Self {
name: "New Template".to_string(),
description: Some(String::from("Placeholder description")),
fields: Vec::new(),
saved: false,
path: None,
editor_mode: EditorMode::default(),
dialog: None,
}
}
}
impl Template {
pub fn load(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(&path)?;
let mut template: Self = serde_json::from_str(&content)?;
template.path = Some(path);
Ok(template)
}
pub fn save(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let content = serde_json::to_string_pretty(self)?;
std::fs::write(self.path.as_ref().ok_or("no path")?, content)?;
Ok(())
}
pub fn ui(
&mut self,
ui: &mut egui::Ui,
new_instance: &mut RightPanelContent,
new_field_name: &mut String,
new_field_type: &mut FieldType,
new_field_required: &mut bool,
new_field_description: &mut String,
) {
match self.editor_mode {
EditorMode::View => {
ScrollArea::vertical().show(ui, |ui| {
if ui.button("Edit Template").clicked() {
self.editor_mode = EditorMode::EditTemplate;
}
if ui.button("New Instance").clicked() {
*new_instance = RightPanelContent::Instance {
instance: Box::new(ObjectInstance::new(self)),
path: None,
};
}
});
self.viewer_ui(ui, new_instance);
if self.saved {
ui.label(RichText::new("✓ Saved").color(egui::Color32::GREEN));
} else {
ui.label(RichText::new("* Unsaved").color(egui::Color32::YELLOW));
}
// Show current save path or "Not saved yet"
let path_display = self
.path
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("Not saved yet");
ui.label(path_display);
}
EditorMode::EditTemplate => {
ScrollArea::vertical().show(ui, |ui| {
ui.vertical_centered(|ui| {
self.editor_ui(
ui,
new_field_name,
new_field_type,
new_field_required,
new_field_description,
);
// Save/Cancel buttons
ui.horizontal(|ui| {
if ui.button("Save Template").clicked() {
if let Some(_path) = &self.path {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
} else {
// Open save dialog
let mut dialog = egui_file::FileDialog::save_file(Some(
PROJECT_FOLDER.clone(),
));
dialog.open();
self.dialog = Some(dialog);
}
}
if ui.button("Cancel").clicked() {
self.editor_mode = EditorMode::View;
}
});
});
});
}
}
}
pub fn viewer_ui(&self, ui: &mut egui::Ui, new_instance: &mut RightPanelContent) {
// Show template view
ui.vertical_centered(|ui| {
ui.heading(&self.name);
if let Some(description) = &self.description {
ui.separator();
ui.label(description);
}
ui.separator();
ui.heading("Fields");
for field in &self.fields {
ui.separator();
ui.horizontal(|ui| {
ui.strong(&field.name);
ui.label(format!("({:?})", field.field_type));
if field.required {
ui.label("*");
}
});
if let Some(desc) = &field.description {
ui.label(desc);
}
}
ui.separator();
if ui.button("Create New Instance").clicked() {
*new_instance = RightPanelContent::Instance {
instance: Box::new(ObjectInstance::new(self)),
path: None,
};
}
});
}
pub fn editor_ui(
&mut self,
ui: &mut egui::Ui,
new_field_name: &mut String,
new_field_type: &mut FieldType,
new_field_required: &mut bool,
new_field_description: &mut String,
) {
// Template name and description
ui.horizontal(|ui| {
ui.label("Template Name:");
ui.text_edit_singleline(&mut self.name);
});
ui.horizontal(|ui| {
ui.label("Description:");
ui.text_edit_multiline(self.description.get_or_insert_with(String::new));
});
ui.separator();
ui.heading("Fields");
// List of fields
let mut to_remove = None;
for (i, field) in self.fields.iter_mut().enumerate() {
let id = ui.make_persistent_id(format!("field_{i}"));
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, true)
.show_header(ui, |ui| {
ui.label(field.name.clone());
if ui.button("").clicked() {
to_remove = Some(i);
}
})
.body(|ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut field.name);
});
ui.horizontal(|ui| {
ui.label("Type:");
egui::ComboBox::from_id_salt(format!("field_type_{i}"))
.selected_text(format!("{:?}", field.field_type))
.show_ui(ui, |ui| {
for variant in [
FieldType::SingleLine,
FieldType::MultiLine,
FieldType::Number,
FieldType::Date,
FieldType::Image,
] {
ui.selectable_value(
&mut field.field_type,
variant.clone(),
format!("{variant:?}"),
);
}
});
});
ui.horizontal(|ui| {
ui.checkbox(&mut field.required, "Required");
});
ui.horizontal(|ui| {
ui.label("Description:");
ui.text_edit_singleline(field.description.get_or_insert_with(String::new));
});
});
}
// Remove field if needed
if let Some(index) = to_remove {
self.fields.remove(index);
}
// Add new field
ui.separator();
ui.heading("Add New Field");
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(new_field_name);
});
ui.horizontal(|ui| {
ui.label("Type:");
egui::ComboBox::from_id_salt("new_field_type")
.selected_text(format!("{new_field_type:?}"))
.show_ui(ui, |ui| {
for variant in [
FieldType::SingleLine,
FieldType::MultiLine,
FieldType::Number,
FieldType::Date,
FieldType::Image,
] {
ui.selectable_value(
new_field_type,
variant.clone(),
format!("{variant:?}"),
);
}
});
});
ui.horizontal(|ui| {
ui.checkbox(new_field_required, "Required");
});
ui.horizontal(|ui| {
ui.label("Description:");
ui.text_edit_singleline(new_field_description);
});
if ui.button("Add Field").clicked() && !new_field_name.is_empty() {
self.fields.push(FieldDefinition {
name: new_field_name.clone(),
field_type: new_field_type.clone(),
required: *new_field_required,
description: if new_field_description.is_empty() {
None
} else {
Some(new_field_description.clone())
},
});
// Reset new field form
new_field_name.clear();
*new_field_required = false;
new_field_description.clear();
}
});
});
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FieldValue {
pub value: String,
#[serde(skip)]
pub modified: bool,
}