feat(editor): improve writing layout

This commit is contained in:
Feror 2026-05-29 19:19:59 +02:00
parent 0b01178f24
commit 820099ddad
3 changed files with 96 additions and 37 deletions

View file

@ -106,6 +106,13 @@ public struct EditorState: Hashable, Sendable {
document.hasUnsavedChanges 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) { public mutating func updateSource(_ source: String) {
document.source = source document.source = source
activeLineIndex = Self.lineIndex(containing: selection.location, in: source) activeLineIndex = Self.lineIndex(containing: selection.location, in: source)

View file

@ -91,42 +91,35 @@ public struct HybridMarkdownEditor: View, EditorView {
EditorStatusBar( EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex, activeLineIndex: viewModel.state.activeLineIndex,
columnNumber: viewModel.state.activeColumnNumber,
lineCount: viewModel.state.lines.count, lineCount: viewModel.state.lines.count,
hasUnsavedChanges: viewModel.state.hasUnsavedChanges, hasUnsavedChanges: viewModel.state.hasUnsavedChanges
activeLinePreview: activeLinePreview
) )
} }
.background(platformTextBackground) .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 { private struct EditorStatusBar: View {
let activeLineIndex: Int let activeLineIndex: Int
let columnNumber: Int
let lineCount: Int let lineCount: Int
let hasUnsavedChanges: Bool let hasUnsavedChanges: Bool
let activeLinePreview: AttributedString
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Text("Line \(activeLineIndex + 1) of \(lineCount)") Text("Line \(activeLineIndex + 1)")
Text("Column \(columnNumber)")
Text("\(lineCount) lines")
Text(hasUnsavedChanges ? "Modified" : "Saved") Text(hasUnsavedChanges ? "Modified" : "Saved")
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary) .foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
Spacer() Spacer()
Text(activeLinePreview)
.lineLimit(1)
.foregroundStyle(.secondary)
} }
.font(.caption) .font(.caption.weight(.medium))
.padding(.horizontal, 12) .foregroundStyle(.secondary)
.padding(.vertical, 6) .padding(.horizontal, 14)
.background(.bar) .padding(.vertical, 7)
.background(.thinMaterial)
} }
} }
@ -141,10 +134,28 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
} }
func makeNSView(context: Context) -> NSScrollView { func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView() let scrollView = ComfortableEditorScrollView()
guard let textView = scrollView.documentView as? NSTextView else { scrollView.hasVerticalScroller = true
return scrollView 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.delegate = context.coordinator
textView.string = text textView.string = text
@ -155,16 +166,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
textView.allowsUndo = true textView.allowsUndo = true
textView.usesFindPanel = true textView.usesFindPanel = true
textView.isContinuousSpellCheckingEnabled = true textView.isContinuousSpellCheckingEnabled = true
textView.textContainerInset = NSSize(width: 20, height: 18)
textView.backgroundColor = .textBackgroundColor textView.backgroundColor = .textBackgroundColor
textView.insertionPointColor = .controlAccentColor textView.insertionPointColor = .controlAccentColor
textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular) textView.font = .systemFont(ofSize: 16, weight: .regular)
textView.textContainer?.widthTracksTextView = true textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize( textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width, width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude height: CGFloat.greatestFiniteMagnitude
) )
scrollView.documentView = textView
scrollView.editorTextView = textView
scrollView.updateEditorInsets()
context.coordinator.applyHybridAttributes(to: textView) context.coordinator.applyHybridAttributes(to: textView)
return scrollView return scrollView
} }
@ -246,6 +259,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
guard textView.selectedRange() != range else { return } guard textView.selectedRange() != range else { return }
performProgrammaticUpdate { performProgrammaticUpdate {
textView.setSelectedRange(range) 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) #elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable { private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String @Binding var text: String
@ -284,8 +319,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
textView.autocorrectionType = .yes textView.autocorrectionType = .yes
textView.smartDashesType = .no textView.smartDashesType = .no
textView.smartQuotesType = .no textView.smartQuotesType = .no
textView.font = .monospacedSystemFont(ofSize: 15, weight: .regular) textView.font = .systemFont(ofSize: 17, weight: .regular)
textView.textContainerInset = UIEdgeInsets(top: 18, left: 16, bottom: 18, right: 16) textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
textView.backgroundColor = .systemBackground textView.backgroundColor = .systemBackground
context.coordinator.applyHybridAttributes(to: textView) context.coordinator.applyHybridAttributes(to: textView)
return textView return textView
@ -344,6 +379,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
if textView.selectedRange != selectedRange, if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count { selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange textView.selectedRange = selectedRange
textView.scrollRangeToVisible(selectedRange)
} }
} }
@ -397,7 +433,7 @@ private enum MarkdownTextStyler {
if line.index == activeLineIndex { if line.index == activeLineIndex {
textStorage.addAttributes([ textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor, .backgroundColor: activeLineBackgroundColor,
.font: monospacedFont(size: 14, weight: .regular) .font: monospacedFont(size: 15, weight: .regular)
], range: line.range) ], range: line.range)
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor) styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
} else { } else {
@ -431,7 +467,7 @@ private enum MarkdownTextStyler {
if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) { if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) {
textStorage.addAttributes([ textStorage.addAttributes([
.foregroundColor: secondaryTextColor, .foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 12, weight: .regular) .font: monospacedFont(size: 13, weight: .regular)
], range: heading.markerRange) ], range: heading.markerRange)
textStorage.addAttributes([ textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold) .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("- [ ] ") { if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") {
textStorage.addAttributes([ textStorage.addAttributes([
.foregroundColor: secondaryTextColor, .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))) ], 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 applyRegex("\\*\\*([^*]+)\\*\\*", in: textStorage, line: line) { match in
guard match.numberOfRanges > 1 else { return } 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 { markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0) textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
} }
@ -472,7 +508,7 @@ private enum MarkdownTextStyler {
applyRegex("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in applyRegex("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
guard match.numberOfRanges > 1 else { return } guard match.numberOfRanges > 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 { markdownDelimiterRanges(match.range, leading: 1, trailing: 1).forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0) textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
} }
@ -509,9 +545,9 @@ private enum MarkdownTextStyler {
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] { private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
let paragraph = NSMutableParagraphStyle() let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4 paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 3 paragraph.paragraphSpacing = 5
return [ return [
.font: systemFont(size: 14, weight: .regular), .font: systemFont(size: 16, weight: .regular),
.foregroundColor: textColor, .foregroundColor: textColor,
.paragraphStyle: paragraph .paragraphStyle: paragraph
] ]
@ -564,10 +600,10 @@ private enum MarkdownTextStyler {
private static func headingFontSize(level: Int) -> CGFloat { private static func headingFontSize(level: Int) -> CGFloat {
switch level { switch level {
case 1: 24 case 1: 28
case 2: 20 case 2: 23
case 3: 18 case 3: 20
default: 15 default: 17
} }
} }

View file

@ -39,6 +39,22 @@ final class EditorStateTests: XCTestCase {
XCTAssertFalse(state.hasUnsavedChanges) 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 @MainActor
func testViewModelSavesDocumentToDisk() throws { func testViewModelSavesDocumentToDisk() throws {
let directory = FileManager.default.temporaryDirectory let directory = FileManager.default.temporaryDirectory