initial commit - v0.1.0
This commit is contained in:
+323
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user