Stabilize paged caret geometry and home loading states
17 files changed,
+230
−21
native/apple/Sources/DocsApp/DocsAccountWindow.swift+4 −1native/apple/Sources/DocsApp/DocsWindowCommands.swift+11 −0native/apple/Sources/DocsApp/DocsWindows.swift+1 −0native/apple/Sources/DocsKit/DocsAccountActions.swift+10 −0native/apple/Sources/DocsKit/DocsModel.swift+11 −1native/apple/Sources/DocsKit/DocsPagedTextView.swift+13 −1native/apple/Sources/DocsKit/DocsWorkspace.swift+12 −5native/apple/Sources/DocsKit/NativeDocumentTextView.swift+1 −1native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift+19 −0native/apple/Tests/DocsKitTests/HomeLoadingTests.swift+22 −0native/crdt/examples/caret_fixture.rs+11 −0native/scripts/test-document-window.py+1 −1native/scripts/test-home-loading.py+36 −0native/smoke/document-window/README.md+14 −0native/smoke/document-window/Sources/DocsWindowCommands.swift+1 −0native/smoke/document-window/Sources/DocsWindows.swift+1 −0native/smoke/document-window/Sources/Smoke.swift+62 −11
diff --git a/native/apple/Sources/DocsApp/DocsAccountWindow.swift b/native/apple/Sources/DocsApp/DocsAccountWindow.swift| index 3f7cbe1..fe2a50c 100644 |
| --- a/native/apple/Sources/DocsApp/DocsAccountWindow.swift |
| +++ b/native/apple/Sources/DocsApp/DocsAccountWindow.swift |
| @@ -19,7 +19,7 @@ struct DocsAccountWindow: View { |
| guard connection.activeAccountID == account, windows.canSignOut(account) else { return } |
| windows.entries.removeAll { $0.account == account } |
| connection.signOut() |
| - }, canSignOut: windows.canSignOut(account), createRequested: $windows.createRequested, openDocument: { document, title in |
| + }, canSignOut: windows.canSignOut(account), createRequested: $windows.createRequested, accountsRequested: $windows.accountsRequested, openDocument: { document, title in |
| guard connection.activeAccountID == account else { return } |
| let id = windows.reserve(account: account, document: document, title: title, client: client) |
| openWindow(id: "document", value: id) |
| @@ -38,6 +38,9 @@ struct DocsAccountWindow: View { |
| } |
| }.onOpenURL(perform: connection.handleOpenURL) |
| #if os(macOS) |
| + .onChange(of: windows.accountsRequested, initial: true) { _, requested in |
| + if requested && connection.client == nil { windows.accountsRequested = false } |
| + } |
| .focusedSceneValue(\.docsWindowAccount, connection.activeAccountID) |
| .onChange(of: connection.activeAccountID, initial: true) { _, value in windows.activeAccount = value } |
| #endif |
diff --git a/native/apple/Sources/DocsApp/DocsWindowCommands.swift b/native/apple/Sources/DocsApp/DocsWindowCommands.swift| index db8eaa2..5933678 100644 |
| --- a/native/apple/Sources/DocsApp/DocsWindowCommands.swift |
| +++ b/native/apple/Sources/DocsApp/DocsWindowCommands.swift |
| @@ -5,8 +5,19 @@ import DocsKit |
| struct DocsWindowCommands: Commands { |
| let windows: DocsWindows |
| @FocusedValue(\.docsWindowAccount) private var account |
| + @FocusedValue(\.docsAccountActions) private var accountActions |
| @Environment(\.openWindow) private var openWindow |
| var body: some Commands { |
| + CommandGroup(after: .appSettings) { |
| + Button(docsMacString("Accounts")) { |
| + if let action = accountActions?.accounts { action() } |
| + else { windows.accountsRequested = true; openWindow(id: "home") } |
| + }.disabled(accountActions?.canManage == false) |
| + Button(docsMacString("Sign out")) { accountActions?.signOut() } |
| + .disabled(accountActions?.canSignOut != true) |
| + Divider() |
| + } |
| + |
| CommandGroup(replacing: .newItem) { |
| Button(docsMacString("New document")) { |
| windows.createRequested = true |
diff --git a/native/apple/Sources/DocsApp/DocsWindows.swift b/native/apple/Sources/DocsApp/DocsWindows.swift| index 2e80e54..59361f3 100644 |
| --- a/native/apple/Sources/DocsApp/DocsWindows.swift |
| +++ b/native/apple/Sources/DocsApp/DocsWindows.swift |
| @@ -17,6 +17,7 @@ import SwiftUI |
| var entries: [Entry] = [] |
| var activeAccount: String? |
| var createRequested = false |
| + var accountsRequested = false |
| func reserve(account: String, document: String, title: String, client: NativeHTTP) -> UUID { |
| if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) { |
| var entry = entries.remove(at: index) |
diff --git a/native/apple/Sources/DocsKit/DocsAccountActions.swift b/native/apple/Sources/DocsKit/DocsAccountActions.swift| new file mode 100644 |
| index 0000000..8bca77a |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsAccountActions.swift |
| @@ -0,0 +1,10 @@ |
| +#if os(macOS) |
| +import SwiftUI |
| +public struct DocsAccountActions { |
| + public let accounts: (() -> Void)? |
| + public let signOut: () -> Void |
| + public let canManage: Bool |
| + public let canSignOut: Bool |
| +} |
| +extension FocusedValues { @Entry public var docsAccountActions: DocsAccountActions? } |
| +#endif |
diff --git a/native/apple/Sources/DocsKit/DocsModel.swift b/native/apple/Sources/DocsKit/DocsModel.swift| index 12b405f..84ee7bc 100644 |
| --- a/native/apple/Sources/DocsKit/DocsModel.swift |
| +++ b/native/apple/Sources/DocsKit/DocsModel.swift |
| @@ -3,6 +3,9 @@ import Observation |
| import RastrilloApple |
| @MainActor @Observable final class DocsModel { |
| + enum ListState { case loading, loaded, failed } |
| + var listState = ListState.loading |
| + private var listRequest = UUID() |
| var documents: [DocumentSummary] = [] |
| var canCreate = false |
| var errorKey: String? |
| @@ -32,12 +35,19 @@ import RastrilloApple |
| var hasPendingInput: Bool { !editing.isEmpty || editingTask != nil } |
| var hasUnsavedChanges: Bool { composing || !pending.isEmpty || !editing.isEmpty || editingTask != nil } |
| func load(query: String = "") async { |
| + let request = UUID(); listRequest = request |
| + listState = .loading |
| do { |
| var components = URLComponents(); components.queryItems = [URLQueryItem(name: "q", value: query)] |
| let q = (components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") |
| let response: DocumentList = try await client.request("/?\(q)") |
| + guard listRequest == request, !Task.isCancelled else { return } |
| documents = response.documents; canCreate = response.can_create |
| - } catch { errorKey = "docs.error.generic" } |
| + listState = .loaded; errorKey = nil |
| + } catch { |
| + guard listRequest == request else { return } |
| + listState = .failed; errorKey = "docs.error.generic" |
| + } |
| } |
| @discardableResult func create(title: String, openCreated: Bool = true) async -> DocumentSummary? { |
| guard !creatingDocument, !hasUnsavedChanges else { return nil } |
diff --git a/native/apple/Sources/DocsKit/DocsPagedTextView.swift b/native/apple/Sources/DocsKit/DocsPagedTextView.swift| index 0e7b223..3ed20fc 100644 |
| --- a/native/apple/Sources/DocsKit/DocsPagedTextView.swift |
| +++ b/native/apple/Sources/DocsKit/DocsPagedTextView.swift |
| @@ -28,13 +28,25 @@ import AppKit |
| manager.addTextContainer(pages) |
| super.init(frame: frameRect, textContainer: pages) |
| textContainerInset = .zero |
| - isVerticallyResizable = true |
| + // This view owns its paginated height; NSTextView auto-sizing uses a |
| + // different used rect and briefly clips the current line during input. |
| + isVerticallyResizable = false |
| isHorizontallyResizable = 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") } |
| + // Page margins are already part of DocsPageTextContainer's line geometry. |
| + // NSTextView's inferred origin moves during incremental layout and can put |
| + // its native insertion indicator at a different origin from the glyphs. |
| + override var textContainerOrigin: NSPoint { .zero } |
| + override func didChangeText() { |
| + // Size the document before AppKit scrolls the newly edited caret. |
| + needsLayout = true |
| + layoutSubtreeIfNeeded() |
| + super.didChangeText() |
| + } |
| private var viewportWidth: CGFloat { max(636, enclosingScrollView?.contentView.bounds.width ?? bounds.width) } |
| private func updatePagination() { |
| guard !updatingPagination else { return } |
diff --git a/native/apple/Sources/DocsKit/DocsWorkspace.swift b/native/apple/Sources/DocsKit/DocsWorkspace.swift| index 55333b6..a41cdd0 100644 |
| --- a/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| +++ b/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| @@ -15,6 +15,7 @@ import SwiftUI |
| @State private var column: NavigationSplitViewColumn = .sidebar |
| private let updateRecents: (([(String, String)]) -> Void)? |
| private let canSignOut: Bool |
| + @Binding private var accountsRequested: Bool |
| @Binding private var createRequested: Bool |
| private let documentID: String? |
| private let openDocument: ((String, String) -> Void)? |
| @@ -23,8 +24,8 @@ import SwiftUI |
| private let signOut: () -> Void |
| private let accounts: (() -> Void)? |
| private let client: NativeHTTP |
| - public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: String? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) { |
| - self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self.documentID = documentID; self.openDocument = openDocument |
| + public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: String? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), accountsRequested: Binding<Bool> = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) { |
| + self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument |
| _model = State(initialValue: DocsModel(client: client)); self.signOut = signOut; self.accounts = accounts; self.client = client |
| } |
| private var library: some View { |
| @@ -64,6 +65,9 @@ import SwiftUI |
| .onChange(of: model.documents.map { "\($0.sqid)\u{0}\($0.title)" }) { |
| if query.isEmpty { updateRecents?(model.documents.map { ($0.sqid, $0.title) }) } |
| } |
| + .onChange(of: accountsRequested && !model.creatingDocument, initial: true) { _, ready in |
| + if ready { accountsRequested = false; accounts?() } |
| + } |
| .onChange(of: createRequested, initial: true) { _, requested in |
| if requested && documentID == nil { creating = true; createRequested = false } |
| } |
| @@ -207,7 +211,11 @@ import SwiftUI |
| }.buttonStyle(.plain) |
| } |
| } |
| - if model.documents.isEmpty && model.errorKey == nil { |
| + if model.listState == .loading { ProgressView().frame(maxWidth: .infinity) } |
| + if model.listState == .loaded && model.documents.isEmpty && !query.isEmpty { |
| + ContentUnavailableView { Text("docs.list.search.none.title", bundle: DocsResources.bundle) } description: { Text("docs.list.search.none.body", bundle: DocsResources.bundle) } |
| + } |
| + if model.listState == .loaded && model.documents.isEmpty && query.isEmpty { |
| ContentUnavailableView(docsMacString("No documents"), systemImage: "doc.text", description: Text(docsMacString("Create a document to get started."))) |
| } |
| }.padding(28) |
| @@ -215,10 +223,9 @@ import SwiftUI |
| .navigationTitle(docsMacString("Home")) |
| .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: DocsResources.bundle)) |
| .onSubmit(of: .search) { Task { await model.load(query: query) } } |
| + .focusedSceneValue(\.docsAccountActions, DocsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.creatingDocument, canSignOut: canSignOut && !model.creatingDocument)) |
| .toolbar { |
| - if let accounts { Button(docsMacString("Accounts"), action: accounts).disabled(model.creatingDocument) } |
| Button(docsMacString("New document"), systemImage: "plus") { creating = true }.disabled(!model.canCreate) |
| - Button(docsMacString("Sign out"), action: signOut).disabled(!canSignOut || model.creatingDocument).help(docsMacString("Close this account’s document windows before signing out.")) |
| } |
| } |
| } |
diff --git a/native/apple/Sources/DocsKit/NativeDocumentTextView.swift b/native/apple/Sources/DocsKit/NativeDocumentTextView.swift| index 3f012dd..9750b5a 100644 |
| --- a/native/apple/Sources/DocsKit/NativeDocumentTextView.swift |
| +++ b/native/apple/Sources/DocsKit/NativeDocumentTextView.swift |
| @@ -28,7 +28,7 @@ struct NativeDocumentTextView: NSViewRepresentable { |
| scroll.allowsMagnification = false |
| scroll.magnification = 4.0 / 3.0 |
| let text = DocsTextView(frame: .zero) |
| - text.isVerticallyResizable = true |
| + text.isVerticallyResizable = false |
| text.autoresizingMask = [.width] |
| scroll.documentView = text |
| text.delegate = context.coordinator |
diff --git a/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift b/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift| index 6f5436a..6a64cff 100644 |
| --- a/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift |
| +++ b/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift |
| @@ -83,6 +83,25 @@ import XCTest |
| XCTAssertGreaterThan(try XCTUnwrap(bitmap.colorAt(x: 50, y: 50)).greenComponent, 0.9) |
| } |
| + func testTypingKeepsCaretInsidePageBoundsWithStableOrigin() throws { |
| + let nodes = (0..<60).map { index in |
| + CRDTNode(id: "p\(index)", type: "paragraph", children: [CRDTNode(id: "t\(index)", type: "text", runs: [CRDTRun(insert: String(repeating: "A paragraph that wraps across lines. ", count: 8))])]) |
| + } |
| + let view = DocsTextView(frame: CGRect(x: 0, y: 0, width: 900, height: 800)) |
| + view.textStorage!.setAttributedString(NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false))) |
| + view.paged = true |
| + view.layout() |
| + view.setSelectedRange(NSRange(location: view.string.utf16.count - 1, length: 0)) |
| + for text in ["\n", "a", "\n", "b"] { |
| + view.insertText(text, replacementRange: view.selectedRange()) |
| + view.layoutManager!.ensureLayout(for: view.textContainer!) |
| + let glyph = view.layoutManager!.glyphIndexForCharacter(at: view.selectedRange().location) |
| + let line = view.layoutManager!.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) |
| + XCTAssertEqual(view.textContainerOrigin, .zero) |
| + XCTAssertGreaterThanOrEqual(view.bounds.maxY, line.maxY) |
| + } |
| + } |
| + |
| private func assertBlockStarts(_ container: DocsPageTextContainer, _ storage: NSTextStorage) { |
| for location in container.blockStarts.keys { |
| var range = NSRange() |
diff --git a/native/apple/Tests/DocsKitTests/HomeLoadingTests.swift b/native/apple/Tests/DocsKitTests/HomeLoadingTests.swift| new file mode 100644 |
| index 0000000..3ceb4c3 |
| --- /dev/null |
| +++ b/native/apple/Tests/DocsKitTests/HomeLoadingTests.swift |
| @@ -0,0 +1,22 @@ |
| +import Foundation |
| +import RastrilloApple |
| +import XCTest |
| +@testable import DocsKit |
| + |
| +final class HomeLoadingTests: XCTestCase { |
| + @MainActor func testLoadingSuccessAndFailureAreDistinct() async throws { |
| + guard let origin = ProcessInfo.processInfo.environment["HOME_LOADING_TEST_ORIGIN"] else { |
| + throw XCTSkip("Run the delayed home-list fixture") |
| + } |
| + let model = DocsModel(client: NativeHTTP(origin: URL(string: origin)!)) |
| + XCTAssertEqual(model.listState, .loading) |
| + for expected in [1, 0, -1, 1] { |
| + let request = Task { await model.load() } |
| + try await Task.sleep(for: .milliseconds(25)) |
| + XCTAssertEqual(model.listState, .loading, "Never classify an in-flight request as empty") |
| + await request.value |
| + XCTAssertEqual(model.listState, expected == -1 ? .failed : .loaded) |
| + if expected >= 0 { XCTAssertEqual(model.documents.count, expected) } |
| + } |
| + } |
| +} |
diff --git a/native/crdt/examples/caret_fixture.rs b/native/crdt/examples/caret_fixture.rs| new file mode 100644 |
| index 0000000..aeaaad4 |
| --- /dev/null |
| +++ b/native/crdt/examples/caret_fixture.rs |
| @@ -0,0 +1,11 @@ |
| +use docs_native_crdt::Editor; |
| +use serde_json::json; |
| +fn main() { |
| + let mut editor = Editor::new("window-smoke", 17); |
| + for index in 0..60 { |
| + editor.command(&json!({"op":"insert_element", "at":index, "type":"paragraph"})).unwrap(); |
| + let block = editor.projection()["children"][index]["id"].as_str().unwrap().to_owned(); |
| + editor.command(&json!({"op":"insert_text", "node":block, "at":0, "text":format!("Paragraph {}. {}", index + 1, "Synthetic text with enough words to wrap onto several lines in a document window. ".repeat(3))})).unwrap(); |
| + } |
| + std::fs::write(std::env::args().nth(1).expect("output path"), editor.snapshot()).unwrap(); |
| +} |
diff --git a/native/scripts/test-document-window.py b/native/scripts/test-document-window.py| index 1ffeb3e..be127c5 100644 |
| --- a/native/scripts/test-document-window.py |
| +++ b/native/scripts/test-document-window.py |
| @@ -12,7 +12,7 @@ import time |
| root = Path(__file__).resolve().parents[2] |
| 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() |
| +fixture = Path(os.environ["DOCS_SMOKE_UPDATE"]).read_bytes() if "DOCS_SMOKE_UPDATE" in os.environ else (root / "internal/docs/seeddata" / (fixture_name + ".update.bin")).read_bytes() |
| class Handler(http.server.BaseHTTPRequestHandler): |
| def do_GET(self): |
diff --git a/native/scripts/test-home-loading.py b/native/scripts/test-home-loading.py| new file mode 100644 |
| index 0000000..1efe5aa |
| --- /dev/null |
| +++ b/native/scripts/test-home-loading.py |
| @@ -0,0 +1,36 @@ |
| +#!/usr/bin/env python3 |
| +"""Verify loading/populated/empty/failure/retry against delayed HTTP responses.""" |
| +import http.server |
| +import json |
| +import os |
| +from pathlib import Path |
| +import subprocess |
| +import threading |
| +import time |
| + |
| +APP = "Docs" |
| +root = Path(__file__).resolve().parents[2] |
| +class Handler(http.server.BaseHTTPRequestHandler): |
| + count = 0 |
| + def do_GET(self): |
| + type(self).count += 1 |
| + number = type(self).count |
| + time.sleep(0.2) |
| + item = ({"sqid": "fixture", "title": "Fixture"} if APP == "Docs" else |
| + {"ID": 1, "Title": "Fixture", "TabID": 1, "TabName": "Sheet1"}) |
| + items = [] if number == 2 else [item] |
| + payload = {"documents": items, "can_create": True} if APP == "Docs" else {"Sheets": items} |
| + data = json.dumps(payload).encode() |
| + self.send_response(503 if number == 3 else 200) |
| + self.send_header("Content-Length", str(len(data))) |
| + self.end_headers() |
| + self.wfile.write(data) |
| + def log_message(self, *_): |
| + pass |
| +with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: |
| + threading.Thread(target=server.serve_forever, daemon=True).start() |
| + result = subprocess.run(["swift", "test", "--package-path", str(root / "native/apple"), |
| + "--filter", "HomeLoadingTests"], |
| + env=dict(os.environ, HOME_LOADING_TEST_ORIGIN=f"http://127.0.0.1:{server.server_port}"), timeout=180) |
| + server.shutdown() |
| + raise SystemExit(result.returncode) |
diff --git a/native/smoke/document-window/README.md b/native/smoke/document-window/README.md| index 3687f14..b1dab97 100644 |
| --- a/native/smoke/document-window/README.md |
| +++ b/native/smoke/document-window/README.md |
| @@ -15,3 +15,17 @@ Restoring the `.inspector` container reproduces SIGTRAP with AppKit's “more Up |
| Constraints in Window passes than there are views” exception. The docked settings |
| panel avoids that container. The negative and fixed scene runs were verified on |
| macOS 27 build 26A5388g; the user report was build 26A428. |
| + |
| +The typing regression now sends native key events and checks the actual |
| +NSTextInsertionIndicator against glyph geometry and document bounds, including |
| +repeated Enter presses. To exercise multiple pages (the short seeds do not): |
| + |
| +```sh |
| +cargo run --manifest-path native/crdt/Cargo.toml --example caret_fixture -- /tmp/docs-caret.bin |
| +DOCS_SMOKE_UPDATE=/tmp/docs-caret.bin python3 native/scripts/test-document-window.py |
| +``` |
| + |
| +The generated fixture has 60 independent paragraphs. Optional |
| +`DOCS_SMOKE_SCREENSHOT=/tmp/docs-caret.png` captures the window after typing. |
| +The same executable imports the real window command source through symlinks and |
| +verifies Accounts/Sign out are in the app menu and Accounts reopens closed Home. |
diff --git a/native/smoke/document-window/Sources/DocsWindowCommands.swift b/native/smoke/document-window/Sources/DocsWindowCommands.swift| new file mode 120000 |
| index 0000000..7f0dc8a |
| --- /dev/null |
| +++ b/native/smoke/document-window/Sources/DocsWindowCommands.swift |
| @@ -0,0 +1 @@ |
| +../../../apple/Sources/DocsApp/DocsWindowCommands.swift |
| \ No newline at end of file |
diff --git a/native/smoke/document-window/Sources/DocsWindows.swift b/native/smoke/document-window/Sources/DocsWindows.swift| new file mode 120000 |
| index 0000000..e67e0f0 |
| --- /dev/null |
| +++ b/native/smoke/document-window/Sources/DocsWindows.swift |
| @@ -0,0 +1 @@ |
| +../../../apple/Sources/DocsApp/DocsWindows.swift |
| \ No newline at end of file |
diff --git a/native/smoke/document-window/Sources/Smoke.swift b/native/smoke/document-window/Sources/Smoke.swift| index 68879b2..3ed74ad 100644 |
| --- a/native/smoke/document-window/Sources/Smoke.swift |
| +++ b/native/smoke/document-window/Sources/Smoke.swift |
| @@ -3,23 +3,29 @@ import DocsKit |
| import RastrilloApple |
| import SwiftUI |
| +@MainActor private enum SmokeRun { static var started = false } |
| + |
| @main struct DocumentWindowSmoke: App { |
| + @State private var windows = DocsWindows() |
| var body: some Scene { |
| - Window("Smoke Home", id: "home") { Launcher() } |
| + Window("Smoke Home", id: "home") { Launcher(windows: windows) } |
| WindowGroup("Smoke Document", id: "document", for: String.self) { _ in |
| DocsWorkspace(client: NativeHTTP(origin: URL(string: ProcessInfo.processInfo.environment["DOCS_SMOKE_ORIGIN"]!)!), |
| signOut: {}, documentID: "fixture") |
| }.defaultSize(width: 1120, height: 820) |
| .restorationBehavior(.disabled) |
| - .commands { DocsEditorCommands() } |
| + .commands { DocsEditorCommands(); DocsWindowCommands(windows: windows) } |
| } |
| } |
| private struct Launcher: View { |
| + let windows: DocsWindows |
| @Environment(\.openWindow) private var openWindow |
| var body: some View { |
| Text("Synthetic document window smoke test").frame(width: 400, height: 200) |
| .task { |
| + guard !SmokeRun.started else { return } |
| + SmokeRun.started = true |
| NSApp.setActivationPolicy(.regular) |
| openWindow(id: "document", value: "fixture") |
| for _ in 0..<100 { |
| @@ -49,6 +55,7 @@ private struct Launcher: View { |
| window.makeKeyAndOrderFront(nil) |
| window.makeFirstResponder(editor) |
| editor.setSelectedRange(NSRange(location: 1, length: 2)) |
| + try? await Task.sleep(for: .milliseconds(500)) |
| let original = editor.string |
| for step in 0..<8 { |
| try? await Task.sleep(for: .milliseconds(200)) |
| @@ -70,24 +77,68 @@ 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") |
| } |
| - 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." { |
| + NSApp.activate(ignoringOtherApps: true) |
| + window.makeKeyAndOrderFront(nil) |
| + window.makeFirstResponder(editor) |
| + editor.scrollRangeToVisible(editor.selectedRange()) |
| + try? await Task.sleep(for: .milliseconds(100)) |
| + func indicators(_ view: NSView) -> [NSView] { |
| + (view is NSTextInsertionIndicator ? [view] : []) + view.subviews.flatMap(indicators) |
| + } |
| + func record(_ phase: String) { |
| + let manager = editor.layoutManager! |
| + manager.ensureLayout(for: editor.textContainer!) |
| + let glyph = manager.glyphIndexForCharacter(at: min(editor.selectedRange().location, editor.string.utf16.count - 1)) |
| + let line = manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) |
| + let rect = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil) |
| + precondition(editor.textContainerOrigin == .zero, "Native caret origin drifted") |
| + precondition(rect.height > 0, "Caret clipped outside editor bounds") |
| + precondition(editor.bounds.maxY >= line.maxY, "Editor shrank below current line") |
| + let visible = indicators(window.contentView!).filter { !$0.isHidden } |
| + precondition(visible.count == 1, "Expected exactly one visible native insertion indicator") |
| + for indicator in visible { |
| + let actual = indicator.convert(indicator.bounds, to: editor) |
| + precondition(abs(actual.minY - line.minY) < 1, "Native insertion indicator disagrees with glyphs") |
| + } |
| + |
| + } |
| + for character in "\nCaret stability after Enter.\nSecond line.\nThird line." { |
| + window.makeKeyAndOrderFront(nil) |
| + window.makeFirstResponder(editor) |
| let before = editor.selectedRange().location |
| - if character == "\n" { editor.insertNewline(nil) } |
| - else { editor.insertText(String(character), replacementRange: editor.selectedRange()) } |
| + let typed = character == "\n" ? "\r" : String(character) |
| + let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, characters: typed, charactersIgnoringModifiers: typed, isARepeat: false, keyCode: character == "\n" ? 36 : 0)! |
| + editor.keyDown(with: event) |
| + record("immediate") |
| 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") |
| + record("settled") |
| let settled = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil) |
| - precondition(abs(immediate.minY - settled.minY) < 1, "Caret moved while reconciling optimistic text") |
| + precondition(abs(immediate.minY - settled.minY) < 1, "Caret jumped after publication") |
| + } |
| + 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)) |
| } |
| print("PASS: document loads, inspector toggles, window resizes, editor and selection persist, Enter and typing keep caret geometry stable") |
| + NSApp.windows.first(where: { $0.title == "Smoke Home" })?.close() |
| + window.makeKeyAndOrderFront(nil) |
| + guard let menu = NSApp.mainMenu, let appMenu = menu.items.first?.submenu else { fatalError("App menu missing") } |
| + appMenu.delegate?.menuNeedsUpdate?(appMenu) |
| + appMenu.update() |
| + guard let accounts = menuItem(docsMacString("Accounts"), in: appMenu), accounts.isEnabled, |
| + let signOut = menuItem(docsMacString("Sign out"), in: appMenu), !signOut.isEnabled else { |
| + fatalError("Account commands missing or unsafe in document window") |
| + } |
| + accounts.menu!.performActionForItem(at: accounts.menu!.index(of: accounts)) |
| + try? await Task.sleep(for: .milliseconds(300)) |
| + precondition(windows.accountsRequested, "Accounts action did not reach launcher") |
| + precondition(NSApp.windows.contains { $0.title == "Smoke Home" && $0.isVisible }, "Accounts did not reopen Home") |
| + print("PASS: account commands live in app menu and Accounts reopens closed Home") |
| NSApp.terminate(nil) |
| } |
| } |