added notes, improved other features and removed most bugs

This commit is contained in:
2025-07-15 00:40:12 +01:00
parent 35ab726206
commit 76ec44d4e6
18 changed files with 777 additions and 488 deletions
Generated
+13
View File
@@ -3267,6 +3267,8 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.12",
"uuid",
"walkdir",
]
[[package]]
@@ -3611,6 +3613,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "v_frame"
version = "0.3.9"
+2
View File
@@ -20,3 +20,5 @@ serde_json = "1.0.140"
chrono = { version = "0.4.41", features = ["serde"] }
thiserror = "2.0.12"
egui_commonmark = { version = "0.20.0", features = ["embedded_image"] }
walkdir = "2.5.0"
uuid = { version = "1.17.0", features = ["v4"] }
@@ -0,0 +1 @@
{"name":"Note","content":"this is the note! gfjh gfdhgj fgfjhghfd iughuifghuifghuifghuifghuifdg"}
@@ -0,0 +1,13 @@
{
"id": "1da3111e-717a-4452-8209-98c5443fc31a",
"template_id": "c96f5e87-7517-44cc-a5ab-42ffd537801d",
"name": "sword",
"fields": {
"description": {
"value": "idk, it probably looks cool"
},
"durability": {
"value": "13.1"
}
}
}
@@ -0,0 +1,16 @@
{
"id": "67e29043-9aa8-4dd9-807c-cc7385eb6847",
"template_id": "c96f5e87-7517-44cc-a5ab-42ffd537801d",
"name": "sword",
"fields": {
"durability": {
"value": "8.6"
},
"Icon": {
"value": "/home/zxq5/Projects/Minecraft/Packs/ZXQ5 projects/ZXQ5 x/ZXQ5 x-512/ZXQ5 x Release/classic.pack.png"
},
"description": {
"value": "idk"
}
}
}
@@ -0,0 +1,22 @@
{
"id": "d1b10e0c-f6b4-4b9d-a96e-cb51213f0243",
"template_id": "a24b3ab7-2572-4af4-8457-df26937fd773",
"name": "zxq5",
"fields": {
"Appearance": {
"value": "taller than panic"
},
"Date of Birth": {
"value": "2025-07-15"
},
"Portrait / Image": {
"value": "/home/zxq5/Pictures/logos and pfps/YT profile picture background.png"
},
"Age": {
"value": "19"
},
"Personality": {
"value": "coder"
}
}
}
-28
View File
@@ -1,28 +0,0 @@
{
"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"
}
}
}
@@ -0,0 +1,37 @@
{
"name": "Character",
"id": "a24b3ab7-2572-4af4-8457-df26937fd773",
"description": "character",
"fields": [
{
"name": "Portrait / Image",
"field_type": "Image",
"required": true,
"description": "image of character"
},
{
"name": "Date of Birth",
"field_type": "Date",
"required": false,
"description": "date of birth"
},
{
"name": "Age",
"field_type": "Number",
"required": false,
"description": "age"
},
{
"name": "Appearance",
"field_type": "MultiLine",
"required": false,
"description": "character's appearance"
},
{
"name": "Personality",
"field_type": "MultiLine",
"required": false,
"description": "character's personality"
}
]
}
@@ -0,0 +1,25 @@
{
"name": "Item",
"id": "c96f5e87-7517-44cc-a5ab-42ffd537801d",
"description": "an in-game item",
"fields": [
{
"name": "durability",
"field_type": "Number",
"required": false,
"description": "the item's durability"
},
{
"name": "description",
"field_type": "MultiLine",
"required": true,
"description": "the item's description"
},
{
"name": "Icon",
"field_type": "Image",
"required": false,
"description": "yes"
}
]
}
-48
View File
@@ -1,48 +0,0 @@
{
"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"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
pub struct Error {
message: String,
visible: bool,
}
impl Error {
pub fn new(message: String) -> Self {
Self {
message,
visible: true,
}
}
pub fn show(&mut self, ui: &mut egui::Ui) {
egui::Window::new("Error")
.open(&mut self.visible)
.fixed_size([200.0, 100.0])
.show(ui.ctx(), |ui| {
ui.label(self.message.clone());
});
}
}
+159
View File
@@ -0,0 +1,159 @@
use egui::RichText;
use crate::{
PROJECT_FOLDER, RightPanelContent,
note::Note,
object::ObjectInstance,
template::{FieldType, Template},
};
pub struct Explorer {}
impl Explorer {
pub fn new() -> Self {
Self {}
}
pub fn ui(&mut self, to_load: &mut Option<RightPanelContent>, ui: &mut egui::Ui) {
let (templates, objects) = match Self::load_templates() {
Ok((templates, objects)) => (templates, objects),
Err(e) => {
eprintln!("Failed to load project: {e}");
ui.label(RichText::new("Failed to load project").color(egui::Color32::RED));
return;
}
};
ui.vertical(|ui| {
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
ui.make_persistent_id("templates"),
true,
)
.show_header(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Templates");
if ui.button("+").clicked() {
*to_load = Some(RightPanelContent::Template {
template: Box::new(Template::default()),
new_field_name: Default::default(),
new_field_type: FieldType::SingleLine,
new_field_required: false,
new_field_description: Default::default(),
});
}
});
})
.body(|ui| {
for template in &templates {
let id = ui.make_persistent_id(template.name.clone());
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
id,
true,
)
.show_header(ui, |ui| {
// load the template
if ui.selectable_label(false, template.name.clone()).clicked() {
*to_load = Some(RightPanelContent::template(Some(template.clone())));
}
// create a new object based on this template
if ui.button("+").clicked() {
*to_load = Some(RightPanelContent::Object {
object: Box::new(ObjectInstance::new(template)),
});
}
})
.body(|ui| {
for object in &objects {
if object.template_id == template.id {
ui.horizontal(|ui| {
ui.add_space(10.0);
// load the object
if ui.selectable_label(false, &object.name).clicked() {
*to_load = Some(RightPanelContent::Object {
object: Box::new(object.clone()),
});
}
});
}
}
});
}
});
let notes = Self::load_notes().unwrap();
egui::collapsing_header::CollapsingState::load_with_default_open(
ui.ctx(),
ui.make_persistent_id("notes"),
true,
)
.show_header(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Notes");
if ui.button("+").clicked() {
*to_load = Some(RightPanelContent::Note {
note: Box::new(Note::default()),
});
}
});
})
.body(|ui| {
for note in &notes {
ui.horizontal(|ui| {
ui.add_space(10.0);
// load the note
if ui.selectable_label(false, &note.name).clicked() {
*to_load = Some(RightPanelContent::Note {
note: Box::new(note.clone()),
});
}
});
}
});
egui::CollapsingHeader::new("Projects").show(ui, |ui| {});
});
}
fn load_templates() -> std::io::Result<(Vec<Template>, Vec<ObjectInstance>)> {
let mut templates = Vec::new();
let mut objects = Vec::new();
for entry in std::fs::read_dir(PROJECT_FOLDER.join("templates")).unwrap() {
let path = entry.unwrap().path();
match Template::load(path.file_stem().unwrap().to_str().unwrap()) {
Ok(t) => templates.push(t),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
}
}
for entry in std::fs::read_dir(PROJECT_FOLDER.join("objects")).unwrap() {
let path = entry.unwrap().path();
match ObjectInstance::load(path.file_stem().unwrap().to_str().unwrap()) {
Ok(o) => objects.push(o),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
}
}
Ok((templates, objects))
}
fn load_notes() -> std::io::Result<Vec<Note>> {
let mut notes = Vec::new();
for entry in std::fs::read_dir(PROJECT_FOLDER.join("notes")).unwrap() {
let path = entry.unwrap().path();
match Note::load(path.file_stem().unwrap().to_str().unwrap()) {
Ok(note) => notes.push(note),
Err(err) => eprintln!("Could not parse file {path:?}: {err}"),
}
}
Ok(notes)
}
}
+40 -123
View File
@@ -2,13 +2,19 @@ use std::{fs, path::PathBuf, sync::LazyLock};
use egui::{RichText, ScrollArea};
mod error;
mod explorer;
mod main_editor;
mod note;
mod object;
mod scene;
mod template;
use egui_file::DialogType;
use object::ObjectInstance;
use template::{FieldType, Template};
use crate::{explorer::Explorer, note::Note};
static PROJECT_FOLDER: LazyLock<PathBuf> = LazyLock::new(|| {
let mut path = std::env::current_dir().unwrap();
path.push("project");
@@ -27,98 +33,24 @@ fn main() {
}
pub struct Interface {
text: String,
dialog: Option<egui_file::FileDialog>,
right_panel_content: RightPanelContent,
editor: main_editor::MainEditor,
scene: scene::EditorScene,
explorer: Explorer,
}
impl Interface {
#[must_use]
pub fn new() -> Self {
Self {
text: "".to_string(),
dialog: None,
right_panel_content: RightPanelContent::None,
editor: main_editor::MainEditor::new(),
scene: scene::EditorScene::new(),
explorer: Explorer::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 {
@@ -145,29 +77,25 @@ impl eframe::App for Interface {
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()) {
if let Ok(object) =
ObjectInstance::load(path.file_stem().unwrap().to_str().unwrap())
{
// Instance
self.right_panel_content = RightPanelContent::instance(
Some(path.to_path_buf()),
Some(instance),
);
self.right_panel_content = RightPanelContent::instance(Some(object));
self.dialog = None;
} else if let Ok(template) = Template::load(path.to_path_buf()) {
} else if let Ok(template) =
Template::load(path.file_stem().unwrap().to_str().unwrap())
{
// Template
self.right_panel_content = RightPanelContent::template(
Some(path.to_path_buf()),
Some(template),
);
self.right_panel_content = RightPanelContent::template(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 {
@@ -218,14 +146,15 @@ impl eframe::App for Interface {
ui.heading("Project Files");
ui.separator();
let mut to_load: Option<RightPanelContent> = None;
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),
);
}
self.explorer.ui(&mut to_load, ui);
});
if let Some(to_load) = to_load {
self.right_panel_content = to_load;
}
});
// Main content area
@@ -234,19 +163,11 @@ impl eframe::App for Interface {
match &mut self.right_panel_content {
// an instance of a template
RightPanelContent::Instance { instance, path: _ } => {
RightPanelContent::Object { object } => {
// 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();
let template = Template::load(&object.template_id).unwrap();
ScrollArea::vertical().show(ui, |ui| {
instance.ui(ui, &template);
object.ui(ui, &template);
});
}
@@ -266,6 +187,8 @@ impl eframe::App for Interface {
new_field_description,
),
RightPanelContent::Note { note } => note.ui(ui),
RightPanelContent::None => {
ui.centered_and_justified(|ui| {
ui.label("No template loaded to edit.");
@@ -283,6 +206,7 @@ impl eframe::App for Interface {
});
self.editor.ui(ctx);
self.scene.ui(ctx);
}
}
@@ -295,15 +219,17 @@ pub enum RightPanelContent {
new_field_required: bool,
new_field_description: String,
},
Instance {
instance: Box<ObjectInstance>,
path: Option<PathBuf>,
Object {
object: Box<ObjectInstance>,
},
Note {
note: Box<Note>,
},
None,
}
impl RightPanelContent {
fn template(path: Option<PathBuf>, template: Option<Template>) -> Self {
fn template(template: Option<Template>) -> Self {
Self::Template {
template: Box::new(template.unwrap_or_default()),
new_field_name: String::new(),
@@ -313,18 +239,9 @@ impl RightPanelContent {
}
}
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,
fn instance(instance: Option<ObjectInstance>) -> Self {
Self::Object {
object: Box::new(instance.unwrap_or_default()),
}
}
}
-1
View File
@@ -25,7 +25,6 @@ impl MainEditor {
.resizable(true)
.default_width(1000.0)
.default_height(800.0)
.max_height(800.0)
.open(&mut self.show_editor)
.show(ctx, |ui| {
ui.horizontal(|ui| {
+109
View File
@@ -0,0 +1,109 @@
use std::fs;
use egui::RichText;
use serde::{Deserialize, Serialize};
use crate::PROJECT_FOLDER;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Note {
pub name: String,
pub content: String,
#[serde(skip)]
pub id: String,
#[serde(skip)]
pub saved: bool,
}
impl Default for Note {
fn default() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: "New Note".to_string(),
content: "".to_string(),
saved: false,
}
}
}
impl Note {
pub fn new(name: String, content: String) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
content,
saved: false,
}
}
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)?)?;
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();
note.saved = true;
Ok(note)
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
}
ui.group(|ui| {
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));
}
ui.label(format!("id: {}", self.id));
});
});
if ui.button("Save").clicked() {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
}
egui::Grid::new("note_grid")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.label("Name:");
if ui
.add(egui::TextEdit::singleline(&mut self.name).frame(false))
.changed()
{
self.saved = false;
}
ui.end_row();
ui.label("Content:");
if ui
.add(
egui::TextEdit::multiline(&mut self.content)
.desired_rows(1)
.frame(false),
)
.changed()
{
self.saved = false;
}
ui.end_row();
});
}
}
+68 -101
View File
@@ -1,9 +1,7 @@
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::{
@@ -14,32 +12,41 @@ use crate::{
#[derive(Debug, Serialize, Deserialize)]
pub struct ObjectInstance {
// template info
pub template_name: String,
pub template_path: PathBuf,
pub id: String,
pub template_id: String,
// instance info
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub name: 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 Clone for ObjectInstance {
fn clone(&self) -> Self {
Self {
id: self.id.clone(),
template_id: self.template_id.clone(),
name: self.name.clone(),
fields: self.fields.clone(),
saved: self.saved,
dialog: None,
}
}
}
impl Default for ObjectInstance {
fn default() -> Self {
Self {
template_name: "New Template Instance".to_string(),
name: None,
template_path: PathBuf::new(),
id: uuid::Uuid::new_v4().to_string(),
template_id: "new_template_instance".to_string(),
name: "new_object".to_string(),
fields: std::collections::HashMap::new(),
saved: false,
path: None,
dialog: None,
}
}
@@ -54,124 +61,84 @@ impl ObjectInstance {
}
Self {
template_name: template.name.clone(),
name: None,
id: uuid::Uuid::new_v4().to_string(),
template_id: template.id.clone(),
name: "new_object".to_string(),
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>> {
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)?;
self.saved = true;
Ok(())
}
pub fn load(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
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: 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);
let mut instance: ObjectInstance = serde_json::from_str(&content)?;
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) {
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
}
}
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;
}
ui.group(|ui| {
ui.horizontal(|ui| {
if self.saved {
ui.label(RichText::new("✓ Saved").color(egui::Color32::GREEN));
} 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.label(RichText::new("* Unsaved").color(egui::Color32::YELLOW));
}
}
ui.label(format!("id: {}", self.id));
});
});
if ui.button("Save").clicked() {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
}
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
// Render each field
// allow name to be edited
CollapsingHeader::new("Name")
.default_open(true)
.show(ui, |ui| {
ui.heading(RichText::new("Name").size(14.0).strong());
ui.separator();
let _ = TextEdit::singleline(&mut self.name)
.desired_width(f32::INFINITY)
.frame(false)
.show(ui)
.response;
ui.separator();
});
for field_def in &template.fields {
if let Some(field_value) = self.fields.get_mut(&field_def.name) {
CollapsingHeader::new(&field_def.name)
+27
View File
@@ -0,0 +1,27 @@
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) {
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| {
egui::Resize::default().auto_sized().show(ui, |ui| {
ui.group(|ui| {
ui.label("Scene");
});
});
});
});
}
}
+223 -187
View File
@@ -1,8 +1,9 @@
use core::fmt;
use egui::{RichText, ScrollArea};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::{PROJECT_FOLDER, RightPanelContent, object::ObjectInstance};
use crate::{PROJECT_FOLDER, RightPanelContent, error::Error, object::ObjectInstance};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum FieldType {
@@ -21,57 +22,77 @@ pub struct FieldDefinition {
pub description: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub enum EditorMode {
#[default]
View,
EditTemplate,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Serialize, Deserialize)]
pub struct Template {
pub name: String,
pub id: 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,
pub error: Option<Error>,
}
#[serde(skip)]
pub dialog: Option<egui_file::FileDialog>,
impl fmt::Debug for Template {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Template")
.field("name", &self.name)
.field("id", &self.id)
.field("description", &self.description)
.field("fields", &self.fields)
.field("saved", &self.saved)
.finish()
}
}
impl Clone for Template {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
id: self.id.clone(),
description: self.description.clone(),
fields: self.fields.clone(),
saved: self.saved,
error: None,
}
}
}
impl Default for Template {
fn default() -> Self {
Self {
name: "New Template".to_string(),
id: uuid::Uuid::new_v4().to_string(),
description: Some(String::from("Placeholder description")),
fields: Vec::new(),
saved: false,
path: None,
editor_mode: EditorMode::default(),
dialog: None,
error: None,
}
}
}
impl Template {
pub fn load(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
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)?;
template.path = Some(path);
template.saved = true;
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(self.path.as_ref().ok_or("no path")?, content)?;
std::fs::write(path, content)?;
self.saved = true;
Ok(())
}
@@ -84,110 +105,64 @@ impl Template {
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 let Some(error) = &mut self.error {
error.show(ui);
}
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;
}
});
});
});
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
}
}
pub fn viewer_ui(&self, ui: &mut egui::Ui, new_instance: &mut RightPanelContent) {
// Show template view
ui.vertical_centered(|ui| {
ui.heading(&self.name);
ScrollArea::vertical().show(ui, |ui| {
ui.vertical(|ui| {
ui.group(|ui| {
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));
}
ui.label(format!("id: {}", self.id));
});
});
if let Some(description) = &self.description {
ui.separator();
ui.label(description);
}
ui.separator();
ui.heading("Fields");
for field in &self.fields {
ui.separator();
// Save/Cancel buttons
ui.horizontal(|ui| {
ui.strong(&field.name);
ui.label(format!("({:?})", field.field_type));
if field.required {
ui.label("*");
if ui.button("Save Template").clicked() {
if let Err(e) = self.save() {
eprintln!("Failed to save: {e}");
}
}
if ui.button("Cancel").clicked() {
// load default state
*self = Self::load(&self.id).unwrap();
}
if ui.button("Create New Instance").clicked() {
if self.saved {
*new_instance = RightPanelContent::Object {
object: Box::new(ObjectInstance::new(self)),
};
} else {
self.error = Some(Error::new(
"You must save the template before creating a new instance!"
.to_string(),
));
}
}
});
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,
};
}
self.editor_ui(
ui,
new_field_name,
new_field_type,
new_field_required,
new_field_description,
);
});
});
}
@@ -199,19 +174,57 @@ impl Template {
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);
});
egui::Grid::new("template_grid")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
ui.label("Template Name:");
if ui
.add(egui::TextEdit::singleline(&mut self.name).frame(false))
.changed()
{
self.saved = false;
}
ui.end_row();
ui.horizontal(|ui| {
ui.label("Description:");
ui.text_edit_multiline(self.description.get_or_insert_with(String::new));
});
ui.label("Description:");
if ui
.add(
egui::TextEdit::multiline(self.description.get_or_insert_with(String::new))
.desired_rows(1)
.frame(false),
)
.changed()
{
self.saved = false;
}
ui.end_row();
});
ui.separator();
ui.heading("Fields");
egui::CollapsingHeader::new("Name").show(ui, |ui: &mut egui::Ui| {
egui::Grid::new("field_grid")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
ui.label("Name:");
ui.label("Name");
ui.end_row();
ui.label("Type:");
ui.label("SingleLine");
ui.end_row();
ui.label("Required");
ui.label("");
ui.end_row();
ui.label("Description:");
ui.label("Object name");
ui.end_row();
});
});
// List of fields
let mut to_remove = None;
@@ -220,52 +233,73 @@ impl Template {
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);
}
ui.label(field.name.clone());
})
.body(|ui| {
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut field.name);
});
ui.separator();
egui::Grid::new("field_grid")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
ui.label("Name:");
if ui.text_edit_singleline(&mut field.name).changed() {
self.saved = false;
}
ui.end_row();
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.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,
] {
if ui
.selectable_value(
&mut field.field_type,
variant.clone(),
format!("{variant:?}"),
)
.changed()
{
self.saved = false;
}
}
});
ui.end_row();
ui.horizontal(|ui| {
ui.checkbox(&mut field.required, "Required");
});
ui.label("Required:");
if ui.checkbox(&mut field.required, "").clicked() {
self.saved = false;
}
ui.end_row();
ui.horizontal(|ui| {
ui.label("Description:");
ui.text_edit_singleline(field.description.get_or_insert_with(String::new));
});
ui.label("Description:");
if ui
.text_edit_singleline(
field.description.get_or_insert_with(String::new),
)
.changed()
{
self.saved = false;
}
ui.end_row();
ui.separator();
});
});
}
// Remove field if needed
if let Some(index) = to_remove {
self.fields.remove(index);
self.saved = false;
}
// Add new field
@@ -273,13 +307,14 @@ impl Template {
ui.heading("Add New Field");
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
egui::Grid::new("field_grid")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
ui.label("Name:");
ui.text_edit_singleline(new_field_name);
});
ui.end_row();
ui.horizontal(|ui| {
ui.label("Type:");
egui::ComboBox::from_id_salt("new_field_type")
.selected_text(format!("{new_field_type:?}"))
@@ -298,35 +333,36 @@ impl Template {
);
}
});
});
ui.end_row();
ui.horizontal(|ui| {
ui.checkbox(new_field_required, "Required");
});
ui.label("Required:");
ui.checkbox(new_field_required, "");
ui.end_row();
ui.horizontal(|ui| {
ui.label("Description:");
ui.text_edit_singleline(new_field_description);
ui.end_row();
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())
},
});
self.saved = false;
// Reset new field form
new_field_name.clear();
*new_field_required = false;
new_field_description.clear();
}
});
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();
}
});
});
}
}