oficina / docs Public

Stabilize paged caret geometry and home loading states

Paul Campbell pushed by paul@keymail.dev dde2aafaab795889fba11cc544a37221b213b765 parent 089401e
17 files changed, +230 −21
  • native/apple/Sources/DocsApp/DocsAccountWindow.swift +4 −1
  • native/apple/Sources/DocsApp/DocsWindowCommands.swift +11 −0
  • native/apple/Sources/DocsApp/DocsWindows.swift +1 −0
  • native/apple/Sources/DocsKit/DocsAccountActions.swift +10 −0
  • native/apple/Sources/DocsKit/DocsModel.swift +11 −1
  • native/apple/Sources/DocsKit/DocsPagedTextView.swift +13 −1
  • native/apple/Sources/DocsKit/DocsWorkspace.swift +12 −5
  • native/apple/Sources/DocsKit/NativeDocumentTextView.swift +1 −1
  • native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift +19 −0
  • native/apple/Tests/DocsKitTests/HomeLoadingTests.swift +22 −0
  • native/crdt/examples/caret_fixture.rs +11 −0
  • native/scripts/test-document-window.py +1 −1
  • native/scripts/test-home-loading.py +36 −0
  • native/smoke/document-window/README.md +14 −0
  • native/smoke/document-window/Sources/DocsWindowCommands.swift +1 −0
  • native/smoke/document-window/Sources/DocsWindows.swift +1 −0
  • native/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)
}
}