oficina / docs Public

Restore shared formatting bar and keep paginated caret geometry stable

Paul Campbell pushed by paul@keymail.dev 0d7adf8eade6fc8499bd0fcc7d453afb47902f45 parent e2919ff
11 files changed, +205 −43
  • native/apple/Sources/DocsKit/DocsLayoutManager.swift +0 −1
  • native/apple/Sources/DocsKit/DocsPagedTextView.swift +53 −21
  • native/apple/Sources/DocsKit/DocsWorkspace.swift +1 −2
  • native/apple/Sources/DocsKit/DocumentCommands.swift +1 −0
  • native/apple/Sources/DocsKit/DocumentPageLayout.swift +13 −0
  • native/apple/Sources/DocsKit/DocumentToolbarSelection.swift +16 −0
  • native/apple/Sources/DocsKit/EditorToolbar.swift +21 −13
  • native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift +51 −3
  • native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift +22 −0
  • native/scripts/test-document-window.py +9 −2
  • native/smoke/document-window/Sources/Smoke.swift +18 −1

CI passed — run details

diff --git a/native/apple/Sources/DocsKit/DocsLayoutManager.swift b/native/apple/Sources/DocsKit/DocsLayoutManager.swift
index 699d4b3..206380b 100644
--- a/native/apple/Sources/DocsKit/DocsLayoutManager.swift
+++ b/native/apple/Sources/DocsKit/DocsLayoutManager.swift
@@ -17,7 +17,6 @@ extension NSAttributedString.Key {
changeInLength: delta, invalidatedRange: invalidatedCharRange)
storageRevision &+= 1
for container in textContainers {
- (container as? DocsPageTextContainer)?.blockStarts = [:]
(container.textView as? DocsPagedTextView)?.invalidatePagination()
}
}
diff --git a/native/apple/Sources/DocsKit/DocsPagedTextView.swift b/native/apple/Sources/DocsKit/DocsPagedTextView.swift
index a539668..0e7b223 100644
--- a/native/apple/Sources/DocsKit/DocsPagedTextView.swift
+++ b/native/apple/Sources/DocsKit/DocsPagedTextView.swift
@@ -5,13 +5,17 @@ import AppKit
@MainActor class DocsPagedTextView: NSTextView {
var paged = false { didSet { if oldValue != paged { needsLayout = true; needsDisplay = true } } }
private var updatingGeometry = false
+ private var updatingPagination = false
private var separators: [CGFloat] = []
private var measuredRevision: UInt64?
private var measuredWidth: CGFloat = 0
private var measuredPaged = false
+ private var measuredContent: NSAttributedString?
func invalidatePagination() {
measuredRevision = nil
- if !separators.isEmpty { separators = []; needsDisplay = true }
+ // TextKit may ask for caret geometry before the next NSView layout.
+ // Prepare the complete page plan now, on an independent layout stack.
+ if !updatingGeometry { updatePagination() }
needsLayout = true
}
override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
@@ -26,33 +30,59 @@ import AppKit
textContainerInset = .zero
isVerticallyResizable = true
isHorizontallyResizable = false
- drawsBackground = false
+ drawsBackground = true
textColor = .black
}
override convenience init(frame: NSRect) { self.init(frame: frame, textContainer: nil) }
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
+ private var viewportWidth: CGFloat { max(636, enclosingScrollView?.contentView.bounds.width ?? bounds.width) }
+ private func updatePagination() {
+ guard !updatingPagination else { return }
+ updatingPagination = true
+ defer { updatingPagination = false }
+ guard let pages = textContainer as? DocsPageTextContainer,
+ let manager = layoutManager as? DocsLayoutManager, let storage = textStorage else { return }
+ let width = viewportWidth
+ let size = CGSize(width: width, height: 1_000_000)
+ if pages.size != size { pages.size = size }
+ pages.geometry = DocsPageGeometry(viewportWidth: width, paged: false)
+ let revision = manager.storageRevision
+ guard measuredRevision != revision || measuredWidth != width || measuredPaged != paged else { return }
+ let content = paged ? DocumentPageLayout.measurementContent(storage) : nil
+ if measuredWidth == width, measuredPaged == paged, let content, measuredContent?.isEqual(to: content) == true {
+ measuredRevision = revision
+ return
+ }
+ var plan = DocumentPageLayout.Result()
+ if let content {
+ // Never clear the live container to measure unpaginated content: doing
+ // so exposes a second set of caret/scroll coordinates during edits.
+ let measurement = NSTextStorage(attributedString: content)
+ let measurementManager = DocsLayoutManager()
+ let measurementContainer = DocsPageTextContainer(size: size)
+ measurementContainer.lineFragmentPadding = 0
+ measurementContainer.geometry = pages.geometry
+ measurement.addLayoutManager(measurementManager)
+ measurementManager.addTextContainer(measurementContainer)
+ measurementManager.ensureLayout(for: measurementContainer)
+ plan = DocumentPageLayout.measure(storage: measurement, manager: measurementManager, container: measurementContainer)
+ }
+ if pages.blockStarts != plan.starts || separators != plan.separators { needsDisplay = true }
+ pages.blockStarts = plan.starts
+ separators = plan.separators
+ measuredRevision = revision; measuredWidth = width; measuredPaged = paged
+ measuredContent = content
+ }
override func layout() {
- super.layout()
guard !updatingGeometry, let pages = textContainer as? DocsPageTextContainer,
let manager = layoutManager else { return }
updatingGeometry = true
defer { updatingGeometry = false }
- let width = max(636, enclosingScrollView?.contentView.bounds.width ?? bounds.width)
- let size = CGSize(width: width, height: 1_000_000)
- if pages.size != size { pages.size = size }
- pages.geometry = DocsPageGeometry(viewportWidth: width, paged: false)
- let revision = (manager as? DocsLayoutManager)?.storageRevision
- if measuredRevision != revision || measuredWidth != width || measuredPaged != paged {
- pages.blockStarts = [:]
- manager.ensureLayout(for: pages)
- if paged, let storage = textStorage {
- let result = DocumentPageLayout.measure(storage: storage, manager: manager, container: pages)
- pages.blockStarts = result.starts
- if separators != result.separators { separators = result.separators; needsDisplay = true }
- manager.ensureLayout(for: pages)
- } else if !separators.isEmpty { separators = []; needsDisplay = true }
- measuredRevision = revision; measuredWidth = width; measuredPaged = paged
- }
+ updatePagination()
+ // AppKit must only lay out the caret after final page coordinates exist.
+ super.layout()
+ manager.ensureLayout(for: pages)
+ let width = viewportWidth
let bottom = manager.numberOfGlyphs > 0
? manager.lineFragmentRect(forGlyphAt: manager.numberOfGlyphs - 1, effectiveRange: nil).maxY : DocsPageGeometry.verticalMargin
let height = pages.geometry.documentHeight(usedHeight: bottom)
@@ -60,7 +90,10 @@ import AppKit
setFrameSize(CGSize(width: width, height: height))
}
}
- override func draw(_ dirtyRect: NSRect) {
+ override func drawBackground(in dirtyRect: NSRect) {
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+ dirtyRect.clip()
NSColor.windowBackgroundColor.setFill()
dirtyRect.fill()
if let pages = textContainer as? DocsPageTextContainer {
@@ -78,7 +111,6 @@ import AppKit
}
}
}
- super.draw(dirtyRect)
}
}
#else
diff --git a/native/apple/Sources/DocsKit/DocsWorkspace.swift b/native/apple/Sources/DocsKit/DocsWorkspace.swift
index aa49955..55333b6 100644
--- a/native/apple/Sources/DocsKit/DocsWorkspace.swift
+++ b/native/apple/Sources/DocsKit/DocsWorkspace.swift
@@ -111,9 +111,8 @@ import SwiftUI
if let projection = model.projection {
let documentPath = "/d/" + (model.detail?.sqid ?? "")
let text = NativeTextProjection.render(projection, images: images.images(forDocumentPath: documentPath, client: client))
- #if os(iOS)
EditorToolbar(model: model, content: text)
- #endif
+ Divider()
NativeDocumentTextView(content: text, generation: model.projectionGeneration, didRender: model.didRender, compositionChanged: model.compositionChanged, isEditable: model.detail?.can_write == true && !model.inputPaused, paged: paged, pendingInput: model.hasPendingInput, canRender: model.canRender, currentSelection: model.currentSelection, selection: $model.selection, edit: model.enqueue, action: { action in
if action == "jump" {
#if os(macOS)
diff --git a/native/apple/Sources/DocsKit/DocumentCommands.swift b/native/apple/Sources/DocsKit/DocumentCommands.swift
index ba6d055..930ec44 100644
--- a/native/apple/Sources/DocsKit/DocumentCommands.swift
+++ b/native/apple/Sources/DocsKit/DocumentCommands.swift
@@ -20,6 +20,7 @@ import Foundation
}
return active
}
+ var selectedBlock: CRDTNode? { selectedBlocks.first.flatMap { path(to: $0)?.last } }
func hasBlock(_ type: String) -> Bool {
guard let id = selectedBlocks.first else { return false }
return path(to: id)?.contains { $0.type == type } == true
diff --git a/native/apple/Sources/DocsKit/DocumentPageLayout.swift b/native/apple/Sources/DocsKit/DocumentPageLayout.swift
index afc90b1..9ae7f8f 100644
--- a/native/apple/Sources/DocsKit/DocumentPageLayout.swift
+++ b/native/apple/Sources/DocsKit/DocumentPageLayout.swift
@@ -15,6 +15,19 @@ extension NSAttributedString.Key {
var starts: [Int: CGFloat] = [:]
var separators: [CGFloat] = []
}
+ /// Mapping IDs, formatting marks and colors do not affect glyph positions.
+ /// Retain only layout inputs so metadata acknowledgements reuse pagination.
+ static func measurementContent(_ storage: NSAttributedString) -> NSAttributedString {
+ let keys: Set<NSAttributedString.Key> = [.font, .paragraphStyle, .kern, .ligature, .baselineOffset,
+ .attachment, .expansion, .obliqueness, .writingDirection, .verticalGlyphForm,
+ .superscript, .docsBaseFont, .docsLeading, .docsCodeStart, .docsFirstMargin,
+ .docsTopBlock, .docsMarginBefore, .docsMarginAfter]
+ let result = NSMutableAttributedString(string: storage.string)
+ storage.enumerateAttributes(in: NSRange(location: 0, length: storage.length)) { attributes, range, _ in
+ result.setAttributes(attributes.filter { keys.contains($0.key) }, range: range)
+ }
+ return result
+ }
static func measure(storage: NSTextStorage, manager: NSLayoutManager, container: NSTextContainer) -> Result {
var result = Result()
var pageTop: CGFloat?
diff --git a/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift b/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift
index 0889c84..b6e330b 100644
--- a/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift
+++ b/native/apple/Sources/DocsKit/DocumentToolbarSelection.swift
@@ -5,16 +5,32 @@ import Foundation
struct DocumentToolbarSelection: Equatable {
var marks: Set<String> = []
var blocks: Set<String> = []
+ var style = "paragraph"
+ var font = "sans"
+ var size = 12
+ var alignment = "left"
var canUndo = false
var canRedo = false
var hasSelection = false
+ var styleLabel: String {
+ if style.hasPrefix("heading"), let level = Int(style.dropFirst(7)) {
+ return String(format: docsMacString("Heading %d"), level)
+ }
+ return String(localized: "docs.editor.toolbar.block.paragraph", bundle: DocsResources.bundle)
+ }
init() {}
@MainActor init?(commands: DocumentCommands) {
guard commands.isEnabled else { return nil }
marks = Set(["strong", "em", "code"].filter { commands.hasMark($0) })
blocks = Set(["bullet_list", "ordered_list", "blockquote"].filter { commands.hasBlock($0) })
+ if let block = commands.selectedBlock {
+ style = block.type == "heading" ? "heading" + (block.attrs?["level"] ?? "1") : "paragraph"
+ alignment = block.attrs?["align"] ?? "left"
+ }
+ font = ["serif", "mono"].first { commands.hasMark("font_" + $0) } ?? "sans"
+ size = [8,9,10,11,12,14,18,24,36,48,72].first { commands.hasMark("size_\($0)") } ?? (commands.hasMark("size_sm") ? 10 : commands.hasMark("size_lg") ? 14 : commands.hasMark("size_xl") ? 18 : 12)
canUndo = commands.model.projection?.canUndo == true
canRedo = commands.model.projection?.canRedo == true
hasSelection = commands.model.selection.length > 0
diff --git a/native/apple/Sources/DocsKit/EditorToolbar.swift b/native/apple/Sources/DocsKit/EditorToolbar.swift
index df1cf05..20a3db8 100644
--- a/native/apple/Sources/DocsKit/EditorToolbar.swift
+++ b/native/apple/Sources/DocsKit/EditorToolbar.swift
@@ -17,35 +17,43 @@ import SwiftUI
ForEach(1...3, id: \.self) { level in
Button { block("heading", level: level) } label: { Text(LocalizedStringKey("docs.editor.toolbar.block.heading\(level)"), bundle: DocsResources.bundle) }
}
- } label: { Image(systemName: "paragraphsign") }.accessibilityLabel(Text("native.style", bundle: DocsResources.bundle))
- ForEach(["bullet_list", "ordered_list", "blockquote"], id: \.self) { type in
- Button { block(type) } label: { Image(systemName: type == "bullet_list" ? "list.bullet" : type == "ordered_list" ? "list.number" : "text.quote") }
- .accessibilityLabel(Text(LocalizedStringKey("native." + type), bundle: DocsResources.bundle))
- .foregroundStyle(selection.blocks.contains(type) ? Color.accentColor : Color.primary)
- .accessibilityAddTraits(selection.blocks.contains(type) ? .isSelected : [])
- }
- mark("docs.editor.toolbar.bold", icon: "bold", name: "strong", active: selection.marks.contains("strong"))
- mark("docs.editor.toolbar.italic", icon: "italic", name: "em", active: selection.marks.contains("em"))
- mark("docs.editor.toolbar.code", icon: "chevron.left.forwardslash.chevron.right", name: "code", active: selection.marks.contains("code"))
+ } label: { Text(selection.styleLabel).frame(minWidth: 100, alignment: .leading) }.accessibilityLabel(Text("native.style", bundle: DocsResources.bundle))
Menu {
ForEach(["sans", "serif", "mono"], id: \.self) { family in
Button { format("font_" + family, exclusive: ["font_sans", "font_serif", "font_mono"]) } label: { Text(LocalizedStringKey("docs.editor.toolbar.font." + family), bundle: DocsResources.bundle) }
}
- } label: { Image(systemName: "textformat") }.accessibilityLabel(Text("native.font", bundle: DocsResources.bundle))
+ } label: { Text(LocalizedStringKey("docs.editor.toolbar.font." + selection.font), bundle: DocsResources.bundle).frame(minWidth: 80, alignment: .leading) }.accessibilityLabel(Text("native.font", bundle: DocsResources.bundle))
Menu {
ForEach([12, 8, 9, 10, 11, 14, 18, 24, 36, 48, 72], id: \.self) { size in
Button(size.formatted()) { format("size_\(size)", exclusive: [8,9,10,11,12,14,18,24,36,48,72].map { "size_\($0)" } + ["size_sm", "size_lg", "size_xl"]) }
}
- } label: { Image(systemName: "textformat.size") }.accessibilityLabel(Text("native.size", bundle: DocsResources.bundle))
+ } label: { Text(selection.size.formatted()).frame(minWidth: 32) }.accessibilityLabel(Text("native.size", bundle: DocsResources.bundle))
+ Divider().frame(height: 24)
+ mark("docs.editor.toolbar.bold", icon: "bold", name: "strong", active: selection.marks.contains("strong"))
+ mark("docs.editor.toolbar.italic", icon: "italic", name: "em", active: selection.marks.contains("em"))
+ mark("docs.editor.toolbar.code", icon: "chevron.left.forwardslash.chevron.right", name: "code", active: selection.marks.contains("code"))
+ Divider().frame(height: 24)
ForEach(["left", "center", "right"], id: \.self) { alignment in
Button { blockAttribute("align", value: alignment) } label: { Image(systemName: "text.align" + alignment) }
.accessibilityLabel(Text(LocalizedStringKey("docs.editor.toolbar.align." + alignment), bundle: DocsResources.bundle))
+ .foregroundStyle(selection.alignment == alignment ? Color.accentColor : Color.primary)
+ .accessibilityAddTraits(selection.alignment == alignment ? .isSelected : [])
+ }
+ Divider().frame(height: 24)
+ ForEach(["bullet_list", "ordered_list", "blockquote"], id: \.self) { type in
+ Button { block(type) } label: { Image(systemName: type == "bullet_list" ? "list.bullet" : type == "ordered_list" ? "list.number" : "text.quote") }
+ .accessibilityLabel(Text(LocalizedStringKey("native." + type), bundle: DocsResources.bundle))
+ .foregroundStyle(selection.blocks.contains(type) ? Color.accentColor : Color.primary)
+ .accessibilityAddTraits(selection.blocks.contains(type) ? .isSelected : [])
}
command("docs.editor.menu.format.indent", icon: "increase.indent", op: "indent", enabled: true)
command("docs.editor.menu.format.outdent", icon: "decrease.indent", op: "outdent", enabled: true)
command("docs.editor.menu.format.clear", icon: "eraser", op: "clear-format", enabled: selection.hasSelection)
- }.buttonStyle(.borderless).padding(12)
+ }.buttonStyle(.borderless).padding(.horizontal, 12).padding(.vertical, 8)
}
+ .scrollIndicators(.hidden)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("docs.formatting-toolbar")
// Replica work briefly gates commands on every keystroke. Keep that
// synchronization gate out of disabled styling so the bar does not blink.
.disabled(!actions.isAvailable)
diff --git a/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift b/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift
index b6d63a8..6f5436a 100644
--- a/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift
+++ b/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift
@@ -18,23 +18,71 @@ import XCTest
let initial = container.blockStarts
let insertion = String(repeating: "Inserted text before the boundary. ", count: 20)
storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: insertion)
- XCTAssertTrue(container.blockStarts.isEmpty, "No stale UTF-16 map can reach TextKit after an edit")
+ assertBlockStarts(container, storage)
view.layout()
XCTAssertNotEqual(initial, container.blockStarts)
assertBlockStarts(container, storage)
view.setSelectedRange(NSRange(location: 0, length: 0))
view.setMarkedText("日本語", selectedRange: NSRange(location: 3, length: 0), replacementRange: NSRange(location: 0, length: 0))
- XCTAssertTrue(container.blockStarts.isEmpty)
+ assertBlockStarts(container, storage)
view.layout()
assertBlockStarts(container, storage)
view.unmarkText()
storage.replaceCharacters(in: NSRange(location: 0, length: 3 + insertion.utf16.count), with: "")
- XCTAssertTrue(container.blockStarts.isEmpty)
+ assertBlockStarts(container, storage)
view.layout()
XCTAssertEqual(container.blockStarts, initial)
XCTAssertEqual(storage.string, nodes.map(\.plainText).joined(separator: "\n") + "\n")
}
+ func testMetadataReconciliationNeverExposesUnpaginatedCaretGeometry() throws {
+ let nodes = (0..<1000).map { index in
+ CRDTNode(id: "p\(index)", type: "paragraph", children: [CRDTNode(id: "t\(index)", type: "text", runs: [CRDTRun(insert: "A paragraph kept together when pages are recalculated.")])])
+ }
+ let view = DocsPagedTextView(frame: CGRect(x: 0, y: 0, width: 636, height: 800))
+ let storage = try XCTUnwrap(view.textStorage)
+ storage.setAttributedString(NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false)))
+ view.paged = true
+ view.layout()
+ let manager = try XCTUnwrap(view.layoutManager)
+ let container = try XCTUnwrap(view.textContainer as? DocsPageTextContainer)
+ XCTAssertGreaterThanOrEqual(container.blockStarts.count, 2)
+ let selected = storage.length - 3
+ view.setSelectedRange(NSRange(location: selected, length: 0))
+ func caretY() -> CGFloat {
+ manager.ensureLayout(for: container)
+ return manager.lineFragmentRect(forGlyphAt: manager.glyphIndexForCharacter(at: selected), effectiveRange: nil).minY
+ }
+ let before = caretY()
+ let start = ContinuousClock.now
+ for generation in 0..<10 {
+ storage.addAttribute(NSAttributedString.Key("MetadataOnly"), value: generation, range: NSRange(location: 0, length: storage.length))
+ XCTAssertEqual(caretY(), before, accuracy: 0.01, "AppKit can query the caret before the next view layout")
+ }
+ print("Ten metadata updates in a 1,000-paragraph document:", start.duration(to: .now))
+ view.layout()
+ XCTAssertEqual(caretY(), before, accuracy: 0.01)
+ XCTAssertEqual(view.selectedRange(), NSRange(location: selected, length: 0))
+ }
+
+ func testCaretBackgroundErasureStaysInsideRequestedRectangle() throws {
+ let view = DocsPagedTextView(frame: CGRect(x: 0, y: 0, width: 636, height: 800))
+ view.layout()
+ let bitmap = try XCTUnwrap(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 100,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
+ let context = try XCTUnwrap(NSGraphicsContext(bitmapImageRep: bitmap))
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+ NSGraphicsContext.current = context
+ NSColor.red.setFill()
+ CGRect(x: 0, y: 0, width: 100, height: 100).fill()
+ view.drawBackground(in: CGRect(x: 40, y: 40, width: 20, height: 20))
+ XCTAssertEqual(try XCTUnwrap(bitmap.colorAt(x: 20, y: 20)).greenComponent, 0, accuracy: 0.01,
+ "Erasing one caret must not paint over neighboring glyphs")
+ XCTAssertGreaterThan(try XCTUnwrap(bitmap.colorAt(x: 50, y: 50)).greenComponent, 0.9)
+ }
+
private func assertBlockStarts(_ container: DocsPageTextContainer, _ storage: NSTextStorage) {
for location in container.blockStarts.keys {
var range = NSRange()
diff --git a/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift b/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift
index b0a55ac..a53aaeb 100644
--- a/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift
+++ b/native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift
@@ -40,6 +40,28 @@ final class DocumentToolbarSelectionTests: XCTestCase {
XCTAssertTrue(try XCTUnwrap(DocumentToolbarSelection(commands: commands)).hasSelection)
}
+ @MainActor func testToolbarLabelsFollowHeadingFontSizeAndAlignment() throws {
+ let model = DocsModel(client: NativeHTTP(origin: URL(string: "https://example.invalid")!))
+ model.detail = DocumentDetail(sqid: "fixture", title: "Fixture", guid: "fixture", can_write: true, can_share: false)
+ model.projection = CRDTProjection(children: [CRDTNode(id: "heading", type: "heading", attrs: ["level": "2", "align": "center"], children: [CRDTNode(id: "text", type: "text", runs: [CRDTRun(insert: "Title", marks: ["font_serif": .object([:]), "size_24": .object([:])])])])], canUndo: false, canRedo: false)
+ model.selection = NSRange(location: 1, length: 0)
+ let state = try XCTUnwrap(DocumentToolbarSelection(commands: DocumentCommands(model: model, content: NativeTextProjection.render(model.projection!))))
+ XCTAssertEqual(state.style, "heading2")
+ XCTAssertEqual(state.font, "serif")
+ XCTAssertEqual(state.size, 24)
+ XCTAssertEqual(state.alignment, "center")
+ for level in 4...6 {
+ model.projection?.children[0].attrs?["level"] = String(level)
+ let imported = try XCTUnwrap(DocumentToolbarSelection(commands: DocumentCommands(model: model, content: NativeTextProjection.render(model.projection!))))
+ XCTAssertEqual(imported.styleLabel, String(format: docsMacString("Heading %d"), level))
+ }
+ for (mark, expected) in [("size_sm", 10), ("size_lg", 14), ("size_xl", 18)] {
+ model.typingMarks = [mark: .object([:])]
+ let legacy = try XCTUnwrap(DocumentToolbarSelection(commands: DocumentCommands(model: model, content: NativeTextProjection.render(model.projection!))))
+ XCTAssertEqual(legacy.size, expected)
+ }
+ }
+
@MainActor func testReadOnlyAndPausedInputRemainDisabled() {
let model = DocsModel(client: NativeHTTP(origin: URL(string: "https://example.invalid")!))
let commands = DocumentCommands(model: model, content: NSAttributedString(string: ""))
diff --git a/native/scripts/test-document-window.py b/native/scripts/test-document-window.py
index c129982..1ffeb3e 100644
--- a/native/scripts/test-document-window.py
+++ b/native/scripts/test-document-window.py
@@ -10,7 +10,9 @@ import threading
import time
root = Path(__file__).resolve().parents[2]
-fixture = (root / "internal/docs/seeddata/letter.update.bin").read_bytes()
+fixture_name = os.environ.get("DOCS_SMOKE_FIXTURE", "letter")
+assert fixture_name in {"letter", "documentation"}
+fixture = (root / "internal/docs/seeddata" / (fixture_name + ".update.bin")).read_bytes()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
@@ -27,13 +29,18 @@ class Handler(http.server.BaseHTTPRequestHandler):
else:
time.sleep(0.3) # Include loading -> editor layout transition.
data = json.dumps(dict(sqid="fixture", title="Window smoke fixture", guid="window-smoke",
- can_write=False, can_share=False)).encode()
+ can_write=True, can_share=False)).encode()
self.send_response(200)
self.send_header("Docs-Seq", "0")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
+ def do_POST(self):
+ self.rfile.read(int(self.headers.get("Content-Length", "0")))
+ self.send_response(204)
+ self.end_headers()
+
def log_message(self, *_):
pass
diff --git a/native/smoke/document-window/Sources/Smoke.swift b/native/smoke/document-window/Sources/Smoke.swift
index cc7b9b0..68879b2 100644
--- a/native/smoke/document-window/Sources/Smoke.swift
+++ b/native/smoke/document-window/Sources/Smoke.swift
@@ -70,7 +70,24 @@ private struct Launcher: View {
precondition(window.contentView.flatMap(findEditor) === editor, "Inspector recreated editor")
precondition(editor.string == original && editor.selectedRange() == NSRange(location: 1, length: 2), "Panel lost content or selection")
}
- print("PASS: document loads, inspector toggles, window resizes, editor and selection persist")
+ if let path = ProcessInfo.processInfo.environment["DOCS_SMOKE_SCREENSHOT"], let view = window.contentView,
+ let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
+ view.cacheDisplay(in: view.bounds, to: bitmap)
+ try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
+ }
+ window.makeFirstResponder(editor)
+ editor.setSelectedRange(NSRange(location: editor.string.utf16.count - 1, length: 0))
+ for character in "\nCaret stability after Enter." {
+ let before = editor.selectedRange().location
+ if character == "\n" { editor.insertNewline(nil) }
+ else { editor.insertText(String(character), replacementRange: editor.selectedRange()) }
+ let immediate = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil)
+ try? await Task.sleep(for: .milliseconds(100))
+ precondition(editor.selectedRange().location == before + String(character).utf16.count, "Typing moved selection unexpectedly")
+ let settled = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil)
+ precondition(abs(immediate.minY - settled.minY) < 1, "Caret moved while reconciling optimistic text")
+ }
+ print("PASS: document loads, inspector toggles, window resizes, editor and selection persist, Enter and typing keep caret geometry stable")
NSApp.terminate(nil)
}
}