initial state
This commit is contained in:
commit
1f727923d4
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Swiftly</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.else-if.swiftly</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Swiftly</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Swiftly</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/* Generated by cbindgen — do not edit */
|
||||
|
||||
#ifndef SWIFTLY_H
|
||||
#define SWIFTLY_H
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
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 */
|
||||
|
|
@ -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<ClassifiedLine> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ClassifiedLine>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EvalResult>,
|
||||
pub errors: Vec<EvalError>,
|
||||
}
|
||||
|
||||
pub fn evaluate_document(text: &str) -> DocumentResult {
|
||||
let classified = classify_document(text);
|
||||
let mut cordial_lines: Vec<String> = 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<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)); }
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
pub mod doc;
|
||||
pub mod document;
|
||||
pub mod eval;
|
||||
pub mod persist;
|
||||
pub mod ffi;
|
||||
|
|
@ -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<String, NoteMeta>,
|
||||
}
|
||||
|
||||
impl StateIndex {
|
||||
pub fn new() -> Self {
|
||||
StateIndex {
|
||||
notes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> io::Result<Self> {
|
||||
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<String> {
|
||||
fs::read_to_string(path)
|
||||
}
|
||||
|
||||
pub fn cache_save(uuid: &str, text: &str) -> io::Result<PathBuf> {
|
||||
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<String> {
|
||||
let filename = format!("{}.sw", uuid);
|
||||
let path = cache_dir().join(filename);
|
||||
fs::read_to_string(path)
|
||||
}
|
||||
|
||||
pub fn list_notes() -> Vec<NoteMeta> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue