From 0bf2d1b3449f77be9ed3087d5ca5d8a963b03a48 Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 5 Apr 2026 12:29:04 -0700 Subject: [PATCH] add Settings window with theme mode and line indicator mode controls --- src/AppDelegate.swift | 19 +++++++ src/ContentView.swift | 2 + src/EditorView.swift | 59 +++++++++++++++++++-- src/SettingsView.swift | 116 +++++++++++++++++++++++++++++++++++++++++ src/Theme.swift | 12 +++-- 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/SettingsView.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 6420152..09db8aa 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -28,8 +28,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.setFrameAutosaveName("SwiftlyMainWindow") window.makeKeyAndOrderFront(nil) + applyThemeAppearance() setupMenuBar() observeDocumentTitle() + + NotificationCenter.default.addObserver( + self, selector: #selector(settingsDidChange), + name: .settingsChanged, object: nil + ) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -59,6 +65,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { let menu = NSMenu() menu.addItem(withTitle: "About Swiftly", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") menu.addItem(.separator()) + let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",") + settingsItem.target = self + menu.addItem(settingsItem) + menu.addItem(.separator()) menu.addItem(withTitle: "Quit Swiftly", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") item.submenu = menu return item @@ -163,6 +173,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @objc private func openSettings() { + SettingsWindowController.show() + } + + @objc private func settingsDidChange() { + window.backgroundColor = Theme.current.base + window.contentView?.needsDisplay = true + } + @objc private func toggleSidebar() { NotificationCenter.default.post(name: .toggleSidebar, object: nil) } diff --git a/src/ContentView.swift b/src/ContentView.swift index 301e0cc..5a56606 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -3,8 +3,10 @@ import SwiftUI struct ContentView: View { @ObservedObject var state: AppState @State private var sidebarVisible: Bool = false + @AppStorage("themeMode") private var themeMode: String = "auto" var body: some View { + let _ = themeMode HSplitView { if sidebarVisible { SidebarView(state: state) diff --git a/src/EditorView.swift b/src/EditorView.swift index 525cda1..eefe1a6 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -818,6 +818,7 @@ struct EditorTextView: NSViewRepresentable { private var isUpdatingTables = false private var embeddedTableViews: [MarkdownTableView] = [] private var focusObserver: NSObjectProtocol? + private var settingsObserver: NSObjectProtocol? init(_ parent: EditorTextView) { self.parent = parent @@ -829,12 +830,32 @@ struct EditorTextView: NSViewRepresentable { tv.window?.makeFirstResponder(tv) tv.setSelectedRange(NSRange(location: 0, length: 0)) } + settingsObserver = NotificationCenter.default.addObserver( + forName: .settingsChanged, object: nil, queue: .main + ) { [weak self] _ in + guard let tv = self?.textView, let ts = tv.textStorage else { return } + let palette = Theme.current + tv.backgroundColor = palette.base + tv.insertionPointColor = palette.text + tv.selectedTextAttributes = [.backgroundColor: palette.surface1] + tv.typingAttributes = [ + .font: Theme.editorFont, + .foregroundColor: palette.text + ] + ts.beginEditing() + applySyntaxHighlighting(to: ts) + ts.endEditing() + tv.needsDisplay = true + } } deinit { if let obs = focusObserver { NotificationCenter.default.removeObserver(obs) } + if let obs = settingsObserver { + NotificationCenter.default.removeObserver(obs) + } } func textDidChange(_ notification: Notification) { @@ -1982,6 +2003,8 @@ class LineNumberTextView: NSTextView { let text = string as NSString guard text.length > 0 else { return } + let lineMode = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on" + var containerVisible = visibleRect containerVisible.origin.x -= origin.x containerVisible.origin.y -= origin.y @@ -1995,10 +2018,24 @@ class LineNumberTextView: NSTextView { idx += 1 } + var cursorLine = 1 + if lineMode == "vim" { + let cursorPos = selectedRange().location + var ci = 0 + while ci < min(cursorPos, text.length) { + if text.character(at: ci) == 0x0A { cursorLine += 1 } + ci += 1 + } + } + let lineAttrs: [NSAttributedString.Key: Any] = [ .font: Theme.gutterFont, .foregroundColor: palette.overlay0 ] + let currentLineAttrs: [NSAttributedString.Key: Any] = [ + .font: Theme.gutterFont, + .foregroundColor: palette.text + ] let resultAttrs: [NSAttributedString.Key: Any] = [ .font: Theme.gutterFont, .foregroundColor: palette.teal @@ -2011,9 +2048,25 @@ class LineNumberTextView: NSTextView { let lineRect = lm.boundingRect(forGlyphRange: lineGlyphRange, in: tc) let y = lineRect.origin.y + origin.y - let numStr = NSAttributedString(string: "\(lineNumber)", attributes: lineAttrs) - let numSize = numStr.size() - numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - numSize.width - 8, y: y)) + if lineMode != "off" { + let displayNum: Int + let attrs: [NSAttributedString.Key: Any] + if lineMode == "vim" { + if lineNumber == cursorLine { + displayNum = lineNumber + attrs = currentLineAttrs + } else { + displayNum = abs(lineNumber - cursorLine) + attrs = lineAttrs + } + } else { + displayNum = lineNumber + attrs = lineAttrs + } + let numStr = NSAttributedString(string: "\(displayNum)", attributes: attrs) + let numSize = numStr.size() + 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) diff --git a/src/SettingsView.swift b/src/SettingsView.swift new file mode 100644 index 0000000..ac9fdf0 --- /dev/null +++ b/src/SettingsView.swift @@ -0,0 +1,116 @@ +import SwiftUI +import Cocoa + +enum ThemeMode: String, CaseIterable { + case auto = "auto" + case dark = "dark" + case light = "light" + + var label: String { + switch self { + case .auto: return "Auto" + case .dark: return "Dark" + case .light: return "Light" + } + } +} + +enum LineIndicatorMode: String, CaseIterable { + case on = "on" + case off = "off" + case vim = "vim" + + var label: String { + switch self { + case .on: return "On" + case .off: return "Off" + case .vim: return "Vim" + } + } +} + +struct SettingsView: View { + @AppStorage("themeMode") private var themeMode: String = "auto" + @AppStorage("lineIndicatorMode") private var lineIndicatorMode: String = "on" + + var body: some View { + let palette = Theme.current + Form { + Section("Theme") { + Picker("Mode", selection: $themeMode) { + ForEach(ThemeMode.allCases, id: \.rawValue) { mode in + Text(mode.label).tag(mode.rawValue) + } + } + .pickerStyle(.segmented) + } + + Section("Line Numbers") { + Picker("Mode", selection: $lineIndicatorMode) { + ForEach(LineIndicatorMode.allCases, id: \.rawValue) { mode in + Text(mode.label).tag(mode.rawValue) + } + } + .pickerStyle(.segmented) + } + } + .formStyle(.grouped) + .frame(width: 320, height: 180) + .background(Color(ns: palette.base)) + .onChange(of: themeMode) { + DispatchQueue.main.async { + applyThemeAppearance() + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + } + .onChange(of: lineIndicatorMode) { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .settingsChanged, object: nil) + } + } + } +} + +func applyThemeAppearance() { + let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto" + switch mode { + case "dark": + NSApp.appearance = NSAppearance(named: .darkAqua) + case "light": + NSApp.appearance = NSAppearance(named: .aqua) + default: + NSApp.appearance = nil + } +} + +extension Notification.Name { + static let settingsChanged = Notification.Name("settingsChanged") +} + +class SettingsWindowController { + private static var window: NSWindow? + + static func show() { + if let existing = window, existing.isVisible { + existing.makeKeyAndOrderFront(nil) + return + } + + let settingsView = SettingsView() + let hostingView = NSHostingView(rootView: settingsView) + hostingView.frame = NSRect(x: 0, y: 0, width: 320, height: 200) + + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + w.title = "Settings" + w.contentView = hostingView + w.center() + w.isReleasedWhenClosed = false + w.makeKeyAndOrderFront(nil) + window = w + } +} diff --git a/src/Theme.swift b/src/Theme.swift index c6d92a3..171afe5 100644 --- a/src/Theme.swift +++ b/src/Theme.swift @@ -90,9 +90,15 @@ struct Theme { ) static var current: CatppuccinPalette { - let appearance = NSApp.effectiveAppearance - let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - return isDark ? mocha : latte + let mode = UserDefaults.standard.string(forKey: "themeMode") ?? "auto" + switch mode { + case "dark": return mocha + case "light": return latte + default: + 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)