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
}
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)

View file

@ -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("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
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 {
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
}
}

View file

@ -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