diff --git a/Sources/SaplingEditor/EditorArchitecture.swift b/Sources/SaplingEditor/EditorArchitecture.swift index 7c7a63f..590604e 100644 --- a/Sources/SaplingEditor/EditorArchitecture.swift +++ b/Sources/SaplingEditor/EditorArchitecture.swift @@ -106,6 +106,13 @@ public struct EditorState: Hashable, Sendable { document.hasUnsavedChanges } + public var activeColumnNumber: Int { + guard lines.indices.contains(activeLineIndex) else { return 1 } + let activeLine = lines[activeLineIndex] + let offset = selection.location - activeLine.range.location + return max(0, min(offset, activeLine.range.length)) + 1 + } + public mutating func updateSource(_ source: String) { document.source = source activeLineIndex = Self.lineIndex(containing: selection.location, in: source) diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index a610690..29deb31 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -91,42 +91,35 @@ public struct HybridMarkdownEditor: View, EditorView { EditorStatusBar( activeLineIndex: viewModel.state.activeLineIndex, + columnNumber: viewModel.state.activeColumnNumber, lineCount: viewModel.state.lines.count, - hasUnsavedChanges: viewModel.state.hasUnsavedChanges, - activeLinePreview: activeLinePreview + hasUnsavedChanges: viewModel.state.hasUnsavedChanges ) } .background(platformTextBackground) } - - private var activeLinePreview: AttributedString { - guard viewModel.state.lines.indices.contains(viewModel.state.activeLineIndex) else { - return AttributedString() - } - return renderer.inlineMarkdown(for: viewModel.state.lines[viewModel.state.activeLineIndex].source) - } } private struct EditorStatusBar: View { let activeLineIndex: Int + let columnNumber: Int let lineCount: Int let hasUnsavedChanges: Bool - let activeLinePreview: AttributedString var body: some View { HStack(spacing: 12) { - Text("Line \(activeLineIndex + 1) of \(lineCount)") + Text("Line \(activeLineIndex + 1)") + Text("Column \(columnNumber)") + Text("\(lineCount) lines") Text(hasUnsavedChanges ? "Modified" : "Saved") .foregroundStyle(hasUnsavedChanges ? .orange : .secondary) Spacer() - Text(activeLinePreview) - .lineLimit(1) - .foregroundStyle(.secondary) } - .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.bar) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(.thinMaterial) } } @@ -141,10 +134,28 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - guard let textView = scrollView.documentView as? NSTextView else { - return scrollView - } + let scrollView = ComfortableEditorScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = true + scrollView.backgroundColor = .textBackgroundColor + + let textStorage = NSTextStorage() + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) + textContainer.widthTracksTextView = true + textContainer.heightTracksTextView = false + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + + let textView = NSTextView(frame: scrollView.contentView.bounds, textContainer: textContainer) + textView.autoresizingMask = [.width] + textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height) + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false textView.delegate = context.coordinator textView.string = text @@ -155,16 +166,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable { textView.allowsUndo = true textView.usesFindPanel = true textView.isContinuousSpellCheckingEnabled = true - textView.textContainerInset = NSSize(width: 20, height: 18) textView.backgroundColor = .textBackgroundColor textView.insertionPointColor = .controlAccentColor - textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular) + textView.font = .systemFont(ofSize: 16, weight: .regular) textView.textContainer?.widthTracksTextView = true textView.textContainer?.containerSize = NSSize( width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude ) + scrollView.documentView = textView + scrollView.editorTextView = textView + scrollView.updateEditorInsets() context.coordinator.applyHybridAttributes(to: textView) return scrollView } @@ -246,6 +259,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { guard textView.selectedRange() != range else { return } performProgrammaticUpdate { textView.setSelectedRange(range) + textView.scrollRangeToVisible(range) } } @@ -266,6 +280,27 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } } } + +private final class ComfortableEditorScrollView: NSScrollView { + weak var editorTextView: NSTextView? + + override func layout() { + super.layout() + updateEditorInsets() + } + + func updateEditorInsets() { + guard let editorTextView else { return } + + let readableWidth: CGFloat = 760 + let horizontalInset = max(36, floor((contentView.bounds.width - readableWidth) / 2)) + let targetInset = NSSize(width: horizontalInset, height: 38) + + if editorTextView.textContainerInset != targetInset { + editorTextView.textContainerInset = targetInset + } + } +} #elseif os(iOS) private struct NativeMarkdownTextView: UIViewRepresentable { @Binding var text: String @@ -284,8 +319,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable { textView.autocorrectionType = .yes textView.smartDashesType = .no textView.smartQuotesType = .no - textView.font = .monospacedSystemFont(ofSize: 15, weight: .regular) - textView.textContainerInset = UIEdgeInsets(top: 18, left: 16, bottom: 18, right: 16) + textView.font = .systemFont(ofSize: 17, weight: .regular) + textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24) textView.backgroundColor = .systemBackground context.coordinator.applyHybridAttributes(to: textView) return textView @@ -344,6 +379,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable { if textView.selectedRange != selectedRange, selectedRange.location <= textView.text.utf16.count { textView.selectedRange = selectedRange + textView.scrollRangeToVisible(selectedRange) } } @@ -397,7 +433,7 @@ private enum MarkdownTextStyler { if line.index == activeLineIndex { textStorage.addAttributes([ .backgroundColor: activeLineBackgroundColor, - .font: monospacedFont(size: 14, weight: .regular) + .font: monospacedFont(size: 15, weight: .regular) ], range: line.range) styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor) } else { @@ -431,7 +467,7 @@ private enum MarkdownTextStyler { if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) { textStorage.addAttributes([ .foregroundColor: secondaryTextColor, - .font: monospacedFont(size: 12, weight: .regular) + .font: monospacedFont(size: 13, weight: .regular) ], range: heading.markerRange) textStorage.addAttributes([ .font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold) @@ -442,7 +478,7 @@ private enum MarkdownTextStyler { if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") { textStorage.addAttributes([ .foregroundColor: secondaryTextColor, - .font: monospacedFont(size: 13, weight: .regular) + .font: monospacedFont(size: 14, weight: .regular) ], range: NSRange(location: line.range.location, length: min(6, line.range.length))) } @@ -464,7 +500,7 @@ private enum MarkdownTextStyler { ) { applyRegex("\\*\\*([^*]+)\\*\\*", in: textStorage, line: line) { match in guard match.numberOfRanges > 1 else { return } - textStorage.addAttributes([.font: systemFont(size: 14, weight: .semibold)], range: match.range(at: 1)) + textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: match.range(at: 1)) markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach { textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0) } @@ -472,7 +508,7 @@ private enum MarkdownTextStyler { applyRegex("(? 1 else { return } - textStorage.addAttributes([.font: italicSystemFont(size: 14)], range: match.range(at: 1)) + textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: match.range(at: 1)) markdownDelimiterRanges(match.range, leading: 1, trailing: 1).forEach { textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0) } @@ -509,9 +545,9 @@ private enum MarkdownTextStyler { private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] { let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 4 - paragraph.paragraphSpacing = 3 + paragraph.paragraphSpacing = 5 return [ - .font: systemFont(size: 14, weight: .regular), + .font: systemFont(size: 16, weight: .regular), .foregroundColor: textColor, .paragraphStyle: paragraph ] @@ -564,10 +600,10 @@ private enum MarkdownTextStyler { private static func headingFontSize(level: Int) -> CGFloat { switch level { - case 1: 24 - case 2: 20 - case 3: 18 - default: 15 + case 1: 28 + case 2: 23 + case 3: 20 + default: 17 } } diff --git a/Tests/SaplingEditorTests/EditorStateTests.swift b/Tests/SaplingEditorTests/EditorStateTests.swift index 1fb9807..270043f 100644 --- a/Tests/SaplingEditorTests/EditorStateTests.swift +++ b/Tests/SaplingEditorTests/EditorStateTests.swift @@ -39,6 +39,22 @@ final class EditorStateTests: XCTestCase { XCTAssertFalse(state.hasUnsavedChanges) } + func testActiveColumnFollowsSelectionWithinLine() { + let source = "First line\nSecond line" + let document = EditorDocument( + url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"), + title: "EditorStateTests", + source: source + ) + var state = EditorState(document: document) + + let secondLineLocation = (source as NSString).range(of: "Second").location + state.updateSelection(EditorSelection(location: secondLineLocation + 3, length: 0)) + + XCTAssertEqual(state.activeLineIndex, 1) + XCTAssertEqual(state.activeColumnNumber, 4) + } + @MainActor func testViewModelSavesDocumentToDisk() throws { let directory = FileManager.default.temporaryDirectory