initial commit - v0.1.0
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustc-wrapper = "sccache"
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
*/target
|
||||
Vendored
+9
@@ -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
File diff suppressed because it is too large
Load Diff
+22
@@ -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"
|
||||
Generated
+354
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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