From 3fd9e58d4188977159565340d0e64fd8f8162123 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 20:41:37 +0000 Subject: [PATCH] feat(app): make editor background, font and font size configurable The settings window only knew the eight highlight colors. It now also holds the editor background color and the font family and size, all stored in UserDefaults and applied to the open document right away. `ColorSettings` becomes `AppearanceSettings` around a new `EditorTheme` value: palette, background hex, font family and size in one comparable struct, so the text view can tell in one check whether it has to redraw. Two details worth noting: a custom background gets black or white text (and a matching aqua/darkAqua appearance for the scrollers), because `.textColor` follows the system appearance and would otherwise turn unreadable; and the suggest panel draws its rows in the editor font, since that is the text it inserts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EkV7zVGNsJEj5ex3SV1ejf --- App/App/GuerkchenApp.swift | 6 +- App/UI/Editor/EditorView.swift | 4 +- App/UI/Editor/GherkinTextView.swift | 49 ++++++--- App/UI/Editor/SuggestController.swift | 4 +- App/UI/Editor/SuggestPanel.swift | 22 ++-- App/UI/Editor/SyntaxHighlighter.swift | 6 +- App/UI/Settings/SettingsView.swift | 71 ++++++++++-- App/UI/Theme/AppearanceSettings.swift | 150 ++++++++++++++++++++++++++ App/UI/Theme/ColorSettings.swift | 54 ---------- App/UI/Theme/EditorTheme.swift | 43 ++++++++ App/UI/Theme/FontCatalog.swift | 16 +++ App/UI/Theme/NSColor+Hex.swift | 7 ++ README.md | 3 + 13 files changed, 345 insertions(+), 90 deletions(-) create mode 100644 App/UI/Theme/AppearanceSettings.swift delete mode 100644 App/UI/Theme/ColorSettings.swift create mode 100644 App/UI/Theme/EditorTheme.swift create mode 100644 App/UI/Theme/FontCatalog.swift diff --git a/App/App/GuerkchenApp.swift b/App/App/GuerkchenApp.swift index f34a3d3..4bb7744 100644 --- a/App/App/GuerkchenApp.swift +++ b/App/App/GuerkchenApp.swift @@ -3,13 +3,13 @@ import SwiftUI @main struct GuerkchenApp: App { @State private var appState = AppState() - @State private var colors = ColorSettings() + @State private var appearance = AppearanceSettings() var body: some Scene { Window("guerkchen", id: "main") { ContentView() .environment(appState) - .environment(colors) + .environment(appearance) .frame(minWidth: 800, minHeight: 500) } .commands { @@ -37,7 +37,7 @@ struct GuerkchenApp: App { Settings { SettingsView() - .environment(colors) + .environment(appearance) } } } diff --git a/App/UI/Editor/EditorView.swift b/App/UI/Editor/EditorView.swift index 18c6210..5b2a853 100644 --- a/App/UI/Editor/EditorView.swift +++ b/App/UI/Editor/EditorView.swift @@ -2,7 +2,7 @@ import SwiftUI struct EditorView: View { @Environment(AppState.self) private var appState - @Environment(ColorSettings.self) private var colors + @Environment(AppearanceSettings.self) private var appearance var body: some View { if let document = appState.document { @@ -11,7 +11,7 @@ struct EditorView: View { ContentUnavailableView("Datei kann nicht angezeigt werden", systemImage: "doc.badge.exclamationmark", description: Text(loadError)) } else { - GherkinTextView(document: document, text: document.text, palette: colors.palette) { code in + GherkinTextView(document: document, text: document.text, theme: appearance.theme) { code in appState.project?.steps(for: code) ?? [] } } diff --git a/App/UI/Editor/GherkinTextView.swift b/App/UI/Editor/GherkinTextView.swift index b78c15e..20fedae 100644 --- a/App/UI/Editor/GherkinTextView.swift +++ b/App/UI/Editor/GherkinTextView.swift @@ -7,11 +7,9 @@ struct GherkinTextView: NSViewRepresentable { /// Nur damit der View-Wert sich ändert, wenn sich der Text des Dokuments ändert /// (z. B. nach einer externen Änderung). Der Coordinator arbeitet weiter mit `document`. let text: String - let palette: HighlightPalette + let theme: EditorTheme let stepsProvider: (String) -> [String] - static let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSScrollView { @@ -21,7 +19,6 @@ struct GherkinTextView: NSViewRepresentable { textView.isRichText = false textView.allowsUndo = true textView.usesFindBar = true - textView.font = Self.font textView.textContainerInset = NSSize(width: 8, height: 8) textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false @@ -33,12 +30,12 @@ struct GherkinTextView: NSViewRepresentable { textView.smartInsertDeleteEnabled = false context.coordinator.textView = textView context.coordinator.suggest = SuggestController(textView: textView) - context.coordinator.update(document: document, palette: palette, stepsProvider: stepsProvider) + context.coordinator.update(document: document, theme: theme, stepsProvider: stepsProvider) return scrollView } func updateNSView(_ nsView: NSScrollView, context: Context) { - context.coordinator.update(document: document, palette: palette, stepsProvider: stepsProvider) + context.coordinator.update(document: document, theme: theme, stepsProvider: stepsProvider) } /// Deterministischer Abbau: ohne das bliebe ein offenes Vorschlagspanel am Hauptfenster hängen. @@ -52,16 +49,18 @@ struct GherkinTextView: NSViewRepresentable { weak var textView: NSTextView? var suggest: SuggestController? private var document: EditorDocument? - private var palette: HighlightPalette = [:] + private var theme: EditorTheme? private var isApplying = false - func update(document: EditorDocument, palette: HighlightPalette, stepsProvider: @escaping (String) -> [String]) { + func update(document: EditorDocument, theme: EditorTheme, stepsProvider: @escaping (String) -> [String]) { suggest?.stepsProvider = stepsProvider guard let textView else { return } let documentChanged = document !== self.document - let paletteChanged = palette != self.palette + let themeChanged = theme != self.theme self.document = document - self.palette = palette + self.theme = theme + + if themeChanged { applyTheme(theme) } if documentChanged || textView.string != document.text { isApplying = true @@ -75,15 +74,37 @@ struct GherkinTextView: NSViewRepresentable { isApplying = false suggest?.hide() rehighlight() - } else if paletteChanged { + } else if themeChanged { rehighlight() } } func rehighlight() { - guard let textView, let storage = textView.textStorage else { return } - SyntaxHighlighter.highlight(storage: storage, palette: palette, font: GherkinTextView.font) - textView.typingAttributes = [.font: GherkinTextView.font, .foregroundColor: NSColor.textColor] + guard let textView, let theme, let storage = textView.textStorage else { return } + SyntaxHighlighter.highlight(storage: storage, theme: theme) + textView.typingAttributes = [.font: theme.font, .foregroundColor: theme.textColor] + } + + // MARK: - Private + + private func applyTheme(_ theme: EditorTheme) { + guard let textView else { return } + let background = theme.backgroundColor + textView.font = theme.font + textView.drawsBackground = true + textView.backgroundColor = background + textView.insertionPointColor = theme.textColor + suggest?.font = theme.font + + guard let scrollView = textView.enclosingScrollView else { return } + scrollView.drawsBackground = true + scrollView.backgroundColor = background + // Bei eigenem Hintergrund passen Scroller und Auswahlfarbe sonst nicht dazu. + if theme.customBackgroundColor != nil { + scrollView.appearance = NSAppearance(named: background.isDark ? .darkAqua : .aqua) + } else { + scrollView.appearance = nil + } } // MARK: NSTextViewDelegate diff --git a/App/UI/Editor/SuggestController.swift b/App/UI/Editor/SuggestController.swift index 08ec41d..e5ee715 100644 --- a/App/UI/Editor/SuggestController.swift +++ b/App/UI/Editor/SuggestController.swift @@ -5,6 +5,8 @@ import GuerkchenCore @MainActor final class SuggestController { var stepsProvider: (String) -> [String] = { _ in [] } + /// Die Vorschläge landen so im Editor, wie sie hier stehen – deshalb dieselbe Schrift. + var font: NSFont = .monospacedSystemFont(ofSize: 13, weight: .regular) private weak var textView: NSTextView? private let panel = SuggestPanel() @@ -118,7 +120,7 @@ final class SuggestController { currentLineLocation = lineRange.location let cursorRect = textView.firstRect(forCharacterRange: NSRange(location: selection.location, length: 0), actualRange: nil) - panel.show(suggestions, below: cursorRect, in: window) + panel.show(suggestions, font: font, below: cursorRect, in: window) } /// Ungerade Anzahl Doc-String-Begrenzer vor dieser Zeile → wir sind in einem Doc-String. diff --git a/App/UI/Editor/SuggestPanel.swift b/App/UI/Editor/SuggestPanel.swift index 90fb175..0dcb928 100644 --- a/App/UI/Editor/SuggestPanel.swift +++ b/App/UI/Editor/SuggestPanel.swift @@ -3,10 +3,14 @@ import SwiftUI import GuerkchenCore struct SuggestListView: View { - static let rowHeight: CGFloat = 24 + /// Die Zeilen wachsen mit der eingestellten Editorschrift mit. + static func rowHeight(for font: NSFont) -> CGFloat { + max(24, ceil(font.ascender - font.descender + font.leading) + 8) + } let suggestions: [Suggestion] let selectedIndex: Int + let font: NSFont let onPick: (Int) -> Void var body: some View { @@ -17,12 +21,12 @@ struct SuggestListView: View { .foregroundStyle(.secondary) .frame(width: 14) Text(suggestion.label) - .font(.system(size: 13, design: .monospaced)) + .font(Font(font)) .lineLimit(1) Spacer(minLength: 0) } .padding(.horizontal, 8) - .frame(height: Self.rowHeight) + .frame(height: Self.rowHeight(for: font)) .background(index == selectedIndex ? Color.accentColor.opacity(0.25) : Color.clear, in: RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) @@ -49,6 +53,7 @@ final class SuggestPanel: NSPanel { var onPick: ((Int) -> Void)? private(set) var suggestions: [Suggestion] = [] private(set) var selectedIndex = 0 + private var font: NSFont = .monospacedSystemFont(ofSize: 13, weight: .regular) private let hosting: NSHostingView var selectedSuggestion: Suggestion? { @@ -56,7 +61,9 @@ final class SuggestPanel: NSPanel { } init() { - hosting = NSHostingView(rootView: SuggestListView(suggestions: [], selectedIndex: 0, onPick: { _ in })) + hosting = NSHostingView(rootView: SuggestListView(suggestions: [], selectedIndex: 0, + font: .monospacedSystemFont(ofSize: 13, weight: .regular), + onPick: { _ in })) super.init(contentRect: NSRect(x: 0, y: 0, width: Self.width, height: 100), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) isFloatingPanel = true @@ -72,12 +79,13 @@ final class SuggestPanel: NSPanel { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } - func show(_ suggestions: [Suggestion], below cursorRect: NSRect, in window: NSWindow) { + func show(_ suggestions: [Suggestion], font: NSFont, below cursorRect: NSRect, in window: NSWindow) { self.suggestions = suggestions + self.font = font selectedIndex = 0 render() - let height = CGFloat(suggestions.count) * SuggestListView.rowHeight + 8 + let height = CGFloat(suggestions.count) * SuggestListView.rowHeight(for: font) + 8 var origin = NSPoint(x: cursorRect.minX, y: cursorRect.minY - height - 2) if let screen = window.screen { let visible = screen.visibleFrame @@ -109,7 +117,7 @@ final class SuggestPanel: NSPanel { } private func render() { - hosting.rootView = SuggestListView(suggestions: suggestions, selectedIndex: selectedIndex) { [weak self] index in + hosting.rootView = SuggestListView(suggestions: suggestions, selectedIndex: selectedIndex, font: font) { [weak self] index in self?.selectedIndex = index self?.onPick?(index) } diff --git a/App/UI/Editor/SyntaxHighlighter.swift b/App/UI/Editor/SyntaxHighlighter.swift index d944a3c..451a53a 100644 --- a/App/UI/Editor/SyntaxHighlighter.swift +++ b/App/UI/Editor/SyntaxHighlighter.swift @@ -3,16 +3,16 @@ import GuerkchenCore enum SyntaxHighlighter { @MainActor - static func highlight(storage: NSTextStorage, palette: HighlightPalette, font: NSFont) { + static func highlight(storage: NSTextStorage, theme: EditorTheme) { let text = storage.string let dialect = LineScanner.dialect(for: text) let full = NSRange(location: 0, length: storage.length) storage.beginEditing() - storage.addAttributes([.font: font, .foregroundColor: NSColor.textColor], range: full) + storage.addAttributes([.font: theme.font, .foregroundColor: theme.textColor], range: full) for line in LineScanner.scan(text, dialect: dialect) { guard let category = HighlightCategory.forLine(line.kind) else { continue } - let color = NSColor(hex: palette[category] ?? category.defaultHex) ?? .textColor + let color = theme.color(for: category) let range: Range if case let .keyword(_, keywordRange, _) = line.kind { range = keywordRange diff --git a/App/UI/Settings/SettingsView.swift b/App/UI/Settings/SettingsView.swift index 7f10964..6613cd3 100644 --- a/App/UI/Settings/SettingsView.swift +++ b/App/UI/Settings/SettingsView.swift @@ -1,22 +1,81 @@ +import AppKit import SwiftUI struct SettingsView: View { - @Environment(ColorSettings.self) private var colors + @Environment(AppearanceSettings.self) private var appearance + /// Die Schriftliste zeigt normalerweise nur Monospace-Familien – für einen Editor die + /// sinnvolle Auswahl, aber nicht jeder will sich darauf festlegen lassen. + @AppStorage("fontPickerShowsAllFonts") private var showsAllFonts = false var body: some View { Form { + Section("Schrift") { + Picker("Schriftart", selection: appearance.fontFamilyBinding) { + Text("Systemschrift (Monospace)").tag(String?.none) + Divider() + ForEach(fontFamilies, id: \.self) { family in + Text(family).tag(String?.some(family)) + } + } + Toggle("Alle Schriften anzeigen", isOn: $showsAllFonts) + Stepper("Schriftgröße: \(Int(appearance.theme.fontSize)) pt", + value: appearance.fontSizeBinding, + in: AppearanceSettings.fontSizeRange, + step: 1) + } + + Section("Hintergrund") { + Toggle("Systemhintergrund verwenden", isOn: appearance.usesSystemBackgroundBinding) + ColorPicker("Hintergrundfarbe", selection: appearance.backgroundBinding, supportsOpacity: false) + .disabled(appearance.usesSystemBackground) + } + Section("Farben der Schlüsselwörter") { ForEach(HighlightCategory.allCases) { category in - ColorPicker(category.title, selection: colors.binding(for: category), supportsOpacity: false) + ColorPicker(category.title, selection: appearance.binding(for: category), supportsOpacity: false) } } + + Section("Vorschau") { + preview + } + Section { - Button("Auf Standardfarben zurücksetzen") { colors.reset() } + Button("Auf Standardfarben zurücksetzen") { appearance.resetColors() } + Button("Schrift und Hintergrund zurücksetzen") { appearance.resetFontAndBackground() } } } .formStyle(.grouped) - .frame(width: 420) - .padding(.bottom) - .fixedSize(horizontal: false, vertical: true) + // Feste Größe: die Liste ist zu lang geworden, um das Fenster mitwachsen zu lassen. + .frame(width: 440, height: 560) + } + + private var preview: some View { + let theme = appearance.theme + return VStack(alignment: .leading, spacing: 2) { + Text("Szenario: Anmeldung") + .foregroundStyle(Color(nsColor: theme.color(for: .scenario))) + Text(" Angenommen ein Benutzer ist angemeldet") + .foregroundStyle(Color(nsColor: theme.color(for: .step))) + Text(" # ein Kommentar") + .foregroundStyle(Color(nsColor: theme.color(for: .comment))) + } + .font(Font(theme.font)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color(nsColor: theme.backgroundColor), in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(.separator)) + } + + private var fontFamilies: [String] { + var families = showsAllFonts ? FontCatalog.allFamilies : FontCatalog.monospacedFamilies + // Eine zuvor gewählte Proportionalschrift darf nicht aus der Liste fallen, + // sonst stünde der Picker leer da. + if let current = appearance.theme.fontFamily, !families.contains(current) { + families.append(current) + families.sort() + } + return families } } diff --git a/App/UI/Theme/AppearanceSettings.swift b/App/UI/Theme/AppearanceSettings.swift new file mode 100644 index 0000000..128c5b4 --- /dev/null +++ b/App/UI/Theme/AppearanceSettings.swift @@ -0,0 +1,150 @@ +import AppKit +import Observation +import SwiftUI + +typealias HighlightPalette = [HighlightCategory: String] + +@MainActor +@Observable +final class AppearanceSettings { + private static let colorKeyPrefix = "highlightColor." + private static let backgroundKey = "editorBackgroundColor" + private static let fontFamilyKey = "editorFontFamily" + private static let fontSizeKey = "editorFontSize" + + static let defaultFontSize: Double = 13 + static let fontSizeRange: ClosedRange = 9...32 + + private(set) var theme = EditorTheme(palette: [:], backgroundHex: nil, fontFamily: nil, + fontSize: AppearanceSettings.defaultFontSize) + @ObservationIgnored private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } + + // MARK: - Farben + + func color(for category: HighlightCategory) -> NSColor { + theme.color(for: category) + } + + func setColor(_ color: NSColor, for category: HighlightCategory) { + let hex = color.hexString + theme.palette[category] = hex + defaults.set(hex, forKey: Self.colorKeyPrefix + category.rawValue) + } + + func binding(for category: HighlightCategory) -> Binding { + Binding( + get: { Color(nsColor: self.color(for: category)) }, + set: { self.setColor(NSColor($0), for: category) }) + } + + func resetColors() { + for category in HighlightCategory.allCases { + defaults.removeObject(forKey: Self.colorKeyPrefix + category.rawValue) + } + load() + } + + // MARK: - Hintergrund + + var usesSystemBackground: Bool { theme.backgroundHex == nil } + + func setBackgroundHex(_ hex: String?) { + theme.backgroundHex = hex + if let hex { + defaults.set(hex, forKey: Self.backgroundKey) + } else { + defaults.removeObject(forKey: Self.backgroundKey) + } + } + + var backgroundBinding: Binding { + Binding( + get: { Color(nsColor: self.theme.backgroundColor) }, + set: { self.setBackgroundHex(NSColor($0).hexString) }) + } + + var usesSystemBackgroundBinding: Binding { + Binding( + get: { self.usesSystemBackground }, + // Beim Wechsel auf eine eigene Farbe mit dem aktuellen Systemhintergrund starten, + // damit der Editor nicht plötzlich anders aussieht. + set: { self.setBackgroundHex($0 ? nil : Self.systemBackgroundHex()) }) + } + + // MARK: - Schrift + + func setFontFamily(_ family: String?) { + theme.fontFamily = family + if let family { + defaults.set(family, forKey: Self.fontFamilyKey) + } else { + defaults.removeObject(forKey: Self.fontFamilyKey) + } + } + + func setFontSize(_ size: Double) { + let clamped = min(max(size.rounded(), Self.fontSizeRange.lowerBound), Self.fontSizeRange.upperBound) + theme.fontSize = clamped + defaults.set(clamped, forKey: Self.fontSizeKey) + } + + var fontFamilyBinding: Binding { + Binding( + get: { self.theme.fontFamily }, + set: { self.setFontFamily($0) }) + } + + var fontSizeBinding: Binding { + Binding( + get: { self.theme.fontSize }, + set: { self.setFontSize($0) }) + } + + func resetFontAndBackground() { + defaults.removeObject(forKey: Self.fontFamilyKey) + defaults.removeObject(forKey: Self.fontSizeKey) + defaults.removeObject(forKey: Self.backgroundKey) + load() + } + + // MARK: - Private + + private func load() { + var palette: HighlightPalette = [:] + for category in HighlightCategory.allCases { + if let stored = defaults.string(forKey: Self.colorKeyPrefix + category.rawValue), NSColor(hex: stored) != nil { + palette[category] = stored + } else { + palette[category] = category.defaultHex + } + } + + var backgroundHex: String? + if let stored = defaults.string(forKey: Self.backgroundKey), NSColor(hex: stored) != nil { + backgroundHex = stored + } + + var fontFamily = defaults.string(forKey: Self.fontFamilyKey) + if fontFamily?.isEmpty == true { fontFamily = nil } + + let storedSize = defaults.double(forKey: Self.fontSizeKey) + let fontSize = Self.fontSizeRange.contains(storedSize) ? storedSize : Self.defaultFontSize + + theme = EditorTheme(palette: palette, backgroundHex: backgroundHex, fontFamily: fontFamily, fontSize: fontSize) + } + + /// `NSColor.textBackgroundColor` ist eine dynamische Farbe – sie muss im aktuellen + /// Erscheinungsbild aufgelöst werden, sonst käme im Dunkelmodus die helle Variante heraus. + private static func systemBackgroundHex() -> String { + var hex = "#FFFFFF" + NSApplication.shared.effectiveAppearance.performAsCurrentDrawingAppearance { + if let resolved = NSColor.textBackgroundColor.usingColorSpace(.sRGB) { hex = resolved.hexString } + } + return hex + } +} diff --git a/App/UI/Theme/ColorSettings.swift b/App/UI/Theme/ColorSettings.swift deleted file mode 100644 index fc19d8f..0000000 --- a/App/UI/Theme/ColorSettings.swift +++ /dev/null @@ -1,54 +0,0 @@ -import AppKit -import Observation -import SwiftUI - -typealias HighlightPalette = [HighlightCategory: String] - -@MainActor -@Observable -final class ColorSettings { - private static let keyPrefix = "highlightColor." - - private(set) var palette: HighlightPalette = [:] - @ObservationIgnored private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - load() - } - - func color(for category: HighlightCategory) -> NSColor { - NSColor(hex: palette[category] ?? category.defaultHex) ?? .textColor - } - - func setColor(_ color: NSColor, for category: HighlightCategory) { - let hex = color.hexString - palette[category] = hex - defaults.set(hex, forKey: Self.keyPrefix + category.rawValue) - } - - func reset() { - for category in HighlightCategory.allCases { - defaults.removeObject(forKey: Self.keyPrefix + category.rawValue) - } - load() - } - - func binding(for category: HighlightCategory) -> Binding { - Binding( - get: { Color(nsColor: self.color(for: category)) }, - set: { self.setColor(NSColor($0), for: category) }) - } - - private func load() { - var result: HighlightPalette = [:] - for category in HighlightCategory.allCases { - if let stored = defaults.string(forKey: Self.keyPrefix + category.rawValue), NSColor(hex: stored) != nil { - result[category] = stored - } else { - result[category] = category.defaultHex - } - } - palette = result - } -} diff --git a/App/UI/Theme/EditorTheme.swift b/App/UI/Theme/EditorTheme.swift new file mode 100644 index 0000000..82e31dc --- /dev/null +++ b/App/UI/Theme/EditorTheme.swift @@ -0,0 +1,43 @@ +import AppKit + +/// Alles, was das Aussehen des Editors bestimmt – als reiner Wert, damit sich billig +/// vergleichen lässt, ob neu gezeichnet werden muss. +struct EditorTheme: Equatable { + var palette: HighlightPalette + /// `nil` = Systemhintergrund, der Hell und Dunkel selbst folgt. + var backgroundHex: String? + /// `nil` = Systemschrift (Monospace). + var fontFamily: String? + var fontSize: Double + + var font: NSFont { + let size = CGFloat(fontSize) + // Gewicht 5 ist der reguläre Schnitt der Familie; fehlt die Schrift (deinstalliert, + // Projekt auf einem anderen Rechner geöffnet), bleibt es bei der Systemschrift. + guard let fontFamily, + let resolved = NSFontManager.shared.font(withFamily: fontFamily, traits: [], weight: 5, size: size) + else { return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } + return resolved + } + + /// Die gewählte Hintergrundfarbe, oder `nil`, solange der Systemhintergrund gilt. + var customBackgroundColor: NSColor? { + guard let backgroundHex else { return nil } + return NSColor(hex: backgroundHex) + } + + var backgroundColor: NSColor { + customBackgroundColor ?? .textBackgroundColor + } + + /// `.textColor` folgt dem System-Erscheinungsbild und wäre auf einem eigenen Hintergrund + /// schnell unlesbar – deshalb Schwarz oder Weiß passend zu dessen Helligkeit. + var textColor: NSColor { + guard let custom = customBackgroundColor else { return .textColor } + return custom.isDark ? .white : .black + } + + func color(for category: HighlightCategory) -> NSColor { + NSColor(hex: palette[category] ?? category.defaultHex) ?? textColor + } +} diff --git a/App/UI/Theme/FontCatalog.swift b/App/UI/Theme/FontCatalog.swift new file mode 100644 index 0000000..fdeb1e1 --- /dev/null +++ b/App/UI/Theme/FontCatalog.swift @@ -0,0 +1,16 @@ +import AppKit + +/// Die Schriftfamilien für die Auswahl in den Einstellungen. Die Abfrage beim NSFontManager +/// ist teuer genug, um sie nur einmal zu machen. +enum FontCatalog { + static let monospacedFamilies: [String] = { + let names = NSFontManager.shared.availableFontNames(with: .fixedPitchFontMask) ?? [] + var families = Set() + for name in names { + if let family = NSFont(name: name, size: 12)?.familyName { families.insert(family) } + } + return families.sorted() + }() + + static let allFamilies: [String] = NSFontManager.shared.availableFontFamilies.sorted() +} diff --git a/App/UI/Theme/NSColor+Hex.swift b/App/UI/Theme/NSColor+Hex.swift index 552d260..11f6400 100644 --- a/App/UI/Theme/NSColor+Hex.swift +++ b/App/UI/Theme/NSColor+Hex.swift @@ -19,4 +19,11 @@ extension NSColor { let b = Int(round(c.blueComponent * 255)) return String(format: "#%02X%02X%02X", r, g, b) } + + /// Helligkeit nach Rec. 601 – entscheidet, ob heller oder dunkler Text darauf lesbar ist. + var isDark: Bool { + guard let c = usingColorSpace(.sRGB) ?? usingColorSpace(.deviceRGB) else { return false } + let brightness = 0.299 * c.redComponent + 0.587 * c.greenComponent + 0.114 * c.blueComponent + return brightness < 0.55 + } } diff --git a/README.md b/README.md index b90a352..2a7bfcc 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ them: into a prompt, into a repository, or into a real Cucumber setup. - **Syntax highlighting** for eight categories: Feature/Rule, Background, Scenario/Outline/Examples, steps, comments, tags, tables and doc strings — every color configurable in Settings, with a reset to the defaults. +- **Appearance in Settings.** Font family (monospaced families by default, every installed font on + request), font size and the editor background color — with a live preview and a reset. The + background follows the system appearance until you pick a color of your own. - **Autosave.** About a second after the last edit, when switching files, and on quit. - **Recent projects.** The last project reopens on launch; the last ten are listed in the menu. - **Sandboxed**, with no network access and no third-party dependencies.