diff --git a/CORDIAL_GUIDE.md b/CORDIAL_GUIDE.md new file mode 100644 index 0000000..76664ff --- /dev/null +++ b/CORDIAL_GUIDE.md @@ -0,0 +1,781 @@ +# Cordial Language Reference Guide + +Cordial is a simple, expression-based programming language embedded in Swiftly. This guide documents every working feature. + +## Evaluation Prefix + +All evaluation in the editor uses the `/=` prefix: + +``` +/= 2 + 3 +``` + +Output: +``` +5 +``` + +## Types + +Cordial has five value types: + +- **number**: floating-point numbers (internally f64). Displayed as integers when they have no fractional part. +- **bool**: `true` or `false` +- **str**: strings in double quotes, with escape sequences (`\n`, `\t`, `\\`, `\"`) +- **array**: ordered collections of values +- **void**: the value returned by statements with no explicit value + +## Variables + +### Declaration + +Use `let` to declare and initialize a variable: + +``` +let x = 5 +/= x +``` + +Output: +``` +5 +``` + +### Type Annotations + +Optionally annotate the type after the variable name: + +``` +let x: int = 3.7 +/= x +``` + +Output: +``` +3 +``` + +Supported type annotations: +- `int`: truncates numbers to integers +- `float`: accepts any number (no coercion) +- `bool`: converts 0 to `false`, 1 to `true`; rejects other numbers +- `str`: only accepts strings + +``` +let b: bool = 1 +/= b +``` + +Output: +``` +true +``` + +### Assignment + +Reassign an existing variable without `let`: + +``` +let x = 5 +x = 10 +/= x +``` + +Output: +``` +10 +``` + +## Operators + +### Arithmetic + +- `+`: addition or string concatenation +- `-`: subtraction +- `*`: multiplication +- `/`: division (errors on division by zero) +- `%`: modulo +- `^`: exponentiation (right-associative) + +``` +/= 2 ^ 3 ^ 2 +``` + +Output: +``` +512 +``` + +(Evaluated as 2 ^ (3 ^ 2) = 2 ^ 9 = 512) + +### Comparison + +All comparison operators return booleans: + +- `<`: less than +- `>`: greater than +- `<=`: less than or equal +- `>=`: greater than or equal +- `==`: equality +- `!=`: inequality + +``` +/= 5 > 3 +``` + +Output: +``` +true +``` + +### Logical + +- `&&`: logical AND (short-circuits) +- `||`: logical OR (short-circuits) +- `!`: logical NOT + +``` +/= true && false +``` + +Output: +``` +false +``` + +``` +/= !true +``` + +Output: +``` +false +``` + +### Operator Precedence + +From lowest to highest: +1. `||` +2. `&&` +3. `==`, `!=`, `<`, `>`, `<=`, `>=` +4. `+`, `-` +5. `*`, `/`, `%` +6. `^` (right-associative) +7. unary `-`, `!` +8. function calls, array literals + +## Strings + +String literals use double quotes: + +``` +/= "hello" +``` + +Output: +``` +hello +``` + +Escape sequences: +- `\n`: newline +- `\t`: tab +- `\\`: backslash +- `\"`: double quote + +### String Concatenation + +Strings concatenate with the `+` operator. Numbers and booleans are automatically converted: + +``` +/= "value: " + 42 +``` + +Output: +``` +value: 42 +``` + +``` +/= 100 + " items" +``` + +Output: +``` +100 items +``` + +## Arrays + +### Literals + +Create arrays with square brackets: + +``` +/= [1, 2, 3] +``` + +Output: +``` +[1, 2, 3] +``` + +Arrays can contain mixed types: + +``` +/= [1, "two", true] +``` + +Output: +``` +[1, "two", true] +``` + +Empty arrays: + +``` +/= [] +``` + +Output: +``` +[] +``` + +### Indexing + +Access elements by index (zero-based): + +``` +let arr = [10, 20, 30] +/= arr[1] +``` + +Output: +``` +20 +``` + +Negative indices count from the end: + +``` +/= [10, 20, 30][-1] +``` + +Output: +``` +30 +``` + +Out-of-bounds access returns an error. + +### String Indexing + +Strings support the same index syntax, returning individual characters: + +``` +/= "hello"[0] +``` + +Output: +``` +h +``` + +## Functions + +### Definition + +Define functions with the `fn` keyword: + +``` +fn add(a, b) { + a + b +} +``` + +The last expression in the function body is returned implicitly. An explicit `return` statement is also available for early exit. + +``` +fn square(x) { + x * x +} +/= square(5) +``` + +Output: +``` +25 +``` + +### Scope + +Function parameters are local to the function. The function saves and restores the variable scope: + +``` +let x = 10 +fn modify() { + x = 5 +} +modify() +/= x +``` + +Output: +``` +10 +``` + +(x inside the function is a separate variable; the outer x is unchanged) + +### Recursion + +Functions can call themselves recursively, up to a maximum call depth of 256: + +``` +fn fib(n) { + let a = 0 + let b = 1 + let i = 0 + while (i < n) { + let tmp = b + b = a + b + a = tmp + i = i + 1 + } + a +} +/= fib(10) +``` + +Output: +``` +55 +``` + +## Control Flow + +### While Loops + +Loop while a condition is truthy: + +``` +let i = 0 +let sum = 0 +while (i < 10) { + sum = sum + i + i = i + 1 +} +/= sum +``` + +Output: +``` +45 +``` + +Parentheses around the condition are optional (but recommended for clarity). + +Loop safety: while loops are limited to 10,000 iterations to prevent infinite loops. + +### If/Else + +Conditional branching with optional else clause: + +``` +let x = 10 +if x > 5 { + x = 1 +} else { + x = 0 +} +/= x +``` + +Output: +``` +1 +``` + +Parentheses around the condition are optional. Else-if chaining works: + +``` +let grade = 85 +let letter = "F" +if grade >= 90 { + letter = "A" +} else if grade >= 80 { + letter = "B" +} else if grade >= 70 { + letter = "C" +} +/= letter +``` + +Output: +``` +B +``` + +### For Loops + +Iterate over arrays or ranges: + +``` +let sum = 0 +for x in [1, 2, 3, 4, 5] { + sum = sum + x +} +/= sum +``` + +Output: +``` +15 +``` + +Using a range expression (`start..end`, exclusive of end): + +``` +let sum = 0 +for i in 0..5 { + sum = sum + i +} +/= sum +``` + +Output: +``` +10 +``` + +The `range(start, end)` builtin function also works: + +``` +let sum = 0 +for i in range(1, 6) { + sum = sum + i +} +/= sum +``` + +Output: +``` +15 +``` + +For loops are limited to 10,000 iterations. + +### Return Statements + +Use `return` for early exit from functions: + +``` +fn abs_val(x) { + if x < 0 { + return -x + } + x +} +/= abs_val(-7) +``` + +Output: +``` +7 +``` + +Bare `return` (with no value) returns `false`. + +## Builtin Functions + +### Math Functions + +All math functions accept a single number and return a number: + +- `sin(x)`: sine +- `cos(x)`: cosine +- `tan(x)`: tangent +- `asin(x)`: arc sine +- `acos(x)`: arc cosine +- `atan(x)`: arc tangent +- `sqrt(x)`: square root +- `abs(x)`: absolute value +- `floor(x)`: round down +- `ceil(x)`: round up +- `round(x)`: round to nearest integer +- `ln(x)`: natural logarithm +- `log(x)`: base-10 logarithm + +``` +/= sqrt(16) +``` + +Output: +``` +4 +``` + +``` +/= abs(-5) +``` + +Output: +``` +5 +``` + +``` +/= floor(3.7) +``` + +Output: +``` +3 +``` + +### Range Function + +`range(start, end)` returns an array of numbers from start to end (exclusive): + +``` +/= range(0, 5) +``` + +Output: +``` +[0, 1, 2, 3, 4] +``` + +### Length Function + +`len()` returns the length of a string or array: + +``` +/= len("hello") +``` + +Output: +``` +5 +``` + +``` +/= len([1, 2, 3]) +``` + +Output: +``` +3 +``` + +## Truthiness + +In boolean contexts (conditions, logical operators), values are truthy or falsy: + +- **bool**: `true` is truthy, `false` is falsy +- **number**: 0 is falsy, all other numbers are truthy +- **string**: empty string is falsy, all other strings are truthy +- **array**: empty array is falsy, all other arrays are truthy +- **void**: falsy +- **error**: falsy + +Truthiness matters for `if`, `while`, `for` conditions and `&&`/`||` operators. + +## Ranges + +The `..` operator creates an array from start to end (exclusive): + +``` +/= 0..5 +``` + +Output: +``` +[0, 1, 2, 3, 4] +``` + +Ranges are primarily useful with `for` loops and can be stored in variables. + +## Negative Numbers + +Negative number literals are recognized in appropriate contexts: + +``` +/= -5 +``` + +Output: +``` +-5 +``` + +``` +/= 10 + -3 +``` + +Output: +``` +7 +``` + +The unary minus operator also works: + +``` +/= -(5 + 3) +``` + +Output: +``` +-8 +``` + +## Error Handling + +Errors are propagated as error values and displayed with an `error:` prefix: + +``` +/= undefined_var +``` + +Output: +``` +error: undefined variable 'undefined_var' +``` + +``` +/= 1 / 0 +``` + +Output: +``` +error: division by zero +``` + +Multiple statements execute in sequence, and errors in one statement don't prevent subsequent statements from executing. + +## Comments + +Single-line comments use `//`: + +``` +// This is a comment +let x = 5 +/= x +``` + +Comments can appear at the end of lines too. + +## Examples + +### Multi-step Calculation + +``` +let principal = 1000 +let rate = 0.05 +let years = 10 +let amount = principal * (1 + rate) ^ years +/= amount +``` + +Output: +``` +1628.89462382... +``` + +### Fibonacci + +``` +fn fib(n) { + let a = 0 + let b = 1 + let i = 0 + while (i < n) { + let tmp = b + b = a + b + a = tmp + i = i + 1 + } + a +} +/= fib(15) +``` + +Output: +``` +610 +``` + +### String Building + +``` +let greeting = "Hello" +let name = "World" +/= greeting + ", " + name + "!" +``` + +Output: +``` +Hello, World! +``` + +## Display Formats + +### Inline (default) + +The standard `/=` prefix displays the result as a single value on the right edge of the editor: + +``` +let x = 42 +/= x +``` + +Result appears as: `→ 42` + +Arrays display inline: + +``` +/= [1, 2, 3] +``` + +Result: `→ [1, 2, 3]` + +### Table (`/=|`) + +The `/=|` prefix renders a 2D array (array of arrays) as a visual table overlay: + +``` +let data = [["Name", "Age"], ["Alice", 30], ["Bob", 25]] +/=| data +``` + +The first row is treated as the header (rendered bold). Each subsequent row becomes a table row. Non-array values fall back to inline display. + +Works with any 2D array: + +``` +let matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] +/=| matrix +``` + +### Tree (`/=\`) + +The `/=\` prefix renders nested data as an indented tree overlay: + +``` +let tree = [1, [2, 3], [4, [5, 6]]] +/=\ tree +``` + +Nested arrays are expanded with tree-drawing characters showing depth. Leaf values display inline. The root shows the total element count. + +## Limitations + +- **No array mutation**: Arrays cannot be modified after creation (no `arr[i] = val`) +- **No mutable references**: Variable reassignment creates new bindings in the current scope +- **Maximum call depth**: 256 function calls deep +- **Maximum loop iterations**: 10,000 iterations per while/for loop +- **No user-defined types or structs** +- **No imports or modules** +- **No pattern matching or destructuring** +- **No closures or lambdas** diff --git a/core/src/eval.rs b/core/src/eval.rs index 75acb81..dbcf9b1 100644 --- a/core/src/eval.rs +++ b/core/src/eval.rs @@ -6,6 +6,7 @@ use crate::interp; pub struct EvalResult { pub line: usize, pub result: String, + pub format: String, } #[derive(Debug, Clone, Serialize)] @@ -36,14 +37,23 @@ pub fn evaluate_document(text: &str) -> DocumentResult { let interp_results = interp::interpret_document(&lines); for ir in interp_results { + let fmt = match ir.format { + interp::EvalFormat::Inline => "inline", + interp::EvalFormat::Table => "table", + interp::EvalFormat::Tree => "tree", + }; match ir.value { Some(interp::Value::Error(e)) => { errors.push(EvalError { line: ir.line, error: e }); } Some(v) => { - let s = v.display(); + let s = match ir.format { + interp::EvalFormat::Table => value_to_table_json(&v), + interp::EvalFormat::Tree => value_to_tree_json(&v), + interp::EvalFormat::Inline => v.display(), + }; if !s.is_empty() { - results.push(EvalResult { line: ir.line, result: s }); + results.push(EvalResult { line: ir.line, result: s, format: fmt.to_string() }); } } None => {} @@ -53,6 +63,41 @@ pub fn evaluate_document(text: &str) -> DocumentResult { DocumentResult { results, errors } } +fn value_to_table_json(val: &interp::Value) -> String { + match val { + interp::Value::Array(rows) => { + let table: Vec> = rows.iter().map(|row| { + match row { + interp::Value::Array(cols) => cols.iter().map(|c| c.display()).collect(), + other => vec![other.display()], + } + }).collect(); + serde_json::to_string(&table).unwrap_or_else(|_| val.display()) + } + _ => val.display(), + } +} + +fn value_to_tree_json(val: &interp::Value) -> String { + fn to_json(v: &interp::Value) -> serde_json::Value { + match v { + interp::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(|i| to_json(i)).collect()) + } + interp::Value::Number(n) => { + serde_json::Value::Number( + serde_json::Number::from_f64(*n) + .unwrap_or_else(|| serde_json::Number::from(0)) + ) + } + interp::Value::Bool(b) => serde_json::Value::Bool(*b), + interp::Value::Str(s) => serde_json::Value::String(s.clone()), + other => serde_json::Value::String(other.display()), + } + } + serde_json::to_string(&to_json(val)).unwrap_or_else(|_| val.display()) +} + pub fn evaluate_line(text: &str) -> Result { let mut interp = interp::Interpreter::new(); match interp.eval_expr_str(text) { @@ -251,4 +296,49 @@ mod tests { assert_eq!(result.results.len(), 1); assert_eq!(result.results[0].result, "7"); } + + #[test] + fn eval_table_format() { + let doc = "let data = [[\"Name\", \"Age\"], [\"Alice\", 30], [\"Bob\", 25]]\n/=| data"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].format, "table"); + let parsed: Vec> = serde_json::from_str(&result.results[0].result).unwrap(); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0], vec!["Name", "Age"]); + } + + #[test] + fn eval_tree_format() { + let doc = "let tree = [1, [2, 3], [4, [5]]]\n/=\\ tree"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].format, "tree"); + let parsed: serde_json::Value = serde_json::from_str(&result.results[0].result).unwrap(); + assert!(parsed.is_array()); + } + + #[test] + fn eval_inline_format_default() { + let doc = "let x = 42\n/= x"; + let result = evaluate_document(doc); + assert_eq!(result.results[0].format, "inline"); + } + + #[test] + fn eval_table_flat_array() { + let doc = "/=| [1, 2, 3]"; + let result = evaluate_document(doc); + assert_eq!(result.results.len(), 1); + assert_eq!(result.results[0].format, "table"); + } + + #[test] + fn eval_document_json_has_format() { + let doc = "let x = 42\n/= x\n/=| [[1, 2], [3, 4]]"; + let result = evaluate_document(doc); + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"format\":\"inline\"")); + assert!(json.contains("\"format\":\"table\"")); + } } diff --git a/core/src/interp.rs b/core/src/interp.rs index 177de45..1ec5acf 100644 --- a/core/src/interp.rs +++ b/core/src/interp.rs @@ -1158,6 +1158,14 @@ fn apply_type_annotation(val: &Value, ann: Option<&str>) -> Result, + pub format: EvalFormat, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EvalFormat { + Inline, + Table, + Tree, } pub fn interpret_document(lines: &[(usize, &str, bool)]) -> Vec { @@ -1174,7 +1182,6 @@ pub fn interpret_document(lines: &[(usize, &str, bool)]) -> Vec { for &(idx, content, is_eval) in lines { if is_eval { - // flush any accumulated block first if !block_acc.is_empty() { let block_text = block_acc.join("\n"); block_acc.clear(); @@ -1182,20 +1189,27 @@ pub fn interpret_document(lines: &[(usize, &str, bool)]) -> Vec { match interp.exec_line(&block_text) { Ok(_) => {} Err(e) => { - results.push(InterpResult { line: idx, value: Some(Value::Error(e)) }); + results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format: EvalFormat::Inline }); continue; } } } - let expr = content.trim().strip_prefix("/=").unwrap_or("").trim(); + let trimmed = content.trim(); + let (format, expr) = if let Some(rest) = trimmed.strip_prefix("/=|") { + (EvalFormat::Table, rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix("/=\\") { + (EvalFormat::Tree, rest.trim()) + } else { + (EvalFormat::Inline, trimmed.strip_prefix("/=").unwrap_or("").trim()) + }; if expr.is_empty() { - results.push(InterpResult { line: idx, value: Some(Value::Error("empty expression".into())) }); + results.push(InterpResult { line: idx, value: Some(Value::Error("empty expression".into())), format }); continue; } match interp.eval_expr_str(expr) { - Ok(val) => results.push(InterpResult { line: idx, value: Some(val) }), - Err(e) => results.push(InterpResult { line: idx, value: Some(Value::Error(e)) }), + Ok(val) => results.push(InterpResult { line: idx, value: Some(val), format }), + Err(e) => results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format }), } } else { let trimmed = content.trim(); @@ -1211,17 +1225,15 @@ pub fn interpret_document(lines: &[(usize, &str, bool)]) -> Vec { block_acc.clear(); brace_depth = 0; if let Err(e) = interp.exec_line(&block_text) { - results.push(InterpResult { line: idx, value: Some(Value::Error(e)) }); + results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format: EvalFormat::Inline }); } } } else if opens > closes { - // starting a block block_acc.push(trimmed.to_string()); brace_depth = opens - closes; } else { - // single-line cordial statement if let Err(e) = interp.exec_line(trimmed) { - results.push(InterpResult { line: idx, value: Some(Value::Error(e)) }); + results.push(InterpResult { line: idx, value: Some(Value::Error(e)), format: EvalFormat::Inline }); } } } diff --git a/src/AppState.swift b/src/AppState.swift index 5086fd8..9e7a350 100644 --- a/src/AppState.swift +++ b/src/AppState.swift @@ -11,7 +11,7 @@ class AppState: ObservableObject { } } } - @Published var evalResults: [Int: String] = [:] + @Published var evalResults: [Int: EvalEntry] = [:] @Published var noteList: [NoteInfo] = [] @Published var currentNoteID: UUID @Published var selectedNoteIDs: Set = [] diff --git a/src/EditorView.swift b/src/EditorView.swift index eb0fa6f..531ab8f 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -916,8 +916,8 @@ struct EditorView: View { .padding(.top, 4) } - private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] { - var shifted: [Int: String] = [:] + private func offsetEvalResults(_ results: [Int: EvalEntry]) -> [Int: EvalEntry] { + var shifted: [Int: EvalEntry] = [:] for (key, val) in results where key > 0 { shifted[key - 1] = val } @@ -927,7 +927,7 @@ struct EditorView: View { struct EditorTextView: NSViewRepresentable { @Binding var text: String - var evalResults: [Int: String] + var evalResults: [Int: EvalEntry] var onEvaluate: () -> Void var onBackspaceAtStart: (() -> Void)? = nil @@ -1579,8 +1579,13 @@ private func highlightCodeLine(_ line: String, lineRange: NSRange, textStorage: return } - // Eval prefix - if trimmed.hasPrefix("/=") { + if trimmed.hasPrefix("/=|") || trimmed.hasPrefix("/=\\") { + let prefix = trimmed.hasPrefix("/=|") ? "/=|" : "/=\\" + let prefixRange = (textStorage.string as NSString).range(of: prefix, range: lineRange) + if prefixRange.location != NSNotFound { + textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange) + } + } else if trimmed.hasPrefix("/=") { let prefixRange = (textStorage.string as NSString).range(of: "/=", range: lineRange) if prefixRange.location != NSNotFound { textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange) @@ -2201,7 +2206,7 @@ private func resolveLocalImagePath(_ rawPath: String) -> String? { class LineNumberTextView: NSTextView { static let gutterWidth: CGFloat = 50 - var evalResults: [Int: String] = [:] + var evalResults: [Int: EvalEntry] = [:] override var textContainerOrigin: NSPoint { return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height) @@ -2302,11 +2307,18 @@ class LineNumberTextView: NSTextView { numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - numSize.width - 8, y: y)) } - if let result = evalResults[lineNumber - 1] { - let resultStr = NSAttributedString(string: "\u{2192} \(result)", attributes: resultAttrs) - let size = resultStr.size() - let rightEdge = visibleRect.maxX - resultStr.draw(at: NSPoint(x: rightEdge - size.width - 8, y: y)) + if let entry = evalResults[lineNumber - 1] { + switch entry.format { + case .table: + drawTableResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs) + case .tree: + drawTreeResult(entry.result, at: y, origin: origin, resultAttrs: resultAttrs) + case .inline: + let resultStr = NSAttributedString(string: "\u{2192} \(entry.result)", attributes: resultAttrs) + let size = resultStr.size() + let rightEdge = visibleRect.maxX + resultStr.draw(at: NSPoint(x: rightEdge - size.width - 8, y: y)) + } } lineNumber += 1 @@ -2314,6 +2326,165 @@ class LineNumberTextView: NSTextView { } } + // MARK: - Table/Tree Rendering + + private func drawTableResult(_ json: String, at y: CGFloat, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { + guard let data = json.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data), + let rows = parsed as? [[Any]] else { + let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs) + let size = fallback.size() + fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: y)) + return + } + + let palette = Theme.current + let font = Theme.gutterFont + let headerAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask), + .foregroundColor: palette.teal + ] + let cellAttrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: palette.subtext0 + ] + let borderColor = palette.surface1 + + let stringRows: [[String]] = rows.map { row in + row.map { cell in + if let s = cell as? String { return s } + if let n = cell as? NSNumber { return "\(n)" } + return "\(cell)" + } + } + guard !stringRows.isEmpty else { return } + + let colCount = stringRows.map(\.count).max() ?? 0 + guard colCount > 0 else { return } + + var colWidths = [CGFloat](repeating: 0, count: colCount) + for row in stringRows { + for (ci, cell) in row.enumerated() where ci < colCount { + let w = (cell as NSString).size(withAttributes: cellAttrs).width + colWidths[ci] = max(colWidths[ci], w) + } + } + + let cellPad: CGFloat = 8 + let rowHeight: CGFloat = font.pointSize + 6 + let tableWidth = colWidths.reduce(0, +) + cellPad * CGFloat(colCount + 1) + CGFloat(colCount - 1) + let tableHeight = rowHeight * CGFloat(stringRows.count) + CGFloat(stringRows.count + 1) + + let rightEdge = visibleRect.maxX + let tableX = rightEdge - tableWidth - 12 + let tableY = y + + let tableRect = NSRect(x: tableX, y: tableY, width: tableWidth, height: tableHeight) + palette.mantle.setFill() + let path = NSBezierPath(roundedRect: tableRect, xRadius: 4, yRadius: 4) + path.fill() + borderColor.setStroke() + path.lineWidth = 0.5 + path.stroke() + + var cy = tableY + 2 + for (ri, row) in stringRows.enumerated() { + let attrs = ri == 0 ? headerAttrs : cellAttrs + var cx = tableX + cellPad + for (ci, cell) in row.enumerated() where ci < colCount { + let str = NSAttributedString(string: cell, attributes: attrs) + str.draw(at: NSPoint(x: cx, y: cy)) + cx += colWidths[ci] + cellPad + } + cy += rowHeight + + if ri == 0 { + borderColor.setStroke() + let linePath = NSBezierPath() + linePath.move(to: NSPoint(x: tableX + 2, y: cy - 1)) + linePath.line(to: NSPoint(x: tableX + tableWidth - 2, y: cy - 1)) + linePath.lineWidth = 0.5 + linePath.stroke() + } + } + } + + private func drawTreeResult(_ json: String, at y: CGFloat, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) { + guard let data = json.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) else { + let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs) + let size = fallback.size() + fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: y)) + return + } + + let palette = Theme.current + let font = Theme.gutterFont + let nodeAttrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: palette.teal + ] + let branchAttrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: palette.overlay0 + ] + + var lines: [(String, Int)] = [] + func walk(_ node: Any, depth: Int) { + if let arr = node as? [Any] { + for (i, item) in arr.enumerated() { + let prefix = i == arr.count - 1 ? "\u{2514}" : "\u{251C}" + if item is [Any] { + lines.append(("\(prefix) [\(((item as? [Any])?.count ?? 0))]", depth)) + walk(item, depth: depth + 1) + } else { + lines.append(("\(prefix) \(item)", depth)) + } + } + } else { + lines.append(("\(node)", depth)) + } + } + + if let arr = root as? [Any] { + lines.append(("[\(arr.count)]", 0)) + walk(root, depth: 1) + } else { + lines.append(("\(root)", 0)) + } + + let lineHeight = font.pointSize + 4 + let indent: CGFloat = 14 + var maxWidth: CGFloat = 0 + for (text, depth) in lines { + let w = (text as NSString).size(withAttributes: nodeAttrs).width + CGFloat(depth) * indent + maxWidth = max(maxWidth, w) + } + + let treeHeight = lineHeight * CGFloat(lines.count) + 4 + let treeWidth = maxWidth + 16 + let rightEdge = visibleRect.maxX + let treeX = rightEdge - treeWidth - 8 + let treeY = y + + let treeRect = NSRect(x: treeX, y: treeY, width: treeWidth, height: treeHeight) + palette.mantle.setFill() + let path = NSBezierPath(roundedRect: treeRect, xRadius: 4, yRadius: 4) + path.fill() + palette.surface1.setStroke() + path.lineWidth = 0.5 + path.stroke() + + var cy = treeY + 2 + for (text, depth) in lines { + let x = treeX + 8 + CGFloat(depth) * indent + let attrs = depth == 0 ? nodeAttrs : branchAttrs + let str = NSAttributedString(string: text, attributes: attrs) + str.draw(at: NSPoint(x: x, y: cy)) + cy += lineHeight + } + } + // MARK: - Paste (image from clipboard) override func paste(_ sender: Any?) { diff --git a/src/RustBridge.swift b/src/RustBridge.swift index 0ae2840..45e2b2e 100644 --- a/src/RustBridge.swift +++ b/src/RustBridge.swift @@ -6,6 +6,17 @@ struct NoteInfo: Identifiable { var lastModified: Date } +enum EvalFormat: String { + case inline + case table + case tree +} + +struct EvalEntry { + let result: String + let format: EvalFormat +} + class RustBridge { static let shared = RustBridge() @@ -41,7 +52,7 @@ class RustBridge { return str } - func evaluate(_ id: UUID) -> [Int: String] { + func evaluate(_ id: UUID) -> [Int: EvalEntry] { guard let ptr = docs[id] else { return [:] } guard let cstr = swiftly_doc_evaluate(ptr) else { return [:] } let json = String(cString: cstr) @@ -118,14 +129,15 @@ class RustBridge { return str } - private func parseEvalJSON(_ json: String) -> [Int: String] { + private func parseEvalJSON(_ json: String) -> [Int: EvalEntry] { guard let data = json.data(using: .utf8) else { return [:] } guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } guard let results = obj["results"] as? [[String: Any]] else { return [:] } - var dict: [Int: String] = [:] + var dict: [Int: EvalEntry] = [:] for item in results { if let line = item["line"] as? Int, let result = item["result"] as? String { - dict[line] = result + let fmt = EvalFormat(rawValue: item["format"] as? String ?? "inline") ?? .inline + dict[line] = EvalEntry(result: result, format: fmt) } } return dict