Files
calctext/calcpad-macos/Sources/CalcPad/Views/TwoColumnEditorView.swift
C. Cassel 0d38bd3108 feat(web): implement complete workspace with themes, tabs, sidebar, and mobile
Transform CalcText from a single-document calculator into a full workspace
application with multi-document support, theming, and responsive mobile experience.

- Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors
- Document model with localStorage persistence and auto-save
- Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close
- Sidebar with search, recent, favorites, folders, templates, drag-and-drop
- 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator
- Status bar with cursor position, engine status, dedication to Igor Cassel
- Results panel: type-specific colors, click-to-copy, error hints
- Format toolbar: H, B, I, //, color labels with live preview toggle
- Syntax highlighting using theme CSS variables
- Error hover tooltips
- Mobile: bottom results tray, sidebar drawer, touch targets, safe areas
- Docker multi-stage build (Rust WASM + Vite + Nginx)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:12:05 -04:00

132 lines
5.3 KiB
Swift

import AppKit
import Combine
import SwiftUI
/// The main two-column editor layout: text editor on the left, results on the right.
/// Scrolling is synchronized between both columns.
/// Includes a toolbar for justification toggles and alternating row striping.
struct TwoColumnEditorView: View {
@State private var text: String = "# CalcPad\n\n// Basic arithmetic\n2 + 3\n10 * 4.5\n100 / 7\n\n// Variables\nprice = 49.99\nquantity = 3\nsubtotal = price * quantity\n\n// Percentages\ntax = subtotal * 8%\ntotal = subtotal + tax\n\n// Functions\nsqrt(144)\n2 ^ 10\n"
@State private var scrollOffset: CGFloat = 0
@State private var results: [LineResult] = []
@State private var evaluationTask: Task<Void, Never>?
@State private var editorAlignment: NSTextAlignment = .left
@State private var resultsAlignment: NSTextAlignment = .right
/// Uses the Rust FFI engine. Falls back to StubCalculationEngine if the Rust
/// library is not linked (e.g., during UI-only development).
private let engine: CalculationEngine = RustCalculationEngine()
/// Debounce interval for re-evaluation after typing (seconds).
private let evaluationDebounce: TimeInterval = 0.05
/// Font that respects the user's accessibility / Dynamic Type settings.
private var editorFont: NSFont {
let baseSize = NSFont.systemFontSize
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
let size = max(baseSize, preferredSize)
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
var body: some View {
VStack(spacing: 0) {
// Justification toolbar
HStack(spacing: 0) {
// Editor alignment buttons
HStack(spacing: 4) {
Button(action: { editorAlignment = .left }) {
Image(systemName: "text.alignleft")
}
.buttonStyle(.borderless)
.foregroundColor(editorAlignment == .left ? .accentColor : .secondary)
.help("Align editor text left")
Button(action: { editorAlignment = .center }) {
Image(systemName: "text.aligncenter")
}
.buttonStyle(.borderless)
.foregroundColor(editorAlignment == .center ? .accentColor : .secondary)
.help("Align editor text center")
Button(action: { editorAlignment = .right }) {
Image(systemName: "text.alignright")
}
.buttonStyle(.borderless)
.foregroundColor(editorAlignment == .right ? .accentColor : .secondary)
.help("Align editor text right")
}
.padding(.horizontal, 8)
Spacer()
// Results alignment buttons
HStack(spacing: 4) {
Button(action: { resultsAlignment = .left }) {
Image(systemName: "text.alignleft")
}
.buttonStyle(.borderless)
.foregroundColor(resultsAlignment == .left ? .accentColor : .secondary)
.help("Align results left")
Button(action: { resultsAlignment = .center }) {
Image(systemName: "text.aligncenter")
}
.buttonStyle(.borderless)
.foregroundColor(resultsAlignment == .center ? .accentColor : .secondary)
.help("Align results center")
Button(action: { resultsAlignment = .right }) {
Image(systemName: "text.alignright")
}
.buttonStyle(.borderless)
.foregroundColor(resultsAlignment == .right ? .accentColor : .secondary)
.help("Align results right")
}
.padding(.horizontal, 8)
}
.padding(.vertical, 4)
Divider()
HSplitView {
// Left pane: Editor
EditorTextView(
text: $text,
scrollOffset: $scrollOffset,
font: editorFont,
alignment: editorAlignment
)
.frame(minWidth: 200)
// Right pane: Answer column
AnswerColumnView(
results: results,
scrollOffset: $scrollOffset,
font: editorFont,
alignment: resultsAlignment
)
.frame(minWidth: 120, idealWidth: 200)
}
}
.onChange(of: text) { _, newValue in
scheduleEvaluation(newValue)
}
.onAppear {
evaluateText(text)
}
}
private func scheduleEvaluation(_ newText: String) {
evaluationTask?.cancel()
evaluationTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(Int(evaluationDebounce * 1000)))
guard !Task.isCancelled else { return }
evaluateText(newText)
}
}
private func evaluateText(_ newText: String) {
results = engine.evaluateSheet(newText)
}
}