Restore shared formatting bar and keep paginated caret geometry stable
11 files changed,
+205
−43
native/apple/Sources/DocsKit/DocsLayoutManager.swift+0 −1native/apple/Sources/DocsKit/DocsPagedTextView.swift+53 −21native/apple/Sources/DocsKit/DocsWorkspace.swift+1 −2native/apple/Sources/DocsKit/DocumentCommands.swift+1 −0native/apple/Sources/DocsKit/DocumentPageLayout.swift+13 −0native/apple/Sources/DocsKit/DocumentToolbarSelection.swift+16 −0native/apple/Sources/DocsKit/EditorToolbar.swift+21 −13native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift+51 −3native/apple/Tests/DocsKitTests/DocumentToolbarSelectionTests.swift+22 −0native/scripts/test-document-window.py+9 −2native/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) |
| } |
| } |