From 1f727923d4796f4a52a586d3ce3b9ea6ac58ca72 Mon Sep 17 00:00:00 2001 From: jess Date: Sat, 4 Apr 2026 21:49:37 -0700 Subject: [PATCH] initial state --- Info.plist | 32 ++++++ LICENCE | 12 +++ build.sh | 59 +++++++++++ core/Cargo.toml | 20 ++++ core/build.rs | 21 ++++ core/cbindgen.toml | 17 ++++ core/include/swiftly.h | 37 +++++++ core/src/doc.rs | 135 +++++++++++++++++++++++++ core/src/document.rs | 39 +++++++ core/src/eval.rs | 147 +++++++++++++++++++++++++++ core/src/ffi.rs | 143 ++++++++++++++++++++++++++ core/src/lib.rs | 5 + core/src/persist.rs | 168 ++++++++++++++++++++++++++++++ debug.sh | 51 ++++++++++ src/AppDelegate.swift | 163 +++++++++++++++++++++++++++++ src/AppState.swift | 90 +++++++++++++++++ src/ContentView.swift | 26 +++++ src/EditorView.swift | 225 +++++++++++++++++++++++++++++++++++++++++ src/RustBridge.swift | 208 +++++++++++++++++++++++++++++++++++++ src/SidebarView.swift | 83 +++++++++++++++ src/Theme.swift | 131 ++++++++++++++++++++++++ src/main.swift | 8 ++ 22 files changed, 1820 insertions(+) create mode 100644 Info.plist create mode 100644 LICENCE create mode 100755 build.sh create mode 100644 core/Cargo.toml create mode 100644 core/build.rs create mode 100644 core/cbindgen.toml create mode 100644 core/include/swiftly.h create mode 100644 core/src/doc.rs create mode 100644 core/src/document.rs create mode 100644 core/src/eval.rs create mode 100644 core/src/ffi.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/persist.rs create mode 100755 debug.sh create mode 100644 src/AppDelegate.swift create mode 100644 src/AppState.swift create mode 100644 src/ContentView.swift create mode 100644 src/EditorView.swift create mode 100644 src/RustBridge.swift create mode 100644 src/SidebarView.swift create mode 100644 src/Theme.swift create mode 100644 src/main.swift diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..eb43895 --- /dev/null +++ b/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleExecutable + Swiftly + CFBundleIdentifier + org.else-if.swiftly + CFBundleName + Swiftly + CFBundleDisplayName + Swiftly + CFBundlePackageType + APPL + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.developer-tools + CFBundleIconFile + AppIcon + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..a38c8a4 --- /dev/null +++ b/LICENCE @@ -0,0 +1,12 @@ +This is free to use, without conditions. + +There is no licence here on purpose. Individuals, students, hobbyists — take what +you need, make it yours, don't think twice. You'd flatter me. + +The absence of a licence is deliberate. A licence is a legal surface. Words can be +reinterpreted, and corporations employ lawyers whose job is exactly that. Silence is +harder to exploit than language. If a company wants to use this, the lack of explicit +permission makes it just inconvenient enough to matter. + +This won't change the balance of power. But it shifts the weight, even slightly, away +from the system that co-opts open work for closed profit. That's enough for me. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..1de08fe --- /dev/null +++ b/build.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="$ROOT/build" +APP="$BUILD/bin/Swiftly.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +SDK=$(xcrun --show-sdk-path) + +RUST_LIB="$ROOT/core/target/release" +echo "Building Rust core (release)..." +cd "$ROOT/core" && cargo build --release +cd "$ROOT" +RUST_FLAGS="-import-objc-header $ROOT/core/include/swiftly.h -L $RUST_LIB -lswiftly_core" + +# --- App icon from SVG --- +SVG="$ROOT/static/vectors/icon.svg" +if [ -f "$SVG" ]; then + echo "Generating app icon..." + ICONSET="$BUILD/AppIcon.iconset" + mkdir -p "$ICONSET" + for SIZE in 16 32 64 128 256 512; do + rsvg-convert -w $SIZE -h $SIZE "$SVG" -o "$ICONSET/icon_${SIZE}x${SIZE}.png" + done + for SIZE in 16 32 128 256; do + DOUBLE=$((SIZE * 2)) + rsvg-convert -w $DOUBLE -h $DOUBLE "$SVG" -o "$ICONSET/icon_${SIZE}x${SIZE}@2x.png" + done + rsvg-convert -w 1024 -h 1024 "$SVG" -o "$ICONSET/icon_512x512@2x.png" + iconutil -c icns "$ICONSET" -o "$BUILD/AppIcon.icns" + rm -rf "$ICONSET" +fi + +# --- Bundle structure --- +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" +if [ -f "$BUILD/AppIcon.icns" ]; then + cp "$BUILD/AppIcon.icns" "$RESOURCES/AppIcon.icns" +fi + +# --- Compile Swift --- +echo "Compiling Swift (release)..." +swiftc \ + -target arm64-apple-macosx14.0 \ + -sdk "$SDK" \ + $RUST_FLAGS \ + -framework Cocoa \ + -framework SwiftUI \ + -O \ + -o "$MACOS/Swiftly" \ + "$ROOT"/src/*.swift + +# --- Code sign --- +codesign --force --deep --sign - "$APP" + +echo "Built: $APP" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..a7849c2 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "swiftly-core" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +cord-expr = { path = "../../Cord/crates/cord-expr" } +cord-trig = { path = "../../Cord/crates/cord-trig" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } + +[build-dependencies] +cbindgen = "0.27" + +[profile.release] +panic = "abort" diff --git a/core/build.rs b/core/build.rs new file mode 100644 index 0000000..baf8f64 --- /dev/null +++ b/core/build.rs @@ -0,0 +1,21 @@ +fn main() { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config = cbindgen::Config::from_file("cbindgen.toml") + .unwrap_or_default(); + + match cbindgen::Builder::new() + .with_crate(&crate_dir) + .with_config(config) + .generate() + { + Ok(bindings) => { + let path = format!("{}/include/swiftly.h", crate_dir); + bindings.write_to_file(&path); + println!("cargo:warning=cbindgen: wrote {}", path); + } + Err(e) => { + println!("cargo:warning=cbindgen: {}", e); + } + } +} diff --git a/core/cbindgen.toml b/core/cbindgen.toml new file mode 100644 index 0000000..ee86b11 --- /dev/null +++ b/core/cbindgen.toml @@ -0,0 +1,17 @@ +language = "C" +header = "/* Generated by cbindgen — do not edit */" +include_guard = "SWIFTLY_H" +include_version = false +tab_width = 4 +documentation = false +style = "both" + +[export] +include = [] +exclude = [] + +[fn] +prefix = "" + +[parse] +parse_deps = false diff --git a/core/include/swiftly.h b/core/include/swiftly.h new file mode 100644 index 0000000..8cacafe --- /dev/null +++ b/core/include/swiftly.h @@ -0,0 +1,37 @@ +/* Generated by cbindgen — do not edit */ + +#ifndef SWIFTLY_H +#define SWIFTLY_H + +#include +#include +#include +#include + +typedef struct SwiftlyDoc SwiftlyDoc; + + struct SwiftlyDoc *swiftly_doc_new(void); + + void swiftly_doc_free(struct SwiftlyDoc *doc); + + void swiftly_doc_set_text(struct SwiftlyDoc *doc, const char *text); + + char *swiftly_doc_get_text(const struct SwiftlyDoc *doc); + + char *swiftly_doc_evaluate(struct SwiftlyDoc *doc); + + char *swiftly_eval_line(const char *text); + + bool swiftly_doc_save(const struct SwiftlyDoc *doc, const char *path); + + struct SwiftlyDoc *swiftly_doc_load(const char *path); + + char *swiftly_cache_save(const struct SwiftlyDoc *doc); + + struct SwiftlyDoc *swiftly_cache_load(const char *uuid); + + char *swiftly_list_notes(void); + + void swiftly_free_string(char *s); + +#endif /* SWIFTLY_H */ diff --git a/core/src/doc.rs b/core/src/doc.rs new file mode 100644 index 0000000..6f91415 --- /dev/null +++ b/core/src/doc.rs @@ -0,0 +1,135 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum LineKind { + Markdown, + Cordial, + Eval, + Comment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassifiedLine { + pub index: usize, + pub kind: LineKind, + pub content: String, +} + +pub fn classify_line(index: usize, raw: &str) -> ClassifiedLine { + let trimmed = raw.trim(); + + let kind = if trimmed.starts_with("/=") { + LineKind::Eval + } else if trimmed.starts_with("//") { + LineKind::Comment + } else if is_cordial(trimmed) { + LineKind::Cordial + } else { + LineKind::Markdown + }; + + ClassifiedLine { + index, + kind, + content: raw.to_string(), + } +} + +fn is_cordial(line: &str) -> bool { + if line.starts_with("let ") { + return true; + } + + // variable assignment: identifier = expr (but not ==) + if let Some(eq_pos) = line.find('=') { + if eq_pos > 0 { + let before = &line[..eq_pos]; + let after_eq = line.as_bytes().get(eq_pos + 1); + if after_eq != Some(&b'=') && !before.ends_with('!') && !before.ends_with('<') && !before.ends_with('>') { + let candidate = before.trim(); + if is_assignment_target(candidate) { + return true; + } + } + } + } + + false +} + +fn is_assignment_target(s: &str) -> bool { + // simple variable: `x` + if is_ident(s) { + return true; + } + // function def: `f(x)` or `f(x, y)` + if let Some(paren) = s.find('(') { + let name = &s[..paren]; + if is_ident(name) && s.ends_with(')') { + return true; + } + } + false +} + +fn is_ident(s: &str) -> bool { + if s.is_empty() { return false; } + let mut chars = s.chars(); + let first = chars.next().unwrap(); + if !first.is_alphabetic() && first != '_' { return false; } + chars.all(|c| c.is_alphanumeric() || c == '_') +} + +pub fn classify_document(text: &str) -> Vec { + text.lines() + .enumerate() + .map(|(i, line)| classify_line(i, line)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markdown_line() { + let c = classify_line(0, "# Hello World"); + assert_eq!(c.kind, LineKind::Markdown); + } + + #[test] + fn eval_line() { + let c = classify_line(0, "/= 2 + 3"); + assert_eq!(c.kind, LineKind::Eval); + } + + #[test] + fn comment_line() { + let c = classify_line(0, "// this is a comment"); + assert_eq!(c.kind, LineKind::Comment); + } + + #[test] + fn let_binding() { + let c = classify_line(0, "let x = 5"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn variable_assignment() { + let c = classify_line(0, "x = 5"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn function_def() { + let c = classify_line(0, "f(x) = x^2"); + assert_eq!(c.kind, LineKind::Cordial); + } + + #[test] + fn plain_text() { + let c = classify_line(0, "Some notes about the project"); + assert_eq!(c.kind, LineKind::Markdown); + } +} diff --git a/core/src/document.rs b/core/src/document.rs new file mode 100644 index 0000000..99d22ab --- /dev/null +++ b/core/src/document.rs @@ -0,0 +1,39 @@ +use crate::doc::{classify_document, ClassifiedLine}; +use crate::eval::{evaluate_document, DocumentResult}; + +pub struct SwiftlyDoc { + pub text: String, + pub uuid: String, + lines: Vec, +} + +impl SwiftlyDoc { + pub fn new() -> Self { + SwiftlyDoc { + text: String::new(), + uuid: uuid::Uuid::new_v4().to_string(), + lines: Vec::new(), + } + } + + pub fn with_uuid(uuid: String) -> Self { + SwiftlyDoc { + text: String::new(), + uuid, + lines: Vec::new(), + } + } + + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + self.lines = classify_document(text); + } + + pub fn classified_lines(&self) -> &[ClassifiedLine] { + &self.lines + } + + pub fn evaluate(&self) -> DocumentResult { + evaluate_document(&self.text) + } +} diff --git a/core/src/eval.rs b/core/src/eval.rs new file mode 100644 index 0000000..bf0d7fa --- /dev/null +++ b/core/src/eval.rs @@ -0,0 +1,147 @@ +use serde::Serialize; +use crate::doc::{classify_document, LineKind}; + +#[derive(Debug, Clone, Serialize)] +pub struct EvalResult { + pub line: usize, + pub result: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EvalError { + pub line: usize, + pub error: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DocumentResult { + pub results: Vec, + pub errors: Vec, +} + +pub fn evaluate_document(text: &str) -> DocumentResult { + let classified = classify_document(text); + let mut cordial_lines: Vec = Vec::new(); + let mut results = Vec::new(); + let mut errors = Vec::new(); + + for cl in &classified { + match cl.kind { + LineKind::Cordial => { + cordial_lines.push(cl.content.clone()); + } + LineKind::Eval => { + let expr = cl.content.trim().strip_prefix("/=").unwrap_or("").trim(); + if expr.is_empty() { + errors.push(EvalError { + line: cl.index, + error: "empty expression".into(), + }); + continue; + } + + let mut eval_program = cordial_lines.clone(); + eval_program.push(expr.to_string()); + let program = eval_program.join("\n"); + + match cord_expr::parse_expr(&program) { + Ok(graph) => { + let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0); + results.push(EvalResult { + line: cl.index, + result: format_value(val), + }); + } + Err(e) => { + errors.push(EvalError { + line: cl.index, + error: e, + }); + } + } + } + LineKind::Comment | LineKind::Markdown => {} + } + } + + DocumentResult { results, errors } +} + +pub fn evaluate_line(text: &str) -> Result { + let graph = cord_expr::parse_expr(text)?; + let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0); + Ok(format_value(val)) +} + +fn format_value(val: f64) -> String { + if val == val.trunc() && val.abs() < 1e15 { + format!("{}", val as i64) + } else { + let s = format!("{:.10}", val); + let s = s.trim_end_matches('0'); + let s = s.trim_end_matches('.'); + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_eval() { + let result = evaluate_line("2 + 3").unwrap(); + assert_eq!(result, "5"); + } + + #[test] + fn eval_with_variables() { + let doc = "let a = 5\nlet b = 3\n/= a + b"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "8"); + assert_eq!(result.results[0].line, 2); + } + + #[test] + fn eval_with_markdown() { + let doc = "# Title\nlet val = 10\nSome text\n/= val * 2"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "20"); + } + + #[test] + fn eval_trig() { + let result = evaluate_line("sin(0)").unwrap(); + assert_eq!(result, "0"); + } + + #[test] + fn eval_function_def() { + let doc = "f(a) = a * a\n/= f(5)"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].result, "25"); + } + + #[test] + fn multiple_evals() { + let doc = "let a = 3\n/= a\nlet b = 7\n/= a + b"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 2); + assert_eq!(result.results[0].result, "3"); + assert_eq!(result.results[1].result, "10"); + } + + #[test] + fn format_integer() { + assert_eq!(format_value(42.0), "42"); + } + + #[test] + fn format_float() { + let s = format_value(3.14); + assert!(s.starts_with("3.14")); + } +} diff --git a/core/src/ffi.rs b/core/src/ffi.rs new file mode 100644 index 0000000..f9c5ead --- /dev/null +++ b/core/src/ffi.rs @@ -0,0 +1,143 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::path::Path; + +use crate::document::SwiftlyDoc; +use crate::eval; +use crate::persist; + +fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> { + if ptr.is_null() { return None; } + unsafe { CStr::from_ptr(ptr).to_str().ok() } +} + +fn str_to_cstr(s: &str) -> *mut c_char { + CString::new(s).unwrap_or_default().into_raw() +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_new() -> *mut SwiftlyDoc { + Box::into_raw(Box::new(SwiftlyDoc::new())) +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_free(doc: *mut SwiftlyDoc) { + if doc.is_null() { return; } + unsafe { drop(Box::from_raw(doc)); } +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_set_text(doc: *mut SwiftlyDoc, text: *const c_char) { + let doc = match unsafe { doc.as_mut() } { + Some(d) => d, + None => return, + }; + let text = match cstr_to_str(text) { + Some(s) => s, + None => return, + }; + doc.set_text(text); +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_get_text(doc: *const SwiftlyDoc) -> *mut c_char { + let doc = match unsafe { doc.as_ref() } { + Some(d) => d, + None => return std::ptr::null_mut(), + }; + str_to_cstr(&doc.text) +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_evaluate(doc: *mut SwiftlyDoc) -> *mut c_char { + let doc = match unsafe { doc.as_mut() } { + Some(d) => d, + None => return str_to_cstr("[]"), + }; + let result = doc.evaluate(); + let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".into()); + str_to_cstr(&json) +} + +#[no_mangle] +pub extern "C" fn swiftly_eval_line(text: *const c_char) -> *mut c_char { + let text = match cstr_to_str(text) { + Some(s) => s, + None => return str_to_cstr(""), + }; + match eval::evaluate_line(text) { + Ok(result) => str_to_cstr(&result), + Err(e) => str_to_cstr(&format!("error: {}", e)), + } +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_save(doc: *const SwiftlyDoc, path: *const c_char) -> bool { + let doc = match unsafe { doc.as_ref() } { + Some(d) => d, + None => return false, + }; + let path = match cstr_to_str(path) { + Some(s) => s, + None => return false, + }; + persist::save_to_file(&doc.text, Path::new(path)).is_ok() +} + +#[no_mangle] +pub extern "C" fn swiftly_doc_load(path: *const c_char) -> *mut SwiftlyDoc { + let path = match cstr_to_str(path) { + Some(s) => s, + None => return std::ptr::null_mut(), + }; + match persist::load_from_file(Path::new(path)) { + Ok(text) => { + let mut doc = SwiftlyDoc::new(); + doc.set_text(&text); + Box::into_raw(Box::new(doc)) + } + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn swiftly_cache_save(doc: *const SwiftlyDoc) -> *mut c_char { + let doc = match unsafe { doc.as_ref() } { + Some(d) => d, + None => return std::ptr::null_mut(), + }; + let uuid = doc.uuid.clone(); + match persist::cache_save(&uuid, &doc.text) { + Ok(_) => str_to_cstr(&uuid), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn swiftly_cache_load(uuid: *const c_char) -> *mut SwiftlyDoc { + let uuid = match cstr_to_str(uuid) { + Some(s) => s, + None => return std::ptr::null_mut(), + }; + match persist::cache_load(uuid) { + Ok(text) => { + let mut doc = SwiftlyDoc::with_uuid(uuid.to_string()); + doc.set_text(&text); + Box::into_raw(Box::new(doc)) + } + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn swiftly_list_notes() -> *mut c_char { + let notes = persist::list_notes(); + let json = serde_json::to_string(¬es).unwrap_or_else(|_| "[]".into()); + str_to_cstr(&json) +} + +#[no_mangle] +pub extern "C" fn swiftly_free_string(s: *mut c_char) { + if s.is_null() { return; } + unsafe { drop(CString::from_raw(s)); } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..e76e8dc --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,5 @@ +pub mod doc; +pub mod document; +pub mod eval; +pub mod persist; +pub mod ffi; diff --git a/core/src/persist.rs b/core/src/persist.rs new file mode 100644 index 0000000..3642a0c --- /dev/null +++ b/core/src/persist.rs @@ -0,0 +1,168 @@ +use serde::{Serialize, Deserialize}; +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteMeta { + pub uuid: String, + pub title: String, + pub path: String, + pub modified: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateIndex { + pub notes: HashMap, +} + +impl StateIndex { + pub fn new() -> Self { + StateIndex { + notes: HashMap::new(), + } + } + + pub fn load() -> io::Result { + let path = state_path(); + if !path.exists() { + return Ok(Self::new()); + } + let data = fs::read_to_string(&path)?; + serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } + + pub fn save(&self) -> io::Result<()> { + let path = state_path(); + ensure_dir(path.parent().unwrap())?; + let data = serde_json::to_string_pretty(self)?; + fs::write(&path, data) + } + + pub fn upsert(&mut self, meta: NoteMeta) { + self.notes.insert(meta.uuid.clone(), meta); + } + + pub fn remove(&mut self, uuid: &str) { + self.notes.remove(uuid); + } + + pub fn list(&self) -> Vec<&NoteMeta> { + let mut notes: Vec<&NoteMeta> = self.notes.values().collect(); + notes.sort_by(|a, b| b.modified.cmp(&a.modified)); + notes + } +} + +fn swiftly_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".swiftly") +} + +fn cache_dir() -> PathBuf { + swiftly_dir().join("cache") +} + +fn state_path() -> PathBuf { + swiftly_dir().join("state.json") +} + +fn ensure_dir(dir: &Path) -> io::Result<()> { + if !dir.exists() { + fs::create_dir_all(dir)?; + } + Ok(()) +} + +fn now_epoch() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn title_from_text(text: &str) -> String { + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { continue; } + let title = trimmed.trim_start_matches('#').trim(); + if !title.is_empty() { + return title.chars().take(80).collect(); + } + } + "Untitled".into() +} + +pub fn save_to_file(text: &str, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + ensure_dir(parent)?; + } + fs::write(path, text) +} + +pub fn load_from_file(path: &Path) -> io::Result { + fs::read_to_string(path) +} + +pub fn cache_save(uuid: &str, text: &str) -> io::Result { + let dir = cache_dir(); + ensure_dir(&dir)?; + + let filename = format!("{}.sw", uuid); + let path = dir.join(&filename); + fs::write(&path, text)?; + + let mut index = StateIndex::load().unwrap_or_else(|_| StateIndex::new()); + index.upsert(NoteMeta { + uuid: uuid.to_string(), + title: title_from_text(text), + path: path.to_string_lossy().into_owned(), + modified: now_epoch(), + }); + index.save()?; + + Ok(path) +} + +pub fn cache_load(uuid: &str) -> io::Result { + let filename = format!("{}.sw", uuid); + let path = cache_dir().join(filename); + fs::read_to_string(path) +} + +pub fn list_notes() -> Vec { + StateIndex::load() + .unwrap_or_else(|_| StateIndex::new()) + .list() + .into_iter() + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn title_extraction() { + assert_eq!(title_from_text("# My Note\nSome content"), "My Note"); + assert_eq!(title_from_text("Hello world"), "Hello world"); + assert_eq!(title_from_text(""), "Untitled"); + assert_eq!(title_from_text("\n\n## Section\nstuff"), "Section"); + } + + #[test] + fn state_index_round_trip() { + let mut idx = StateIndex::new(); + idx.upsert(NoteMeta { + uuid: "abc".into(), + title: "Test".into(), + path: "/tmp/test.sw".into(), + modified: 1000, + }); + assert_eq!(idx.list().len(), 1); + idx.remove("abc"); + assert_eq!(idx.list().len(), 0); + } +} diff --git a/debug.sh b/debug.sh new file mode 100755 index 0000000..e8dc375 --- /dev/null +++ b/debug.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BUILD="$ROOT/build" +APP="$BUILD/bin/Swiftly.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" +LOGFILE="$HOME/swiftly/debug.log" + +SDK=$(xcrun --show-sdk-path) + +# --- Rust core (re-enable when backend is ready) --- +# RUST_LIB="$ROOT/core/target/debug" +# if [ -d "$ROOT/core" ]; then +# echo "Building Rust core (debug)..." +# cd "$ROOT/core" && cargo build +# cd "$ROOT" +# RUST_FLAGS="-import-objc-header $ROOT/core/include/swiftly.h -L $RUST_LIB -lswiftly_core" +# fi +RUST_FLAGS="" + +# --- Bundle structure --- +mkdir -p "$MACOS" "$RESOURCES" +cp "$ROOT/Info.plist" "$CONTENTS/Info.plist" + +# --- Compile Swift (debug) --- +echo "Compiling Swift (debug)..." +swiftc \ + -target arm64-apple-macosx14.0 \ + -sdk "$SDK" \ + $RUST_FLAGS \ + -framework Cocoa \ + -framework SwiftUI \ + -Onone -g \ + -o "$MACOS/Swiftly" \ + "$ROOT"/src/*.swift + +# --- Code sign --- +codesign --force --deep --sign - "$APP" + +# --- Kill existing, launch, tail log --- +pkill -f "Swiftly.app" 2>/dev/null || true +sleep 0.5 + +mkdir -p "$(dirname "$LOGFILE")" +open "$APP" + +echo "Swiftly launched." +echo "Tail log: tail -f $LOGFILE" diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift new file mode 100644 index 0000000..f84d6f7 --- /dev/null +++ b/src/AppDelegate.swift @@ -0,0 +1,163 @@ +import Cocoa +import SwiftUI + +class AppDelegate: NSObject, NSApplicationDelegate { + var window: NSWindow! + var appState: AppState! + + func applicationDidFinishLaunching(_ notification: Notification) { + appState = AppState() + + let contentView = ContentView(state: appState) + let hostingView = NSHostingView(rootView: contentView) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Swiftly" + window.contentView = hostingView + window.center() + window.setFrameAutosaveName("SwiftlyMainWindow") + window.makeKeyAndOrderFront(nil) + + setupMenuBar() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + func applicationWillTerminate(_ notification: Notification) { + appState.saveNote() + } + + // MARK: - Menu bar + + private func setupMenuBar() { + let mainMenu = NSMenu() + + mainMenu.addItem(buildAppMenu()) + mainMenu.addItem(buildFileMenu()) + mainMenu.addItem(buildEditMenu()) + mainMenu.addItem(buildViewMenu()) + mainMenu.addItem(buildWindowMenu()) + + NSApp.mainMenu = mainMenu + } + + private func buildAppMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu() + menu.addItem(withTitle: "About Swiftly", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit Swiftly", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + item.submenu = menu + return item + } + + private func buildFileMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "File") + + let newItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "n") + newItem.target = self + menu.addItem(newItem) + + let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o") + openItem.target = self + menu.addItem(openItem) + + menu.addItem(.separator()) + + let saveItem = NSMenuItem(title: "Save", action: #selector(saveNote), keyEquivalent: "s") + saveItem.target = self + menu.addItem(saveItem) + + let saveAsItem = NSMenuItem(title: "Save As...", action: #selector(saveNoteAs), keyEquivalent: "S") + saveAsItem.target = self + menu.addItem(saveAsItem) + + item.submenu = menu + return item + } + + private func buildEditMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Edit") + menu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + menu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + menu.addItem(.separator()) + menu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + menu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + menu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + menu.addItem(.separator()) + + let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f") + findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue) + menu.addItem(findItem) + + item.submenu = menu + return item + } + + private func buildViewMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "View") + let toggleItem = NSMenuItem(title: "Toggle Sidebar", action: #selector(toggleSidebar), keyEquivalent: "\\") + toggleItem.keyEquivalentModifierMask = .command + toggleItem.target = self + menu.addItem(toggleItem) + item.submenu = menu + return item + } + + private func buildWindowMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Window") + menu.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") + menu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "") + item.submenu = menu + NSApp.windowsMenu = menu + return item + } + + // MARK: - Actions + + @objc private func newNote() { + appState.newNote() + } + + @objc private func openNote() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.plainText] + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.appState.loadNoteFromFile(url) + } + } + + @objc private func saveNote() { + appState.saveNote() + } + + @objc private func saveNoteAs() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.plainText] + panel.nameFieldStringValue = "note.txt" + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.appState.saveNoteToFile(url) + } + } + + @objc private func toggleSidebar() { + NotificationCenter.default.post(name: .toggleSidebar, object: nil) + } +} diff --git a/src/AppState.swift b/src/AppState.swift new file mode 100644 index 0000000..55252db --- /dev/null +++ b/src/AppState.swift @@ -0,0 +1,90 @@ +import Foundation +import Combine + +class AppState: ObservableObject { + @Published var documentText: String = "" { + didSet { + if documentText != oldValue { + modified = true + bridge.setText(currentNoteID, text: documentText) + } + } + } + @Published var evalResults: [Int: String] = [:] + @Published var noteList: [NoteInfo] = [] + @Published var currentNoteID: UUID + @Published var modified: Bool = false + + private let bridge = RustBridge.shared + + init() { + let id = bridge.newDocument() + self.currentNoteID = id + refreshNoteList() + } + + func newNote() { + saveCurrentIfNeeded() + let id = bridge.newDocument() + currentNoteID = id + documentText = "" + evalResults = [:] + modified = false + refreshNoteList() + } + + func loadNote(_ id: UUID) { + saveCurrentIfNeeded() + if bridge.cacheLoad(id) { + currentNoteID = id + documentText = bridge.getText(id) + modified = false + evaluate() + } + } + + func saveNote() { + bridge.setText(currentNoteID, text: documentText) + let _ = bridge.cacheSave(currentNoteID) + modified = false + refreshNoteList() + } + + func saveNoteToFile(_ url: URL) { + let _ = bridge.saveNote(currentNoteID, path: url.path) + modified = false + } + + func loadNoteFromFile(_ url: URL) { + if let (id, text) = bridge.loadNote(path: url.path) { + currentNoteID = id + documentText = text + modified = false + let _ = bridge.cacheSave(id) + evaluate() + refreshNoteList() + } + } + + func deleteNote(_ id: UUID) { + bridge.deleteNote(id) + if id == currentNoteID { + newNote() + } + refreshNoteList() + } + + func evaluate() { + evalResults = bridge.evaluate(currentNoteID) + } + + func refreshNoteList() { + noteList = bridge.listNotes() + } + + private func saveCurrentIfNeeded() { + if modified { + saveNote() + } + } +} diff --git a/src/ContentView.swift b/src/ContentView.swift new file mode 100644 index 0000000..b72ecc2 --- /dev/null +++ b/src/ContentView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct ContentView: View { + @ObservedObject var state: AppState + @State private var sidebarVisible: Bool = true + + var body: some View { + HSplitView { + if sidebarVisible { + SidebarView(state: state) + .frame(minWidth: 180, idealWidth: 250, maxWidth: 350) + } + EditorView(state: state) + .frame(minWidth: 400) + } + .frame(minWidth: 700, minHeight: 400) + .background(Color(ns: Theme.current.base)) + .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in + withAnimation { sidebarVisible.toggle() } + } + } +} + +extension Notification.Name { + static let toggleSidebar = Notification.Name("toggleSidebar") +} diff --git a/src/EditorView.swift b/src/EditorView.swift new file mode 100644 index 0000000..1845696 --- /dev/null +++ b/src/EditorView.swift @@ -0,0 +1,225 @@ +import SwiftUI +import AppKit + +struct EditorView: View { + @ObservedObject var state: AppState + + var body: some View { + EditorTextView(text: $state.documentText, evalResults: state.evalResults, onEvaluate: { + state.evaluate() + }) + .background(Color(ns: Theme.current.base)) + } +} + +struct EditorTextView: NSViewRepresentable { + @Binding var text: String + var evalResults: [Int: String] + var onEvaluate: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + + let textView = LineNumberTextView() + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + textView.isRichText = false + textView.usesFindBar = true + textView.isIncrementalSearchingEnabled = true + textView.font = Theme.editorFont + textView.textColor = Theme.current.text + textView.backgroundColor = Theme.current.base + textView.insertionPointColor = Theme.current.text + textView.selectedTextAttributes = [ + .backgroundColor: Theme.current.surface1 + ] + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.smartInsertDeleteEnabled = false + + textView.autoresizingMask = [.width] + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.containerSize = NSSize( + width: scrollView.contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.widthTracksTextView = true + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + + scrollView.documentView = textView + + let ruler = LineNumberRulerView(textView: textView) + ruler.evalResults = evalResults + scrollView.verticalRulerView = ruler + scrollView.hasVerticalRuler = true + scrollView.rulersVisible = true + + textView.string = text + textView.delegate = context.coordinator + context.coordinator.textView = textView + context.coordinator.rulerView = ruler + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? LineNumberTextView else { return } + if textView.string != text { + let selectedRanges = textView.selectedRanges + textView.string = text + textView.selectedRanges = selectedRanges + } + textView.font = Theme.editorFont + textView.textColor = Theme.current.text + textView.backgroundColor = Theme.current.base + textView.insertionPointColor = Theme.current.text + + if let ruler = scrollView.verticalRulerView as? LineNumberRulerView { + ruler.evalResults = evalResults + ruler.needsDisplay = true + } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: EditorTextView + weak var textView: NSTextView? + weak var rulerView: LineNumberRulerView? + + init(_ parent: EditorTextView) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let tv = textView else { return } + parent.text = tv.string + rulerView?.needsDisplay = true + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + textView.insertNewlineIgnoringFieldEditor(nil) + DispatchQueue.main.async { [weak self] in + self?.parent.onEvaluate() + } + return true + } + return false + } + } +} + +class LineNumberTextView: NSTextView { + override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { + var widened = rect + widened.size.width = 2 + super.drawInsertionPoint(in: widened, color: color, turnedOn: flag) + } +} + +class LineNumberRulerView: NSRulerView { + var evalResults: [Int: String] = [:] + + private weak var editorTextView: NSTextView? + + init(textView: NSTextView) { + self.editorTextView = textView + super.init(scrollView: textView.enclosingScrollView!, orientation: .verticalRuler) + self.clientView = textView + self.ruleThickness = 50 + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: textView + ) + } + + required init(coder: NSCoder) { + fatalError() + } + + @objc private func textDidChange(_ notification: Notification) { + needsDisplay = true + } + + override func drawHashMarksAndLabels(in rect: NSRect) { + guard let tv = editorTextView, + let layoutManager = tv.layoutManager, + let textContainer = tv.textContainer else { return } + + let palette = Theme.current + + palette.mantle.setFill() + rect.fill() + + let visibleRect = scrollView!.contentView.bounds + let visibleGlyphRange = layoutManager.glyphRange( + forBoundingRect: visibleRect, in: textContainer + ) + let visibleCharRange = layoutManager.characterRange( + forGlyphRange: visibleGlyphRange, actualGlyphRange: nil + ) + + let text = tv.string as NSString + var lineNumber = 1 + var index = 0 + while index < visibleCharRange.location { + if text.character(at: index) == 0x0A { lineNumber += 1 } + index += 1 + } + + let attrs: [NSAttributedString.Key: Any] = [ + .font: Theme.gutterFont, + .foregroundColor: palette.overlay0 + ] + let resultAttrs: [NSAttributedString.Key: Any] = [ + .font: Theme.gutterFont, + .foregroundColor: palette.teal + ] + + var charIndex = visibleCharRange.location + while charIndex < NSMaxRange(visibleCharRange) { + let lineRange = text.lineRange(for: NSRange(location: charIndex, length: 0)) + let glyphRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) + var lineRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + lineRect.origin.y += tv.textContainerInset.height - visibleRect.origin.y + + if let result = evalResults[lineNumber - 1] { + let resultStr = NSAttributedString(string: result, attributes: resultAttrs) + let resultSize = resultStr.size() + let resultPoint = NSPoint( + x: ruleThickness - resultSize.width - 4, + y: lineRect.origin.y + ) + resultStr.draw(at: resultPoint) + } else { + let numStr = NSAttributedString(string: "\(lineNumber)", attributes: attrs) + let size = numStr.size() + let point = NSPoint( + x: ruleThickness - size.width - 8, + y: lineRect.origin.y + ) + numStr.draw(at: point) + } + + lineNumber += 1 + charIndex = NSMaxRange(lineRange) + } + } +} diff --git a/src/RustBridge.swift b/src/RustBridge.swift new file mode 100644 index 0000000..9b5427e --- /dev/null +++ b/src/RustBridge.swift @@ -0,0 +1,208 @@ +import Foundation + +struct NoteInfo: Identifiable { + let id: UUID + var title: String + var lastModified: Date +} + +class RustBridge { + static let shared = RustBridge() + + private var notes: [UUID: String] = [:] + private var noteMeta: [UUID: NoteInfo] = [:] + + private init() {} + + // MARK: - Document operations (stubbed) + + func newDocument() -> UUID { + let id = UUID() + notes[id] = "" + let info = NoteInfo(id: id, title: "Untitled", lastModified: Date()) + noteMeta[id] = info + return id + } + + func freeDocument(_ id: UUID) { + notes.removeValue(forKey: id) + noteMeta.removeValue(forKey: id) + } + + func setText(_ id: UUID, text: String) { + notes[id] = text + if var info = noteMeta[id] { + info.title = titleFromText(text) + info.lastModified = Date() + noteMeta[id] = info + } + } + + func getText(_ id: UUID) -> String { + return notes[id] ?? "" + } + + func evaluate(_ id: UUID) -> [Int: String] { + // Stub: parse lines for /= prefix, return placeholder results + guard let text = notes[id] else { return [:] } + var results: [Int: String] = [:] + let lines = text.components(separatedBy: "\n") + for (i, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("/=") { + let expr = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + results[i] = stubbedEval(expr) + } + } + return results + } + + func evaluateLine(_ line: String) -> String { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("/=") { + let expr = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + return stubbedEval(expr) + } + return "" + } + + // MARK: - File I/O (stubbed with UserDefaults) + + func saveNote(_ id: UUID, path: String) -> Bool { + guard let text = notes[id] else { return false } + do { + try text.write(toFile: path, atomically: true, encoding: .utf8) + return true + } catch { + return false + } + } + + func loadNote(path: String) -> (UUID, String)? { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { + return nil + } + let id = UUID() + notes[id] = text + let info = NoteInfo(id: id, title: titleFromText(text), lastModified: Date()) + noteMeta[id] = info + return (id, text) + } + + // MARK: - Cache (stubbed with local storage) + + func cacheDir() -> URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("Swiftly", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + func cacheSave(_ id: UUID) -> Bool { + guard let text = notes[id], let info = noteMeta[id] else { return false } + let dir = cacheDir().appendingPathComponent(id.uuidString, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let textFile = dir.appendingPathComponent("content.txt") + let metaFile = dir.appendingPathComponent("meta.json") + do { + try text.write(to: textFile, atomically: true, encoding: .utf8) + let meta: [String: String] = [ + "title": info.title, + "lastModified": ISO8601DateFormatter().string(from: info.lastModified) + ] + let data = try JSONSerialization.data(withJSONObject: meta) + try data.write(to: metaFile, options: .atomic) + return true + } catch { + return false + } + } + + func cacheLoad(_ id: UUID) -> Bool { + let dir = cacheDir().appendingPathComponent(id.uuidString, isDirectory: true) + let textFile = dir.appendingPathComponent("content.txt") + let metaFile = dir.appendingPathComponent("meta.json") + guard let text = try? String(contentsOf: textFile, encoding: .utf8), + let metaData = try? Data(contentsOf: metaFile), + let meta = try? JSONSerialization.jsonObject(with: metaData) as? [String: String] else { + return false + } + notes[id] = text + let formatter = ISO8601DateFormatter() + let date = formatter.date(from: meta["lastModified"] ?? "") ?? Date() + noteMeta[id] = NoteInfo(id: id, title: meta["title"] ?? "Untitled", lastModified: date) + return true + } + + func listNotes() -> [NoteInfo] { + let dir = cacheDir() + guard let entries = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { + return [] + } + var result: [NoteInfo] = [] + let formatter = ISO8601DateFormatter() + for entry in entries { + guard entry.hasDirectoryPath, + let uuid = UUID(uuidString: entry.lastPathComponent) else { continue } + let metaFile = entry.appendingPathComponent("meta.json") + guard let data = try? Data(contentsOf: metaFile), + let meta = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { continue } + let date = formatter.date(from: meta["lastModified"] ?? "") ?? Date() + let info = NoteInfo(id: uuid, title: meta["title"] ?? "Untitled", lastModified: date) + result.append(info) + if noteMeta[uuid] == nil { + noteMeta[uuid] = info + } + } + return result.sorted { $0.lastModified > $1.lastModified } + } + + func deleteNote(_ id: UUID) { + notes.removeValue(forKey: id) + noteMeta.removeValue(forKey: id) + let dir = cacheDir().appendingPathComponent(id.uuidString, isDirectory: true) + try? FileManager.default.removeItem(at: dir) + } + + // MARK: - Internal + + private func titleFromText(_ text: String) -> String { + let firstLine = text.components(separatedBy: "\n").first ?? "" + let trimmed = firstLine.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return "Untitled" } + let clean = trimmed.replacingOccurrences(of: "^#+\\s*", with: "", options: .regularExpression) + let maxLen = 60 + if clean.count > maxLen { + return String(clean.prefix(maxLen)) + "..." + } + return clean + } + + private func stubbedEval(_ expr: String) -> String { + // Stub: attempt basic arithmetic, otherwise return placeholder + let components = expr.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + if components.count == 3, + let a = Double(components[0]), + let b = Double(components[2]) { + let op = components[1] + switch op { + case "+": return formatNumber(a + b) + case "-": return formatNumber(a - b) + case "*": return formatNumber(a * b) + case "/": return b != 0 ? formatNumber(a / b) : "div/0" + default: break + } + } + if components.count == 1, let n = Double(components[0]) { + return formatNumber(n) + } + return "..." + } + + private func formatNumber(_ n: Double) -> String { + if n == n.rounded() && abs(n) < 1e15 { + return String(format: "%.0f", n) + } + return String(format: "%.6g", n) + } +} diff --git a/src/SidebarView.swift b/src/SidebarView.swift new file mode 100644 index 0000000..7674bc1 --- /dev/null +++ b/src/SidebarView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct SidebarView: View { + @ObservedObject var state: AppState + + private let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .short + return f + }() + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Notes") + .font(.headline) + .foregroundColor(Color(ns: Theme.current.text)) + Spacer() + Button(action: { state.newNote() }) { + Image(systemName: "plus") + .foregroundColor(Color(ns: Theme.current.text)) + } + .buttonStyle(.plain) + .help("New Note") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + if state.noteList.isEmpty { + VStack { + Spacer() + Text("No notes yet") + .foregroundColor(Color(ns: Theme.current.overlay1)) + .font(.system(size: 13)) + Spacer() + } + } else { + List(state.noteList) { note in + NoteRow( + note: note, + isSelected: note.id == state.currentNoteID, + dateFormatter: dateFormatter + ) + .contentShape(Rectangle()) + .onTapGesture { state.loadNote(note.id) } + .contextMenu { + Button("Delete") { state.deleteNote(note.id) } + } + .listRowBackground( + note.id == state.currentNoteID + ? Color(ns: Theme.current.surface1) + : Color.clear + ) + } + .listStyle(.plain) + } + } + .background(Color(ns: Theme.current.mantle)) + .onAppear { state.refreshNoteList() } + } +} + +struct NoteRow: View { + let note: NoteInfo + let isSelected: Bool + let dateFormatter: DateFormatter + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(note.title) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundColor(Color(ns: Theme.current.text)) + .lineLimit(1) + Text(dateFormatter.string(from: note.lastModified)) + .font(.system(size: 11)) + .foregroundColor(Color(ns: Theme.current.subtext0)) + } + .padding(.vertical, 2) + } +} diff --git a/src/Theme.swift b/src/Theme.swift new file mode 100644 index 0000000..c6d92a3 --- /dev/null +++ b/src/Theme.swift @@ -0,0 +1,131 @@ +import Cocoa +import SwiftUI + +struct CatppuccinPalette { + let base: NSColor + let mantle: NSColor + let crust: NSColor + let surface0: NSColor + let surface1: NSColor + let surface2: NSColor + let overlay0: NSColor + let overlay1: NSColor + let overlay2: NSColor + let text: NSColor + let subtext0: NSColor + let subtext1: NSColor + let red: NSColor + let maroon: NSColor + let peach: NSColor + let yellow: NSColor + let green: NSColor + let teal: NSColor + let sky: NSColor + let sapphire: NSColor + let blue: NSColor + let lavender: NSColor + let mauve: NSColor + let pink: NSColor + let flamingo: NSColor + let rosewater: NSColor +} + +struct Theme { + static let mocha = CatppuccinPalette( + base: NSColor(red: 0.118, green: 0.118, blue: 0.180, alpha: 1), + mantle: NSColor(red: 0.094, green: 0.094, blue: 0.149, alpha: 1), + crust: NSColor(red: 0.071, green: 0.071, blue: 0.118, alpha: 1), + surface0: NSColor(red: 0.188, green: 0.188, blue: 0.259, alpha: 1), + surface1: NSColor(red: 0.271, green: 0.271, blue: 0.353, alpha: 1), + surface2: NSColor(red: 0.353, green: 0.353, blue: 0.439, alpha: 1), + overlay0: NSColor(red: 0.427, green: 0.427, blue: 0.522, alpha: 1), + overlay1: NSColor(red: 0.506, green: 0.506, blue: 0.600, alpha: 1), + overlay2: NSColor(red: 0.584, green: 0.584, blue: 0.682, alpha: 1), + text: NSColor(red: 0.804, green: 0.839, blue: 0.957, alpha: 1), + subtext0: NSColor(red: 0.651, green: 0.686, blue: 0.820, alpha: 1), + subtext1: NSColor(red: 0.725, green: 0.761, blue: 0.886, alpha: 1), + red: NSColor(red: 0.953, green: 0.545, blue: 0.659, alpha: 1), + maroon: NSColor(red: 0.922, green: 0.600, blue: 0.659, alpha: 1), + peach: NSColor(red: 0.980, green: 0.702, blue: 0.529, alpha: 1), + yellow: NSColor(red: 0.976, green: 0.886, blue: 0.686, alpha: 1), + green: NSColor(red: 0.651, green: 0.890, blue: 0.631, alpha: 1), + teal: NSColor(red: 0.596, green: 0.878, blue: 0.816, alpha: 1), + sky: NSColor(red: 0.537, green: 0.863, blue: 0.922, alpha: 1), + sapphire: NSColor(red: 0.455, green: 0.784, blue: 0.890, alpha: 1), + blue: NSColor(red: 0.537, green: 0.706, blue: 0.980, alpha: 1), + lavender: NSColor(red: 0.710, green: 0.745, blue: 0.996, alpha: 1), + mauve: NSColor(red: 0.796, green: 0.651, blue: 0.969, alpha: 1), + pink: NSColor(red: 0.961, green: 0.710, blue: 0.898, alpha: 1), + flamingo: NSColor(red: 0.949, green: 0.710, blue: 0.765, alpha: 1), + rosewater: NSColor(red: 0.961, green: 0.761, blue: 0.765, alpha: 1) + ) + + static let latte = CatppuccinPalette( + base: NSColor(red: 0.937, green: 0.929, blue: 0.961, alpha: 1), + mantle: NSColor(red: 0.906, green: 0.898, blue: 0.941, alpha: 1), + crust: NSColor(red: 0.863, green: 0.855, blue: 0.910, alpha: 1), + surface0: NSColor(red: 0.800, green: 0.796, blue: 0.863, alpha: 1), + surface1: NSColor(red: 0.737, green: 0.733, blue: 0.816, alpha: 1), + surface2: NSColor(red: 0.667, green: 0.663, blue: 0.757, alpha: 1), + overlay0: NSColor(red: 0.604, green: 0.596, blue: 0.706, alpha: 1), + overlay1: NSColor(red: 0.533, green: 0.529, blue: 0.647, alpha: 1), + overlay2: NSColor(red: 0.467, green: 0.463, blue: 0.592, alpha: 1), + text: NSColor(red: 0.298, green: 0.286, blue: 0.416, alpha: 1), + subtext0: NSColor(red: 0.376, green: 0.365, blue: 0.494, alpha: 1), + subtext1: NSColor(red: 0.337, green: 0.325, blue: 0.455, alpha: 1), + red: NSColor(red: 0.822, green: 0.294, blue: 0.345, alpha: 1), + maroon: NSColor(red: 0.906, green: 0.345, blue: 0.388, alpha: 1), + peach: NSColor(red: 0.996, green: 0.541, blue: 0.243, alpha: 1), + yellow: NSColor(red: 0.875, green: 0.627, blue: 0.086, alpha: 1), + green: NSColor(red: 0.251, green: 0.624, blue: 0.247, alpha: 1), + teal: NSColor(red: 0.090, green: 0.604, blue: 0.502, alpha: 1), + sky: NSColor(red: 0.016, green: 0.639, blue: 0.757, alpha: 1), + sapphire: NSColor(red: 0.125, green: 0.561, blue: 0.737, alpha: 1), + blue: NSColor(red: 0.118, green: 0.404, blue: 0.878, alpha: 1), + lavender: NSColor(red: 0.451, green: 0.420, blue: 0.878, alpha: 1), + mauve: NSColor(red: 0.529, green: 0.329, blue: 0.890, alpha: 1), + pink: NSColor(red: 0.918, green: 0.341, blue: 0.604, alpha: 1), + flamingo: NSColor(red: 0.867, green: 0.369, blue: 0.424, alpha: 1), + rosewater: NSColor(red: 0.863, green: 0.443, blue: 0.439, alpha: 1) + ) + + static var current: CatppuccinPalette { + let appearance = NSApp.effectiveAppearance + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + return isDark ? mocha : latte + } + + static let editorFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + static let gutterFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + static let sidebarFont = NSFont.systemFont(ofSize: 13, weight: .regular) + static let sidebarDateFont = NSFont.systemFont(ofSize: 11, weight: .regular) + + struct SyntaxColors { + let keyword: NSColor + let number: NSColor + let string: NSColor + let comment: NSColor + let `operator`: NSColor + let function: NSColor + let result: NSColor + } + + static var syntax: SyntaxColors { + let p = current + return SyntaxColors( + keyword: p.mauve, + number: p.peach, + string: p.green, + comment: p.overlay1, + operator: p.sky, + function: p.blue, + result: p.teal + ) + } +} + +extension Color { + init(ns: NSColor) { + self.init(nsColor: ns) + } +} diff --git a/src/main.swift b/src/main.swift new file mode 100644 index 0000000..b811e37 --- /dev/null +++ b/src/main.swift @@ -0,0 +1,8 @@ +import Cocoa + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.activate(ignoringOtherApps: true) +app.run()