Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions App/App/GuerkchenApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -37,7 +37,7 @@ struct GuerkchenApp: App {

Settings {
SettingsView()
.environment(colors)
.environment(appearance)
}
}
}
4 changes: 2 additions & 2 deletions App/UI/Editor/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) ?? []
}
}
Expand Down
49 changes: 35 additions & 14 deletions App/UI/Editor/GherkinTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion App/UI/Editor/SuggestController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 15 additions & 7 deletions App/UI/Editor/SuggestPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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())
Expand All @@ -49,14 +53,17 @@ 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<SuggestListView>

var selectedSuggestion: Suggestion? {
suggestions.indices.contains(selectedIndex) ? suggestions[selectedIndex] : nil
}

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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions App/UI/Editor/SyntaxHighlighter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String.Index>
if case let .keyword(_, keywordRange, _) = line.kind {
range = keywordRange
Expand Down
71 changes: 65 additions & 6 deletions App/UI/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading